Bladeren bron

Add Alpine.morphBetween() (#4629)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
Caleb Porzio 1 maand geleden
bovenliggende
commit
b6fd7a9563

+ 12 - 0
packages/docs/src/en/plugins/morph.md

@@ -250,3 +250,15 @@ By adding keys to each node, we can accomplish this like so:
 Now that there are "keys" on the `<li>`s, Morph will match them in both trees and move them accordingly.
 
 You can configure what Morph considers a "key" with the `key:` configuration option. [More on that here](#lifecycle-hooks)
+
+<a name="alpine-morph-between"></a>
+## Alpine.morphBetween()
+
+The `Alpine.morphBetween(startMarker, endMarker, newHtml, options)` method allows you to morph a range of DOM nodes between two marker elements based on passed in HTML. This is useful when you want to update only a specific section of the DOM without providing a single root node.
+
+| Parameter | Description |
+| ---       | --- |
+| `startMarker` | A DOM node (typically a comment node) that marks the beginning of the range to morph |
+| `endMarker` | A DOM node (typically a comment node) that marks the end of the range to morph |
+| `newHtml` | A string of HTML or a DOM element to replace the content between the markers |
+| `options` | An object of options (same as `Alpine.morph()`) |

+ 3 - 2
packages/morph/src/index.js

@@ -1,7 +1,8 @@
-import { morph } from './morph'
+import { morph, morphBetween } from './morph'
 
 export default function (Alpine) {
     Alpine.morph = morph
+    Alpine.morphBetween = morphBetween
 }
 
-export { morph }
+export { morph, morphBetween }

+ 117 - 88
packages/morph/src/morph.js

@@ -1,4 +1,3 @@
-
 let resolveStep = () => {}
 
 let logger = () => {}
@@ -10,27 +9,80 @@ export function morph(from, toHtml, options) {
     // because it's an async function and if run twice, they would overwrite
     // each other.
 
-    let fromEl
-    let toEl
-    let key, lookahead, updating, updated, removing, removed, adding, added
-
-    function assignOptions(options = {}) {
-        let defaultGetKey = el => el.getAttribute('key')
-        let noop = () => {}
-
-        updating = options.updating || noop
-        updated = options.updated || noop
-        removing = options.removing || noop
-        removed = options.removed || noop
-        adding = options.adding || noop
-        added = options.added || noop
-        key = options.key || defaultGetKey
-        lookahead = options.lookahead || false
+    let context = createMorphContext(options)
+
+    // Finally we morph the element
+
+    let toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
+
+    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)
+
+        // We will kick off a clone on the root element.
+        toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
+    }
+
+    context.patch(from, toEl)
+
+    return from
+}
+
+export function morphBetween(startMarker, endMarker, toHtml, options = {}) {
+    monkeyPatchDomSetAttributeToAllowAtSymbols()
+
+    let context = createMorphContext(options)
+
+    // Setup from block...
+    let fromContainer = startMarker.parentNode
+    let fromBlock = new Block(startMarker, endMarker)
+
+    // Setup to block...
+    let toContainer = typeof toHtml === 'string'
+        ? (() => {
+            let container = document.createElement('div')
+            container.insertAdjacentHTML('beforeend', toHtml)
+            return container
+        })()
+        : toHtml
+
+    let toStartMarker = document.createComment('[morph-start]')
+    let toEndMarker = document.createComment('[morph-end]')
+
+    toContainer.insertBefore(toStartMarker, toContainer.firstChild)
+    toContainer.appendChild(toEndMarker)
+
+    let toBlock = new Block(toStartMarker, toEndMarker)
+
+    if (window.Alpine && window.Alpine.closestDataStack) {
+        toContainer._x_dataStack = window.Alpine.closestDataStack(fromContainer)
+        toContainer._x_dataStack && window.Alpine.cloneNode(fromContainer, toContainer)
+    }
+
+    // Run the patch
+    context.patchChildren(fromBlock, toBlock)
+}
+
+function createMorphContext(options = {}) {
+    let defaultGetKey = el => el.getAttribute('key')
+    let noop = () => {}
+
+    let context = {
+        key: options.key || defaultGetKey,
+        lookahead: options.lookahead || false,
+        updating: options.updating || noop,
+        updated: options.updated || noop,
+        removing: options.removing || noop,
+        removed: options.removed || noop,
+        adding: options.adding || noop,
+        added: options.added || noop
     }
 
-    function patch(from, to) {
-        if (differentElementNamesTypesOrKeys(from, to)) {
-            return swapElements(from, to)
+    context.patch = function(from, to) {
+        if (context.differentElementNamesTypesOrKeys(from, to)) {
+            return context.swapElements(from, to)
         }
 
         let updateChildrenOnly = false
@@ -40,60 +92,60 @@ export function morph(from, toHtml, options) {
         // hook to change. For example, when it was `shouldSkip()` the signature was `updating: (el, toEl, childrenOnly, skip)`. But if
         // we append `skipChildren()`, it would make the signature `updating: (el, toEl, childrenOnly, skipChildren, skip)`. This is
         // a breaking change due to how the `shouldSkip()` function is structured.
-        // 
-        // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()` 
+        //
+        // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()`
         // function as an earlier parameter and then append it to the `updating` hook signature manually. The signature of `updating`
         // hook is now `updating: (el, toEl, childrenOnly, skip, skipChildren)`.
-        if (shouldSkipChildren(updating, () => skipChildren = true, from, to, () => updateChildrenOnly = true)) return
+        if (shouldSkipChildren(context.updating, () => skipChildren = true, from, to, () => updateChildrenOnly = true)) return
 
         // Initialize the server-side HTML element with Alpine...
         if (from.nodeType === 1 && window.Alpine) {
             window.Alpine.cloneNode(from, to)
 
             if (from._x_teleport && to._x_teleport) {
-                patch(from._x_teleport, to._x_teleport)
+                context.patch(from._x_teleport, to._x_teleport)
             }
         }
 
         if (textOrComment(to)) {
-            patchNodeValue(from, to)
+            context.patchNodeValue(from, to)
 
-            updated(from, to)
+            context.updated(from, to)
 
             return
         }
 
         if (! updateChildrenOnly) {
-            patchAttributes(from, to)
+            context.patchAttributes(from, to)
         }
 
-        updated(from, to)
+        context.updated(from, to)
 
         if (! skipChildren) {
-            patchChildren(from, to)
+            context.patchChildren(from, to)
         }
     }
 
-    function differentElementNamesTypesOrKeys(from, to) {
+    context.differentElementNamesTypesOrKeys = function(from, to) {
         return from.nodeType != to.nodeType
             || from.nodeName != to.nodeName
-            || getKey(from) != getKey(to)
+            || context.getKey(from) != context.getKey(to)
     }
 
-    function swapElements(from, to) {
-        if (shouldSkip(removing, from)) return
+    context.swapElements = function(from, to) {
+        if (shouldSkip(context.removing, from)) return
 
         let toCloned = to.cloneNode(true)
 
-        if (shouldSkip(adding, toCloned)) return
+        if (shouldSkip(context.adding, toCloned)) return
 
         from.replaceWith(toCloned)
 
-        removed(from)
-        added(toCloned)
+        context.removed(from)
+        context.added(toCloned)
     }
 
-    function patchNodeValue(from, to) {
+    context.patchNodeValue = function(from, to) {
         let value = to.nodeValue
 
         if (from.nodeValue !== value) {
@@ -102,7 +154,7 @@ export function morph(from, toHtml, options) {
         }
     }
 
-    function patchAttributes(from, to) {
+    context.patchAttributes = function(from, to) {
         if (from._x_transitioning) return
 
         if (from._x_isShown && ! to._x_isShown) {
@@ -134,8 +186,8 @@ export function morph(from, toHtml, options) {
         }
     }
 
-    function patchChildren(from, to) {
-        let fromKeys = keyToMap(from.children)
+    context.patchChildren = function(from, to) {
+        let fromKeys = context.keyToMap(from.children)
         let fromKeyHoldovers = {}
 
         let currentTo = getFirstNode(to)
@@ -146,8 +198,8 @@ export function morph(from, toHtml, options) {
             // Let's transfer it to the "to" element so that there isn't a key mismatch...
             seedingMatchingId(currentTo, currentFrom)
 
-            let toKey = getKey(currentTo)
-            let fromKey = getKey(currentFrom)
+            let toKey = context.getKey(currentTo)
+            let fromKey = context.getKey(currentFrom)
 
             // Add new elements...
             if (! currentFrom) {
@@ -158,15 +210,15 @@ export function morph(from, toHtml, options) {
                     from.appendChild(holdover)
 
                     currentFrom = holdover
-                    fromKey = getKey(currentFrom)
+                    fromKey = context.getKey(currentFrom)
                 } else {
-                    if(! shouldSkip(adding, currentTo)) {
+                    if(! shouldSkip(context.adding, currentTo)) {
                         // Add element...
                         let clone = currentTo.cloneNode(true)
 
                         from.appendChild(clone)
 
-                        added(clone)
+                        context.added(clone)
                     }
 
                     currentTo = getNextSibling(to, currentTo)
@@ -227,13 +279,13 @@ export function morph(from, toHtml, options) {
                 let fromBlock = new Block(fromBlockStart, fromBlockEnd)
                 let toBlock = new Block(toBlockStart, toBlockEnd)
 
-                patchChildren(fromBlock, toBlock)
+                context.patchChildren(fromBlock, toBlock)
 
                 continue
             }
 
             // Lookaheads should only apply to non-text-or-comment elements...
-            if (currentFrom.nodeType === 1 && lookahead && ! currentFrom.isEqualNode(currentTo)) {
+            if (currentFrom.nodeType === 1 && context.lookahead && ! currentFrom.isEqualNode(currentTo)) {
                 let nextToElementSibling = getNextSibling(to, currentTo)
 
                 let found = false
@@ -242,9 +294,9 @@ export function morph(from, toHtml, options) {
                     if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) {
                         found = true; // This ";" needs to be here...
 
-                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                        currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
 
-                        fromKey = getKey(currentFrom)
+                        fromKey = context.getKey(currentFrom)
                     }
 
                     nextToElementSibling = getNextSibling(to, nextToElementSibling)
@@ -255,7 +307,7 @@ export function morph(from, toHtml, options) {
                 if (! toKey && fromKey) {
                     // No "to" key...
                     fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                    currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                    currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
                     fromKeyHoldovers[fromKey].remove()
                     currentFrom = getNextSibling(from, currentFrom)
                     currentTo = getNextSibling(to, currentTo)
@@ -268,7 +320,7 @@ export function morph(from, toHtml, options) {
                         // No "from" key...
                         currentFrom.replaceWith(fromKeys[toKey])
                         currentFrom = fromKeys[toKey]
-                        fromKey = getKey(currentFrom)
+                        fromKey = context.getKey(currentFrom)
                     }
                 }
 
@@ -280,11 +332,11 @@ export function morph(from, toHtml, options) {
                         fromKeyHoldovers[fromKey] = currentFrom
                         currentFrom.replaceWith(fromKeyNode)
                         currentFrom = fromKeyNode
-                        fromKey = getKey(currentFrom)
+                        fromKey = context.getKey(currentFrom)
                     } else {
                         // Swap elements with keys...
                         fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                        currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
                         fromKeyHoldovers[fromKey].remove()
                         currentFrom = getNextSibling(from, currentFrom)
                         currentTo = getNextSibling(to, currentTo)
@@ -298,7 +350,7 @@ export function morph(from, toHtml, options) {
             let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom))
 
             // Patch elements
-            patch(currentFrom, currentTo)
+            context.patch(currentFrom, currentTo)
 
             currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo))
 
@@ -311,7 +363,7 @@ 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(context.removing, currentFrom)) removals.push(currentFrom)
 
             // currentFrom = dom.next(fromChildren, currentFrom)
             currentFrom = getNextSibling(from, currentFrom)
@@ -323,19 +375,19 @@ export function morph(from, toHtml, options) {
 
             domForRemoval.remove()
 
-            removed(domForRemoval)
+            context.removed(domForRemoval)
         }
     }
 
-    function getKey(el) {
-        return el && el.nodeType === 1 && key(el)
+    context.getKey = function(el) {
+        return el && el.nodeType === 1 && context.key(el)
     }
 
-    function keyToMap(els) {
+    context.keyToMap = function(els) {
         let map = {}
 
         for (let el of els) {
-            let theKey = getKey(el)
+            let theKey = context.getKey(el)
 
             if (theKey) {
                 map[theKey] = el
@@ -345,13 +397,13 @@ export function morph(from, toHtml, options) {
         return map
     }
 
-    function addNodeBefore(parent, node, beforeMe) {
-        if(! shouldSkip(adding, node)) {
+    context.addNodeBefore = function(parent, node, beforeMe) {
+        if(! shouldSkip(context.adding, node)) {
             let clone = node.cloneNode(true)
 
             parent.insertBefore(clone, beforeMe)
 
-            added(clone)
+            context.added(clone)
 
             return clone
         }
@@ -359,30 +411,7 @@ export function morph(from, toHtml, options) {
         return node
     }
 
-    // Finally we morph the element
-
-    assignOptions(options)
-
-    fromEl = from
-    toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
-
-    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)
-
-        // We will kick off a clone on the root element.
-        toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
-    }
-
-    patch(from, toEl)
-
-    // Release these for the garbage collector.
-    fromEl = undefined
-    toEl = undefined
-
-    return from
+    return context
 }
 
 // These are legacy holdovers that don't do anything anymore...
@@ -399,7 +428,7 @@ function shouldSkip(hook, ...args) {
 
 // Due to the structure of the `shouldSkip()` function, we can't pass in the `skipChildren`
 // function as an argument as it would change the signature of the existing hooks. So we
-// are using this function instead which doesn't have this problem as we can pass the 
+// are using this function instead which doesn't have this problem as we can pass the
 // `skipChildren` function in as an earlier argument and then append it to the end
 // of the hook signature manually.
 function shouldSkipChildren(hook, skipChildren, ...args) {

+ 362 - 0
tests/cypress/integration/plugins/morph.spec.js

@@ -652,3 +652,365 @@ test('can morph teleports with root-level state',
         get('h1').should(haveText('bar'));
     },
 )
+
+test('can use morphBetween with comment markers',
+    [html`
+        <div>
+            <h2>Header</h2>
+            <!--start-->
+            <p>Original content</p>
+            <!--end-->
+            <h2>Footer</h2>
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find the comment markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'start') startMarker = node;
+            if (node.textContent === 'end') endMarker = node;
+        }
+
+        window.Alpine.morphBetween(startMarker, endMarker, '<p>New content</p><p>More content</p>')
+
+        get('h2:nth-of-type(1)').should(haveText('Header'))
+        get('h2:nth-of-type(2)').should(haveText('Footer'))
+        get('p').should(haveLength(2))
+        get('p:nth-of-type(1)').should(haveText('New content'))
+        get('p:nth-of-type(2)').should(haveText('More content'))
+    },
+)
+
+test('morphBetween preserves Alpine state',
+    [html`
+        <div x-data="{ count: 1 }">
+            <button @click="count++">Inc</button>
+            <!--morph-start-->
+            <p x-text="count"></p>
+            <input x-model="count">
+            <!--morph-end-->
+            <span>Static content</span>
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'morph-start') startMarker = node;
+            if (node.textContent === 'morph-end') endMarker = node;
+        }
+
+        get('p').should(haveText('1'))
+        get('button').click()
+        get('p').should(haveText('2'))
+
+        window.Alpine.morphBetween(startMarker, endMarker, `
+            <p x-text="count"></p>
+            <article>New element</article>
+            <input x-model="count">
+        `)
+
+        get('p').should(haveText('2'))
+        get('article').should(haveText('New element'))
+        get('input').should(haveValue('2'))
+        get('input').clear().type('5')
+        get('p').should(haveText('5'))
+    },
+)
+
+test('morphBetween with keyed elements',
+    [html`
+        <ul>
+            <!--items-start-->
+            <li key="1">foo<input></li>
+            <li key="2">bar<input></li>
+            <!--items-end-->
+        </ul>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'items-start') startMarker = node;
+            if (node.textContent === 'items-end') endMarker = node;
+        }
+
+        get('li:nth-of-type(1) input').type('first')
+        get('li:nth-of-type(2) input').type('second')
+
+        get('ul').then(([el]) => window.Alpine.morphBetween(startMarker, endMarker, `
+            <li key="3">baz<input></li>
+            <li key="1">foo<input></li>
+            <li key="2">bar<input></li>
+        `, { key(el) { return el.getAttribute('key') } }))
+
+        get('li').should(haveLength(3))
+        get('li:nth-of-type(1)').should(haveText('baz'))
+        get('li:nth-of-type(2)').should(haveText('foo'))
+        get('li:nth-of-type(3)').should(haveText('bar'))
+        // Need to verify by the key attribute since the elements have been reordered
+        get('li[key="1"] input').should(haveValue('first'))
+        get('li[key="2"] input').should(haveValue('second'))
+        get('li[key="3"] input').should(haveValue(''))
+    },
+)
+
+test('morphBetween with custom key function',
+    [html`
+        <div>
+            <!--start-->
+            <div data-id="a">Item A<input></div>
+            <div data-id="b">Item B<input></div>
+            <!--end-->
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'start') startMarker = node;
+            if (node.textContent === 'end') endMarker = node;
+        }
+
+        get('div[data-id="a"] input').type('aaa')
+        get('div[data-id="b"] input').type('bbb')
+
+        window.Alpine.morphBetween(startMarker, endMarker, `
+            <div data-id="b">Item B Updated<input></div>
+            <div data-id="c">Item C<input></div>
+            <div data-id="a">Item A Updated<input></div>
+        `, {
+            key(el) { return el.dataset.id }
+        })
+
+        get('div[data-id]').should(haveLength(3))
+        get('div[data-id="b"]').should(haveText('Item B Updated'))
+        get('div[data-id="a"]').should(haveText('Item A Updated'))
+        get('div[data-id="a"] input').should(haveValue('aaa'))
+        get('div[data-id="b"] input').should(haveValue('bbb'))
+        get('div[data-id="c"] input').should(haveValue(''))
+    },
+)
+
+test('morphBetween with hooks',
+    [html`
+        <div>
+            <!--region-start-->
+            <p>Old paragraph</p>
+            <span>Old span</span>
+            <!--region-end-->
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'region-start') startMarker = node;
+            if (node.textContent === 'region-end') endMarker = node;
+        }
+
+        let removedElements = []
+        let addedElements = []
+
+        window.Alpine.morphBetween(startMarker, endMarker, `
+            <p>New paragraph</p>
+            <article>New article</article>
+        `, {
+            removing(el) {
+                if (el.nodeType === 1) removedElements.push(el.tagName)
+            },
+            adding(el) {
+                if (el.nodeType === 1) addedElements.push(el.tagName)
+            }
+        })
+
+        get('p').should(haveText('New paragraph'))
+        get('article').should(haveText('New article'))
+
+        // Check hooks were called
+        cy.wrap(removedElements).should('deep.equal', ['SPAN'])
+        cy.wrap(addedElements).should('deep.equal', ['ARTICLE'])
+    },
+)
+
+test('morphBetween with empty content',
+    [html`
+        <div>
+            <h3>Title</h3>
+            <!--content-start-->
+            <p>Content 1</p>
+            <p>Content 2</p>
+            <!--content-end-->
+            <h3>End</h3>
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'content-start') startMarker = node;
+            if (node.textContent === 'content-end') endMarker = node;
+        }
+
+        window.Alpine.morphBetween(startMarker, endMarker, '')
+
+        get('h3').should(haveLength(2))
+        get('p').should(haveLength(0))
+
+        // Verify markers are still there
+        let found = false;
+        const walker2 = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+        while (node = walker2.nextNode()) {
+            if (node.textContent === 'content-start' || node.textContent === 'content-end') {
+                found = true;
+            }
+        }
+        cy.wrap(found).should('be.true')
+    },
+)
+
+test('morphBetween with nested Alpine components',
+    [html`
+        <div x-data="{ outer: 'foo' }">
+            <!--nested-start-->
+            <div x-data="{ inner: 'bar' }">
+                <span x-text="outer"></span>
+                <span x-text="inner"></span>
+                <input x-model="inner">
+            </div>
+            <!--nested-end-->
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'nested-start') startMarker = node;
+            if (node.textContent === 'nested-end') endMarker = node;
+        }
+
+        get('span:nth-of-type(1)').should(haveText('foo'))
+        get('span:nth-of-type(2)').should(haveText('bar'))
+        get('input').clear().type('baz')
+        get('span:nth-of-type(2)').should(haveText('baz'))
+
+        window.Alpine.morphBetween(startMarker, endMarker, `
+            <div x-data="{ inner: 'bar' }">
+                <h4>New heading</h4>
+                <span x-text="outer"></span>
+                <span x-text="inner"></span>
+                <input x-model="inner">
+            </div>
+        `)
+
+        get('h4').should(haveText('New heading'))
+        get('span:nth-of-type(1)').should(haveText('foo'))
+        get('span:nth-of-type(2)').should(haveText('baz'))
+        get('input').should(haveValue('baz'))
+    },
+)
+
+test('morphBetween with conditional blocks',
+    [html`
+        <main>
+            <!--section-start-->
+            <!--[if BLOCK]><![endif]-->
+            <div>conditional content<input></div>
+            <!--[if ENDBLOCK]><![endif]-->
+            <p>regular content<input></p>
+            <!--section-end-->
+        </main>
+    `],
+    ({ get }, reload, window, document) => {
+        // Find markers
+        let startMarker, endMarker;
+        const walker = document.createTreeWalker(
+            document.body,
+            NodeFilter.SHOW_COMMENT,
+            null,
+            false
+        );
+
+        let node;
+        while (node = walker.nextNode()) {
+            if (node.textContent === 'section-start') startMarker = node;
+            if (node.textContent === 'section-end') endMarker = node;
+        }
+
+        get('div input').type('div-value')
+        get('p input').type('p-value')
+
+        window.Alpine.morphBetween(startMarker, endMarker, `
+            <!--[if BLOCK]><![endif]-->
+            <div>conditional content<input></div>
+            <span>new conditional<input></span>
+            <!--[if ENDBLOCK]><![endif]-->
+            <p>regular content<input></p>
+        `)
+
+        get('div input').should(haveValue('div-value'))
+        get('span input').should(haveValue(''))
+        get('p input').should(haveValue('p-value'))
+    },
+)