1
0
Эх сурвалжийг харах

Make morphdom faster and more powerful (#3692)

* wip

* Fix clone for BC
Caleb Porzio 1 жил өмнө
parent
commit
938bb33350

+ 10 - 33
morph.html

@@ -5,44 +5,21 @@
 
 <div id="before">
 <!-- Before markup goes here: -->
-<div>
-    <div>
-    </div>
-
-    <button type="button" wire:click="$refresh" dusk="refresh">
-        Refresh
-    </button>
-
-    <div dusk="child" key="foo">
-        <input type="text">
-        Child
-    </div>
-
-    <div>
-    </div>
-</div>
+<ul>
+    <li data-key="1">foo<input></li>
+</ul>
 </div>
 
 <div id="after" style="display: none;">
 <!-- After markup goes here: -->
-<div>
-    <div>
-    </div>
-
-    <button type="button" wire:click="$refresh" dusk="refresh">
-        Refresh
-    </button>
-
-    <div dusk="child" key="foo">
-        <input type="text">
-        Child
-    </div>
-
-    <div>
-    </div>
+<ul>
+    <li data-key="2">bar<input></li>
+    <li data-key="3">baz<input></li>
+    <li data-key="1">foo<input></li>
+</ul>
 </div>
 
-</div>
+<div id="b"></div>
 
     <div style="display: flex;">
         <pre id="log-from"></pre>
@@ -69,7 +46,7 @@
             Alpine.morph(
                 document.querySelector('#before').firstElementChild,
                 document.querySelector('#after').firstElementChild.outerHTML,
-                { debug: true }
+                { debug: true, key(el) { return el.dataset.key } }
             )
         }
 

+ 3 - 2
packages/alpinejs/src/alpine.js

@@ -5,7 +5,7 @@ import { onElRemoved, onAttributeRemoved, onAttributesAdded, mutateDom, deferMut
 import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
-import { clone, skipDuringClone, onlyDuringClone } from './clone'
+import { clone, cloneNode, skipDuringClone, onlyDuringClone } from './clone'
 import { interceptor } from './interceptor'
 import { getBinding as bound, extractProp } from './utils/bind'
 import { debounce } from './utils/debounce'
@@ -68,7 +68,8 @@ let Alpine = {
     magic,
     store,
     start,
-    clone,
+    clone, // INTERNAL
+    cloneNode, // INTERNAL
     bound,
     $data,
     walk,

+ 45 - 2
packages/alpinejs/src/clone.js

@@ -12,22 +12,53 @@ export function onlyDuringClone(callback) {
     return (...args) => isCloning && callback(...args)
 }
 
-export function interuptCrawl(callback) {
-    return (...args) => isCloning || callback(...args)
+export function cloneNode(from, to)
+{
+    // Transfer over existing runtime Alpine state from
+    // the existing dom tree over to the new one...
+    if (from._x_dataStack) {
+        to._x_dataStack = from._x_dataStack
+
+        // Set a flag to signify the new tree is using
+        // pre-seeded state (used so x-data knows when
+        // and when not to initialize state)...
+        to.setAttribute('data-has-alpine-state', true)
+    }
+
+    isCloning = true
+
+    // We don't need reactive effects in the new tree.
+    // Cloning is just used to seed new server HTML with
+    // Alpine before "morphing" it onto live Alpine...
+    dontRegisterReactiveSideEffects(() => {
+        initTree(to, (el, callback) => {
+            // We're hijacking the "walker" so that we
+            // only initialize the element we're cloning...
+            callback(el, () => {})
+        })
+    })
+
+    isCloning = false
 }
 
+let isCloningLegacy = false
+
+/** deprecated */
 export function clone(oldEl, newEl) {
     if (! newEl._x_dataStack) newEl._x_dataStack = oldEl._x_dataStack
 
     isCloning = true
+    isCloningLegacy = true
 
     dontRegisterReactiveSideEffects(() => {
         cloneTree(newEl)
     })
 
     isCloning = false
+    isCloningLegacy = false
 }
 
+/** deprecated */
 export function cloneTree(el) {
     let hasRunThroughFirstEl = false
 
@@ -59,3 +90,15 @@ function dontRegisterReactiveSideEffects(callback) {
 
     overrideEffect(cache)
 }
+
+// If we are cloning a tree, we only want to evaluate x-data if another
+// x-data context DOESN'T exist on the component.
+// The reason a data context WOULD exist is that we graft root x-data state over
+// from the live tree before hydrating the clone tree.
+export function shouldSkipRegisteringDataDuringClone(el) {
+    if (! isCloning) return false
+    if (isCloningLegacy) return true
+
+    return el.hasAttribute('data-has-alpine-state')
+}
+

+ 2 - 6
packages/alpinejs/src/directives/x-data.js

@@ -2,7 +2,7 @@ import { directive, prefix } from '../directives'
 import { initInterceptors } from '../interceptor'
 import { injectDataProviders } from '../datas'
 import { addRootSelector } from '../lifecycle'
-import { isCloning } from '../clone'
+import { shouldSkipRegisteringDataDuringClone } from '../clone'
 import { addScopeToNode } from '../scope'
 import { injectMagics, magic } from '../magics'
 import { reactive } from '../reactivity'
@@ -11,11 +11,7 @@ import { evaluate } from '../evaluator'
 addRootSelector(() => `[${prefix('data')}]`)
 
 directive('data', ((el, { expression }, { cleanup }) => {
-    // If we are cloning a tree, we only want to evaluate x-data if another
-    // x-data context DOESN'T exist on the component.
-    // The reason a data context WOULD exist is that we graft root x-data state over
-    // from the live tree before hydrating the clone tree.
-    if (isCloning && el._x_dataStack) return;
+    if (shouldSkipRegisteringDataDuringClone(el)) return
 
     expression = expression === '' ? '{}' : expression
 

+ 0 - 85
packages/morph/src/dom.js

@@ -1,85 +0,0 @@
-
-export function createElement(html) {
-    const template = document.createElement('template')
-    template.innerHTML = html
-    return template.content.firstElementChild
-}
-
-export function textOrComment(el) {
-    return el.nodeType === 3
-        || el.nodeType === 8
-}
-
-export let dom = {
-    replace(children, old, replacement) {
-        // Here's what's happening here:
-        // First, we're swapping the actual dom element with the new one
-        // Then, we're replaceing the old one with the new one in the children array
-        // Finally, because the old has been replaced by the new, we can remove the previous new element in it's old position...
-        let index = children.indexOf(old)
-
-        let replacementIndex = children.indexOf(replacement)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        old.replaceWith(replacement)
-
-        children[index] = replacement
-
-        if (replacementIndex) {
-            children.splice(replacementIndex, 1)
-        }
-
-        return children
-    },
-    before(children, reference, subject) {
-        let index = children.indexOf(reference)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        reference.before(subject)
-
-        children.splice(index, 0, subject)
-
-        return children
-    },
-    append(children, subject, appendFn) {
-        let last = children[children.length - 1]
-
-        appendFn(subject)
-
-        children.push(subject)
-
-        return children
-    },
-    remove(children, subject) {
-        let index = children.indexOf(subject)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        subject.remove()
-
-        return children.filter(i => i !== subject)
-    },
-    first(children) {
-        return this.teleportTo(children[0])
-    },
-    next(children, reference) {
-        let index = children.indexOf(reference)
-
-        if (index === -1) return
-
-        return this.teleportTo(this.teleportBack(children[index + 1]))
-    },
-    teleportTo(el) {
-        if (! el) return el
-        if (el._x_teleport) return el._x_teleport
-        return el
-    },
-    teleportBack(el) {
-        if (! el) return el
-        if (el._x_teleportBack) return el._x_teleportBack
-        return el
-    }
-}
-

+ 155 - 83
packages/morph/src/morph.js

@@ -1,4 +1,3 @@
-import { dom, createElement, textOrComment} from './dom.js'
 
 let resolveStep = () => {}
 
@@ -13,19 +12,13 @@ export function morph(from, toHtml, options) {
 
     let fromEl
     let toEl
-    let key
-        ,lookahead
-        ,updating
-        ,updated
-        ,removing
-        ,removed
-        ,adding
-        ,added
+    let key, lookahead, updating, updated, removing, removed, adding, added
 
     function assignOptions(options = {}) {
         let defaultGetKey = el => el.getAttribute('key')
         let noop = () => {}
 
+        console.log(options.key)
         updating = options.updating || noop
         updated = options.updated || noop
         removing = options.removing || noop
@@ -37,25 +30,22 @@ export function morph(from, toHtml, options) {
     }
 
     function patch(from, to) {
-        // This is a time saver, however, it won't catch differences in nested <template> tags.
-        // I'm leaving this here as I believe it's an important speed improvement, I just
-        // don't see a way to enable it currently:
-        //
-        // if (from.isEqualNode(to)) return
-
         if (differentElementNamesTypesOrKeys(from, to)) {
-            // Swap elements...
-            return patchElement(from, to)
+            return swapElements(from, to)
         }
 
         let updateChildrenOnly = false
 
         if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
 
-        window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
+        // Initialize the server-side HTML element with Alpine...
+        if (from.nodeType === 1 && window.Alpine) {
+            window.Alpine.cloneNode(from, to)
+        }
 
         if (textOrComment(to)) {
             patchNodeValue(from, to)
+
             updated(from, to)
 
             return
@@ -67,9 +57,7 @@ export function morph(from, toHtml, options) {
 
         updated(from, to)
 
-        patchChildren(Array.from(from.childNodes), Array.from(to.childNodes), (toAppend) => {
-            from.appendChild(toAppend)
-        })
+        patchChildren(from, to)
     }
 
     function differentElementNamesTypesOrKeys(from, to) {
@@ -78,14 +66,14 @@ export function morph(from, toHtml, options) {
             || getKey(from) != getKey(to)
     }
 
-    function patchElement(from, to) {
+    function swapElements(from, to) {
         if (shouldSkip(removing, from)) return
 
         let toCloned = to.cloneNode(true)
 
         if (shouldSkip(adding, toCloned)) return
 
-        dom.replace([from], from, toCloned)
+        from.replaceWith(toCloned)
 
         removed(from)
         added(toCloned)
@@ -132,36 +120,37 @@ export function morph(from, toHtml, options) {
         }
     }
 
-    function patchChildren(fromChildren, toChildren, appendFn) {
-        let fromKeyDomNodeMap = keyToMap(fromChildren)
+    function patchChildren(from, to) {
+        let fromKeys = keyToMap(from.children)
         let fromKeyHoldovers = {}
 
-        let currentTo = dom.first(toChildren)
-        let currentFrom = dom.first(fromChildren)
+        let currentTo = getFirstNode(to)
+        let currentFrom = getFirstNode(from)
 
         while (currentTo) {
             let toKey = getKey(currentTo)
             let fromKey = getKey(currentFrom)
 
-            // Add new elements
+            // Add new elements...
             if (! currentFrom) {
                 if (toKey && fromKeyHoldovers[toKey]) {
                     // Add element (from key)...
                     let holdover = fromKeyHoldovers[toKey]
 
-                    fromChildren = dom.append(fromChildren, holdover, appendFn)
+                    from.appendChild(holdover)
+
                     currentFrom = holdover
                 } else {
                     if(! shouldSkip(adding, currentTo)) {
                         // Add element...
                         let clone = currentTo.cloneNode(true)
 
-                        fromChildren = dom.append(fromChildren, clone, appendFn)
+                        from.appendChild(clone)
 
                         added(clone)
                     }
 
-                    currentTo = dom.next(toChildren, currentTo)
+                    currentTo = getNextSibling(to, currentTo)
 
                     continue
                 }
@@ -172,54 +161,61 @@ export function morph(from, toHtml, options) {
             let isEnd = node => node && node.nodeType === 8 && node.textContent === ' __ENDBLOCK__ '
 
             if (isIf(currentTo) && isIf(currentFrom)) {
-                let newFromChildren = []
-                let appendPoint
                 let nestedIfCount = 0
+
+                let fromBlockStart = currentFrom
+
                 while (currentFrom) {
-                    let next = dom.next(fromChildren, currentFrom)
+                    let next = getNextSibling(from, currentFrom)
 
                     if (isIf(next)) {
                         nestedIfCount++
                     } else if (isEnd(next) && nestedIfCount > 0) {
                         nestedIfCount--
                     } else if (isEnd(next) && nestedIfCount === 0) {
-                        currentFrom = dom.next(fromChildren, next)
-                        appendPoint = next
+                        currentFrom = next
 
                         break;
                     }
 
-                    newFromChildren.push(next)
                     currentFrom = next
                 }
 
-                let newToChildren = []
+                let fromBlockEnd = currentFrom
+
                 nestedIfCount = 0
+
+                let toBlockStart = currentTo
+
                 while (currentTo) {
-                    let next = dom.next(toChildren, currentTo)
+                    let next = getNextSibling(to, currentTo)
 
                     if (isIf(next)) {
                         nestedIfCount++
                     } else if (isEnd(next) && nestedIfCount > 0) {
                         nestedIfCount--
                     } else if (isEnd(next) && nestedIfCount === 0) {
-                        currentTo = dom.next(toChildren, next)
+                        currentTo = next
 
                         break;
                     }
 
-                    newToChildren.push(next)
                     currentTo = next
                 }
 
-                patchChildren(newFromChildren, newToChildren, node => appendPoint.before(node))
+                let toBlockEnd = currentTo
+
+                let fromBlock = new Block(fromBlockStart, fromBlockEnd)
+                let toBlock = new Block(toBlockStart, toBlockEnd)
+
+                patchChildren(fromBlock, toBlock)
 
                 continue
             }
 
             // Lookaheads should only apply to non-text-or-comment elements...
             if (currentFrom.nodeType === 1 && lookahead && ! currentFrom.isEqualNode(currentTo)) {
-                let nextToElementSibling = dom.next(toChildren, currentTo)
+                let nextToElementSibling = getNextSibling(to, currentTo)
 
                 let found = false
 
@@ -227,12 +223,12 @@ export function morph(from, toHtml, options) {
                     if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) {
                         found = true; // This ";" needs to be here...
 
-                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
+                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
 
                         fromKey = getKey(currentFrom)
                     }
 
-                    nextToElementSibling = dom.next(toChildren, nextToElementSibling)
+                    nextToElementSibling = getNextSibling(to, nextToElementSibling)
                 }
             }
 
@@ -240,37 +236,37 @@ export function morph(from, toHtml, options) {
                 if (! toKey && fromKey) {
                     // No "to" key...
                     fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                    [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
-                    fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
-                    currentFrom = dom.next(fromChildren, currentFrom)
-                    currentTo = dom.next(toChildren, currentTo)
+                    currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                    fromKeyHoldovers[fromKey].remove()
+                    currentFrom = getNextSibling(from, currentFrom)
+                    currentTo = getNextSibling(to, currentTo)
 
                     continue
                 }
 
                 if (toKey && ! fromKey) {
-                    if (fromKeyDomNodeMap[toKey]) {
+                    if (fromKeys[toKey]) {
                         // No "from" key...
-                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyDomNodeMap[toKey])
-                        currentFrom = fromKeyDomNodeMap[toKey]
+                        currentFrom.replaceWith(fromKeys[toKey])
+                        currentFrom = fromKeys[toKey]
                     }
                 }
 
                 if (toKey && fromKey) {
-                    let fromKeyNode = fromKeyDomNodeMap[toKey]
+                    let fromKeyNode = fromKeys[toKey]
 
                     if (fromKeyNode) {
                         // Move "from" key...
                         fromKeyHoldovers[fromKey] = currentFrom
-                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyNode)
+                        currentFrom.replaceWith(fromKeyNode)
                         currentFrom = fromKeyNode
                     } else {
                         // Swap elements with keys...
                         fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
-                        fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
-                        currentFrom = dom.next(fromChildren, currentFrom)
-                        currentTo = dom.next(toChildren, currentTo)
+                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                        fromKeyHoldovers[fromKey].remove()
+                        currentFrom = getNextSibling(from, currentFrom)
+                        currentTo = getNextSibling(to, currentTo)
 
                         continue
                     }
@@ -278,12 +274,13 @@ export function morph(from, toHtml, options) {
             }
 
             // Get next from sibling before patching in case the node is replaced
-            let currentFromNext = currentFrom && dom.next(fromChildren, currentFrom)
+            let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom))
 
             // Patch elements
             patch(currentFrom, currentTo)
 
-            currentTo = currentTo && dom.next(toChildren, currentTo)
+            currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo))
+
             currentFrom = currentFromNext
         }
 
@@ -293,9 +290,10 @@ export function morph(from, toHtml, options) {
         // We need to collect the "removals" first before actually
         // removing them so we don't mess with the order of things.
         while (currentFrom) {
-            if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
+            if (! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
 
-            currentFrom = dom.next(fromChildren, currentFrom)
+            // currentFrom = dom.next(fromChildren, currentFrom)
+            currentFrom = getNextSibling(from, currentFrom)
         }
 
         // Now we can do the actual removals.
@@ -315,29 +313,29 @@ export function morph(from, toHtml, options) {
     function keyToMap(els) {
         let map = {}
 
-        els.forEach(el => {
+        for (let el of els) {
             let theKey = getKey(el)
 
             if (theKey) {
                 map[theKey] = el
             }
-        })
+        }
 
         return map
     }
 
-    function addNodeBefore(children, node, beforeMe) {
+    function addNodeBefore(parent, node, beforeMe) {
         if(! shouldSkip(adding, node)) {
             let clone = node.cloneNode(true)
 
-            children = dom.before(children, beforeMe, clone)
+            parent.insertBefore(clone, beforeMe)
 
             added(clone)
 
-            return [children, clone]
+            return clone
         }
 
-        return [children, node]
+        return node
     }
 
     // Finally we morph the element
@@ -347,12 +345,14 @@ export function morph(from, toHtml, options) {
     fromEl = from
     toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
 
-    // If there is no x-data on the element we're morphing,
-    // let's seed it with the outer Alpine scope on the page.
     if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
+        // Just in case a part of this template uses Alpine scope from somewhere
+        // higher in the DOM tree, we'll find that state and replace it on the root
+        // element so everything is synced up accurately.
         toEl._x_dataStack = window.Alpine.closestDataStack(from)
 
-        toEl._x_dataStack && window.Alpine.clone(from, toEl)
+        // We will kick off a clone on the root element.
+        toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
     }
 
     patch(from, toEl)
@@ -364,10 +364,9 @@ export function morph(from, toHtml, options) {
     return from
 }
 
-morph.step = () => resolveStep()
-morph.log = (theLogger) => {
-    logger = theLogger
-}
+// These are legacy holdovers that don't do anything anymore...
+morph.step = () => {}
+morph.log = () => {}
 
 function shouldSkip(hook, ...args) {
     let skip = false
@@ -377,18 +376,91 @@ function shouldSkip(hook, ...args) {
     return skip
 }
 
-function initializeAlpineOnTo(from, to, childrenOnly) {
-    if (from.nodeType !== 1) return
+let patched = false
+
+export function createElement(html) {
+    const template = document.createElement('template')
+    template.innerHTML = html
+    return template.content.firstElementChild
+}
+
+export function textOrComment(el) {
+    return el.nodeType === 3
+        || el.nodeType === 8
+}
+
+// "Block"s are used when morphing with conditional markers.
+// They allow us to patch isolated portions of a list of
+// siblings in a DOM tree...
+class Block {
+    constructor(start, end) {
+        // We're assuming here that the start and end caps are comment blocks...
+        this.startComment = start
+        this.endComment = end
+    }
+
+    get children() {
+        let children = [];
+
+        let currentNode = this.startComment.nextSibling
+
+        while (currentNode !== undefined && currentNode !== this.endComment) {
+            children.push(currentNode)
 
-    // If the element we are updating is an Alpine component...
-    if (from._x_dataStack) {
-        // Then temporarily clone it (with it's data) to the "to" element.
-        // This should simulate backend Livewire being aware of Alpine changes.
-        window.Alpine.clone(from, to)
+            currentNode = currentNode.nextSibling
+        }
+
+        return children
+    }
+
+    appendChild(child) {
+        this.endComment.before(child)
+    }
+
+    get firstChild() {
+        let first = this.startComment.nextSibling
+
+        if (first === this.endComment) return
+
+        return first
+    }
+
+    nextNode(reference) {
+        let next = reference.nextSibling
+
+        if (next === this.endComment) return
+
+        return next
+    }
+
+    insertBefore(newNode, reference) {
+        reference.before(newNode)
+
+        return newNode
     }
 }
 
-let patched = false
+function getFirstNode(parent) {
+    return parent.firstChild
+}
+
+function getNextSibling(parent, reference) {
+    if (reference._x_teleport) {
+        return reference._x_teleport
+    } else if (reference.teleportBack) {
+        return reference.teleportBack
+    }
+
+    let next
+
+    if (parent instanceof Block) {
+        next =  parent.nextNode(reference)
+    } else {
+        next = reference.nextSibling
+    }
+
+    return next
+}
 
 function monkeyPatchDomSetAttributeToAllowAtSymbols() {
     if (patched) return

+ 2 - 1
tests/cypress/integration/plugins/focus.spec.js

@@ -29,7 +29,7 @@ test('can trap focus',
     },
 )
 
-test('works with clone',
+test.only('works with clone',
     [html`
         <div id="foo" x-data="{
             open: false,
@@ -44,6 +44,7 @@ test('works with clone',
             }
         }">
             <button id="one" @click="open = true">Trap</button>
+
             <div x-trap="open">
                 <input type="text">
                 <button id="two" @click="triggerClone()">Test</button>