diff --git a/packages/docs/src/en/plugins/morph.md b/packages/docs/src/en/plugins/morph.md
index cc60d4a79..108158e65 100644
--- a/packages/docs/src/en/plugins/morph.md
+++ b/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 `
`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)
+
+
+## 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()`) |
diff --git a/packages/morph/src/index.js b/packages/morph/src/index.js
index 93c46b206..e159f9017 100644
--- a/packages/morph/src/index.js
+++ b/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 }
diff --git a/packages/morph/src/morph.js b/packages/morph/src/morph.js
index 8afd35d10..0a2eef861 100644
--- a/packages/morph/src/morph.js
+++ b/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) {
diff --git a/tests/cypress/integration/plugins/morph.spec.js b/tests/cypress/integration/plugins/morph.spec.js
index af16a5b9c..7cf0501f0 100644
--- a/tests/cypress/integration/plugins/morph.spec.js
+++ b/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`
+
+
Header
+
+
Original content
+
+
Footer
+
+ `],
+ ({ 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, '