From 41d471743ab022cda4daa84ef3ab710a3de84b95 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 18 Oct 2026 22:13:31 +0500 Subject: [PATCH 1/5] plugin/fragment: keep blocks in order under keyed x-for Since Alpine 3.15.9, keyed x-for moves only the item element and, for x-if, its single rendered element. The content of x-fragment, x-when and x-match was rendered before the template and was left behind, so reordering mixed up the blocks. Render the content after the template, like x-if, and expose its last node through _x_currentIfEl and _x_lastRenderedEl, so x-for treats the whole block as the item. The shared mutation observer then restores the content of every block in the affected parent right after its template, following nested blocks and x-if. The observer also handles removed nodes and the records produced by deleting nested blocks, and works with Alpine versions that do not expose the last rendered item of x-for or destroy x-if content. Fixes #62 --- src/utilities/anchor_block.js | 139 ++++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 16 deletions(-) diff --git a/src/utilities/anchor_block.js b/src/utilities/anchor_block.js index a194a7b..a0ff905 100644 --- a/src/utilities/anchor_block.js +++ b/src/utilities/anchor_block.js @@ -11,46 +11,153 @@ export function anchor_block(el, template, { addScopeToNode, cleanup, destroyTre ? [...template.content.cloneNode(true).childNodes] : [template.cloneNode(true)]; - mutateDom(() => { - for (let node of nodes) { - is_element(node) && addScopeToNode(node, scope, el); - el.parentElement.insertBefore(node, el); - is_element(node) && initTree(node); - } + // Like x-if, the content is rendered after the template, + // and the template exposes the last rendered node + const end = () => { + let node = nodes.findLast(node => node.parentNode); + return node && last_node(node); + }; + + Object.defineProperties(el, { + // x-for before alpinejs/alpine#4912 handles x-if items specially: it keeps + // the x-if content right after the item and continues after it. Pretending + // to be x-if makes it treat the whole block as the item, so the last item + // it reports in 3.16+ is the actual end of the list. Not needed once + // the minimum supported Alpine includes the fix. + _x_currentIfEl: { configurable: true, get: end }, + + // x-for with alpinejs/alpine#4912 follows it to move the whole block, + // morph to skip the content + _x_lastRenderedEl: { configurable: true, get: end } }); el._r_block = { template, update() { - mutateDom(() => { - for (let node of nodes ?? []) { - el.parentElement.insertBefore(node, el); + let last = el; + el.parentNode && mutateDom(() => { + for (let node of nodes) { + // Do not bring back nodes that were removed rather than moved + if (node.parentNode) { + last = restore(node, skip_rendered(last, el)); + } } - }) + }); + return last; }, delete() { - el._r_block = null; mutateDom(() => { - for (let node of nodes ?? []) { + el._r_block = null; + delete el._x_currentIfEl; + delete el._x_lastRenderedEl; + + for (let node of nodes) { + node._r_anchor = null; is_element(node) && destroyTree(node); node.remove(); } + + nodes = null; }); - nodes = null; } } + mutateDom(() => { + for (let node of nodes) { + // Set before initTree, so the observer can tell the nodes of nested blocks apart + node._r_anchor = el; + is_element(node) && addScopeToNode(node, scope, el); + } + + el.after(...nodes); + + for (let node of nodes) { + is_element(node) && initTree(node); + } + }); + cleanup(() => el._r_block?.delete()); } +// Places the node right after "prev", followed by the content rendered for it +// by a nested block or x-if, and returns the last placed node +function restore(node, prev) { + prev.nextSibling === node || prev.after(node); + + return node._r_block + ? node._r_block.update() + : node._x_currentIfEl + ? restore(node._x_currentIfEl, node) + : node; +} + +// x-for renders its items after its template and keeps them in order itself, +// so step over them up to the next node owned by the block +function skip_rendered(node, anchor) { + if (node._x_lookup) { + // Alpine below 3.16 does not expose the last item + const end = last_node(node); + const known = end !== node; + + while (node.nextSibling && node.nextSibling._r_anchor !== anchor && !(known && node === end)) { + node = node.nextSibling; + } + } + + return node; +} + +// Follows nested x-if, x-for and blocks to the last node rendered for the node +function last_node(node) { + while (true) { + const next = node._x_currentIfEl ?? node._x_lastRenderedEl; + + // Rendered content stays next to its template, so a detached + // or moved away node is no longer part of it + if (!next || next.parentNode !== node.parentNode) { + return node; + } + + node = next; + } +} + function initialize() { document.body._r_block ??= (() => { const observer = new MutationObserver(mutations => { - for (let mutation of mutations) { - for (let node of mutation.addedNodes) { - node._r_block?.update(); + const parents = new Set(); + + // Deleting a block can remove nested ones, so the records it produces are processed too + for (; mutations.length; mutations = observer.takeRecords()) { + for (let mutation of mutations) { + for (let list of [mutation.addedNodes, mutation.removedNodes]) { + for (let node of list) { + (node._r_block || node._r_anchor) && parents.add(mutation.target); + } + } + + for (let node of mutation.removedNodes) { + // Alpine before 3.14.2 removes x-if content without destroying it + // while its observer is paused, so the block would be left behind + node.isConnected || node._r_block?.delete(); + } } } + + // Moving one item, x-for may also move separate nodes of other blocks + // or insert the next item into the middle of a block. Rather than tracking + // the affected blocks, restore each block of the parent after its template, + // since x-for keeps the templates themselves in order. Nested blocks are + // restored by their parents. + for (let parent of parents) { + for (let node of [...parent.childNodes]) { + node._r_anchor || node._r_block?.update(); + } + } + + // Restoring only moves the nodes of the blocks themselves, + // so the records it produces need no further handling + observer.takeRecords(); }); observer.observe(document.body, { childList: true, subtree: true }); From 1c99b4d7c5676917035fd84e1b1bd0d05cd14551 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 19 Oct 2026 00:51:46 +0500 Subject: [PATCH 2/5] plugin/fragment: test block order under keyed x-for Cover reordering, adding, removing and toggling the items of a keyed x-for for x-fragment, x-when and x-match, checking the exact node layout and that the existing nodes are moved rather than recreated. For x-fragment, check all transitions between the orders of four items. Also cover nested blocks, x-if inside a block and vice versa, a block containing a keyed x-for of blocks, restoring a moved node of a block, keeping the initial content of nested x-if and x-for, and that the observer settles without a feedback loop. Co-Authored-By: Claude Opus 5.5 --- tests/playwright/anchor-block.spec.js | 178 ++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tests/playwright/anchor-block.spec.js b/tests/playwright/anchor-block.spec.js index 28d5f88..04ba369 100644 --- a/tests/playwright/anchor-block.spec.js +++ b/tests/playwright/anchor-block.spec.js @@ -78,3 +78,181 @@ for (const directive of ["fragment", "when", "match"]) { expect(errors).toEqual([]); }); } + +const permutations = items => items.length + ? items.flatMap((item, i) => permutations(items.filter((_, j) => i !== j)).map(rest => [item, ...rest])) + : [[]]; + +// Child nodes of the element, including templates, text and comments +const layout = locator => locator.evaluate(el => [...el.childNodes] + .filter(node => node.nodeType !== Node.TEXT_NODE || node.textContent.trim()) + .map(node => node.nodeName + ":" + (node.nodeName === "TEMPLATE" ? "" : node.textContent.trim()))); + +for (const directive of ["fragment", "when", "match"]) { + test(`anchor block: keyed x-for reordering (${directive})`, async ({ page }) => { + const errors = []; + page.on("pageerror", e => errors.push(e.message)); + + const content = `[
]`; + const block = { + fragment: ``, + when: ``, + match: `` + }[directive]; + + await set_html(page, `
`); + await expect(page.locator("dt")).toHaveCount(4); + + const orders = permutations([1, 2, 3, 4]); + const states = [ + // Adding, removing and toggling the content + ...[[1, 2, 3], [3, 2, 1], [3, 2, 1, 4], [3, 5, 2, 1, 4], [5, 1], [], [2, 5, 3, 1]].map(items => ({ items, show: true })), + ...(directive === "fragment" ? [] : [false, true, false, true].map(show => ({ items: [1, 3, 5, 2], show }))) + ]; + + const result = await page.locator("#block").evaluate(async (block, { orders, states, transitions }) => { + const data = Alpine.$data(block); + const terms = new Map([...block.querySelectorAll("dt")].map(dt => [dt.textContent, dt])); + + const check = async ({ items, show = true }, same_nodes) => { + data.items = items; + data.show = show; + await Alpine.nextTick(); + + // Like x-if, a block renders its content right after its template + const actual = [...block.childNodes].map(node => node.nodeName + ":" + (node.nodeName === "TEMPLATE" ? "" : node.textContent)); + const expected = ["TEMPLATE:", ...items.flatMap(n => show + ? ["TEMPLATE:", "#text:[", `DT:${n}`, "#comment:pair", `DD:${n}`, "#text:]"] + : ["TEMPLATE:"])]; + + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(JSON.stringify({ items, show, actual, expected })); + } + + if (same_nodes && [...block.querySelectorAll("dt")].some(dt => terms.get(dt.textContent) !== dt)) { + throw new Error(`Reordering must move the existing nodes: ${items}`); + } + }; + + let count = 0; + for (const from of orders) { + for (const to of transitions ? orders : [from]) { + await check({ items: from }, true); + await check({ items: to }, true); + count++; + } + } + + for (const state of states) { + await check(state, null); + } + + // Restoring the order must settle rather than trigger an observer feedback loop + let mutations = 0; + const observer = new MutationObserver(records => mutations += records.length); + observer.observe(block, { childList: true }); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + observer.disconnect(); + + return { count, mutations }; + }, { orders, states, transitions: directive === "fragment" }); + + // All transitions between the orders of four items for x-fragment + expect(result).toEqual({ count: directive === "fragment" ? 576 : 24, mutations: 0 }); + expect(errors).toEqual([]); + }); +} + +for (const [name, html, item] of [ + [ + "nested blocks", + ``, + (n, show) => ["[", `SPAN:${n}`, "(", `I:${n}`, ...(show ? ["{", `B:${n}`, "}"] : []), ")", "]"] + ], + [ + "x-if inside a block", + ``, + (n, show) => ["[", `DT:${n}`, ...(show ? [`DD:${n}`] : []), "]"] + ], + [ + "block inside x-if inside a block", + ``, + (n, show) => ["[", ...(show ? [`B:${n}`, "-", `I:${n}`] : []), "]"] + ], + [ + "block inside x-if", + ``, + (n, show) => show ? ["[", `DT:${n}`, `DD:${n}`, "]"] : [] + ] +]) { + test(`anchor block: keyed x-for reordering with ${name}`, async ({ page }) => { + const errors = []; + page.on("pageerror", e => errors.push(e.message)); + + await set_html(page, ` +
+

Before

+ +

After

+
`); + + const states = [ + ...permutations([1, 2, 3, 4]).map(items => ({ items, show: true })), + ...[[1, 2, 3], [3, 2, 1, 4], [3, 5, 2, 1, 4], [5, 1], [], [2, 5, 3, 1]].map(items => ({ items, show: true })), + ...[[1, 3, 5, 2], [2, 5, 3, 1], [2, 5, 3, 1], [5, 1, 2, 3]].map((items, i) => ({ items, show: i % 2 === 1 })) + ]; + + for (const { items, show } of [{ items: [1, 2, 3, 4], show: true }, ...states]) { + await page.locator("#block").evaluate(async (block, { items, show }) => { + Object.assign(Alpine.$data(block), { items, show }); + await Alpine.nextTick(); + }, { items, show }); + + const actual = (await layout(page.locator("#block"))).filter(token => token !== "TEMPLATE:").map(token => token.replace(/^#text:/, "")); + expect(actual, JSON.stringify({ items, show })).toEqual(["P:Before", ...items.flatMap(n => item(n, show)), "P:After"]); + } + + expect(errors).toEqual([]); + }); +} + +test("anchor block: block containing keyed x-for of blocks", async ({ page }) => { + await set_html(page, `
`); + await expect(page.locator("#block")).toHaveText("Title[11][22][33][44]Footer"); + + for (const items of [...permutations([1, 2, 3, 4]), [3, 2, 1], [2, 3, 1, 4], [4, 1], [], [1, 2, 3]]) { + await page.evaluate(items => Alpine.$data(document.querySelector("#block")).items = items, items); + await expect(page.locator("#block")).toHaveText(`Title${items.map(n => `[${n}${n}]`).join("")}Footer`); + } +}); + +for (const type of ["element", "text", "comment"]) { + test(`anchor block: restores moved ${type} with a stationary anchor`, async ({ page }) => { + await set_html(page, `
`); + await expect(page.locator("dt")).toHaveText("Term"); + + const moved = await page.locator("#block").evaluateHandle((block, type) => { + const nodeType = { element: Node.ELEMENT_NODE, text: Node.TEXT_NODE, comment: Node.COMMENT_NODE }[type]; + const node = [...block.childNodes].find(node => node.nodeType === nodeType && node.nodeName !== "TEMPLATE"); + block.append(node); + return node; + }, type); + + await expect.poll(() => layout(page.locator("#block"))).toEqual([ + "TEMPLATE:", "#text:before", "#comment:marker", "DT:Term", "#text:between", "DD:Description", "#text:after" + ]); + expect(await moved.evaluate(node => node.parentNode.id)).toBe("block"); + }); +} + +for (const [directive, content, expected] of [ + ["x-if", ``, "[Content]"], + ["x-for", ``, "[123]"] +]) { + test(`anchor block: preserves initial ${directive} content`, async ({ page }) => { + await set_html(page, `
`); + await expect(page.locator("#block")).toHaveText(expected); + }); +} From 54eb570e06f3edf107e9d37b5f68942c72147b4f Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 24 Oct 2026 20:39:24 +0500 Subject: [PATCH 3/5] plugin/fragment: keep block content in order under keyed x-for --- src/utilities/anchor_block.js | 143 ++++++++++++++++++++++---- tests/playwright/anchor-block.spec.js | 70 ++++++++++++- 2 files changed, 192 insertions(+), 21 deletions(-) diff --git a/src/utilities/anchor_block.js b/src/utilities/anchor_block.js index a0ff905..7b537d1 100644 --- a/src/utilities/anchor_block.js +++ b/src/utilities/anchor_block.js @@ -39,7 +39,7 @@ export function anchor_block(el, template, { addScopeToNode, cleanup, destroyTre for (let node of nodes) { // Do not bring back nodes that were removed rather than moved if (node.parentNode) { - last = restore(node, skip_rendered(last, el)); + last = restore(node, last); } } }); @@ -80,31 +80,82 @@ export function anchor_block(el, template, { addScopeToNode, cleanup, destroyTre } // Places the node right after "prev", followed by the content rendered for it -// by a nested block or x-if, and returns the last placed node +// by a nested block, x-if or x-for, and returns the last placed node function restore(node, prev) { prev.nextSibling === node || prev.after(node); - return node._r_block - ? node._r_block.update() - : node._x_currentIfEl - ? restore(node._x_currentIfEl, node) - : node; -} + if (node._r_block) { + return node._r_block.update(); + } + + if (node._x_currentIfEl) { + return restore(node._x_currentIfEl, node); + } + + let last = node; -// x-for renders its items after its template and keeps them in order itself, -// so step over them up to the next node owned by the block -function skip_rendered(node, anchor) { if (node._x_lookup) { - // Alpine below 3.16 does not expose the last item - const end = last_node(node); - const known = end !== node; + for (let item of x_for_items(node)) { + last = restore(item, last); + } + } + + return last; +} + +// Returns the items rendered by x-for in their order. "inserted" maps the items +// added since the last call to the node each one was first inserted after. +function x_for_items(node, inserted) { + const lookup = node._x_lookup; - while (node.nextSibling && node.nextSibling._r_anchor !== anchor && !(known && node === end)) { - node = node.nextSibling; + // Alpine before 3.15 keeps the keys in order + if (!(lookup instanceof Map)) { + return node._x_prevKeys.map(key => lookup[key]); + } + + // Alpine 3.15+ creates the lookup on each run, with the items it already had + // in order, followed by the added ones. Their order in the DOM can be broken + // by the outer x-for, or by another nested one moving its own scattered items, + // so each added item is placed after the node x-for inserted it after: the template, + // the previous item or its content. + if (node._r_lookup !== lookup) { + const known = new Set(node._r_items); + const items = [...lookup.values()]; + const next = new Map(); + + for (let item of items) { + known.has(item) || !inserted?.has(item) || next.set(inserted.get(item), item); } + + let order = items; + + if (next.size) { + order = []; + const follow = prev => { + for (let item; (item = next.get(prev)); prev = item) { + order.push(item); + } + }; + + follow(node); + for (let item of items) { + if (known.has(item)) { + order.push(item); + follow(item); + item._x_currentIfEl && follow(item._x_currentIfEl); + } + } + + // Not expected, but the items with an unknown position must not get lost + const placed = new Set(order); + order.push(...items.filter(item => !placed.has(item))); + } + + node._r_lookup = lookup; + node._r_items = order; } - return node; + return node._r_items; } // Follows nested x-if, x-for and blocks to the last node rendered for the node @@ -122,20 +173,55 @@ function last_node(node) { } } +// Alpine 3.15+ x-for puts each item after the previous one, swapping it with the node +// found there. Run in the same tick after the outer x-for, which leaves the blocks scattered, +// it would swap in nodes of other blocks or outside of them. So the pending mutations +// are processed, putting the blocks back in order, when x-for replaces the lookup +// at the start of each run, before it moves anything. +function watch(node, flush) { + let lookup = Object.getOwnPropertyDescriptor(node, "_x_lookup")?.value; + + lookup instanceof Map && Object.defineProperty(node, "_x_lookup", { + configurable: true, + get: () => lookup, + set(value) { + flush(); + lookup = value; + } + }); +} + function initialize() { document.body._r_block ??= (() => { - const observer = new MutationObserver(mutations => { + const observer = new MutationObserver(process); + + // Processes the mutations made so far, without waiting for the observer + const flush = () => process(observer.takeRecords()); + + function process(mutations) { const parents = new Set(); + const lists = new Set(); + const inserted = new Map(); // Deleting a block can remove nested ones, so the records it produces are processed too for (; mutations.length; mutations = observer.takeRecords()) { for (let mutation of mutations) { + // x-for moves the last node of the block right after its template, which + // may be all it does when the order stays the same. That node can belong + // to a nested x-for or x-if, rather than the block itself. + mutation.previousSibling?._r_block && parents.add(mutation.target); + for (let list of [mutation.addedNodes, mutation.removedNodes]) { for (let node of list) { (node._r_block || node._r_anchor) && parents.add(mutation.target); + node._x_refreshXForScope && lists.add(mutation.target); } } + for (let node of mutation.addedNodes) { + node._x_refreshXForScope && (inserted.has(node) || inserted.set(node, mutation.previousSibling)); + } + for (let node of mutation.removedNodes) { // Alpine before 3.14.2 removes x-if content without destroying it // while its observer is paused, so the block would be left behind @@ -144,6 +230,23 @@ function initialize() { } } + // Record the order of the items just added by x-for, while the records of their + // insertion are at hand. Since blocks render their content next to their templates, + // x-for of the blocks, including the ones rendered by x-if, share the parent with them. + // Updating the order after removals as well releases the removed items. + for (let parent of lists) { + const nodes = [...parent.childNodes]; + + if (nodes.some(node => node._r_block)) { + for (let node of nodes) { + if (node._x_lookup) { + x_for_items(node, inserted); + watch(node, flush); + } + } + } + } + // Moving one item, x-for may also move separate nodes of other blocks // or insert the next item into the middle of a block. Rather than tracking // the affected blocks, restore each block of the parent after its template, @@ -155,10 +258,10 @@ function initialize() { } } - // Restoring only moves the nodes of the blocks themselves, + // Restoring only puts the existing nodes back in place, // so the records it produces need no further handling observer.takeRecords(); - }); + } observer.observe(document.body, { childList: true, subtree: true }); return observer; diff --git a/tests/playwright/anchor-block.spec.js b/tests/playwright/anchor-block.spec.js index 04ba369..661637d 100644 --- a/tests/playwright/anchor-block.spec.js +++ b/tests/playwright/anchor-block.spec.js @@ -222,12 +222,80 @@ test("anchor block: block containing keyed x-for of blocks", async ({ page }) => await set_html(page, `
`); await expect(page.locator("#block")).toHaveText("Title[11][22][33][44]Footer"); - for (const items of [...permutations([1, 2, 3, 4]), [3, 2, 1], [2, 3, 1, 4], [4, 1], [], [1, 2, 3]]) { + for (const items of [...permutations([1, 2, 3, 4]), [3, 2, 1], [2, 3, 1, 4], [4, 1], [], [1, 2, 3], [5, 1, 6, 2, 3], [2, 6, 7, 3]]) { await page.evaluate(items => Alpine.$data(document.querySelector("#block")).items = items, items); await expect(page.locator("#block")).toHaveText(`Title${items.map(n => `[${n}${n}]`).join("")}Footer`); } }); +for (const [name, block, { ends = false, removes = true } = {}] of [ + ["containing keyed x-for of elements", list => `

${list(``)}
F
`], + ["containing keyed x-for of blocks", list => `

${list(``)}
F
`], + // Alpine before 3.14.2 leaves the x-for items behind when the block is removed + ["containing x-if of keyed x-for", list => `

F
`, { removes: false }], + ["ending with keyed x-for of elements", list => `

F
${list(``)}`, { ends: true }], + ["ending with keyed x-for of blocks", list => `

F
${list(``)}`, { ends: true }], + ["ending with x-if", () => `

F
`, { ends: true }] +]) { + test(`anchor block: keyed x-for of blocks ${name}`, async ({ page }) => { + const errors = []; + page.on("pageerror", e => errors.push(e.message)); + + const inner = lists => Object.fromEntries([1, 2, 3, 4, 5].map((o, i) => [o, lists[i] ?? [1, 2]])); + const text = ({ outer, inner }) => outer.map(o => { + const items = inner[o].map(n => `[${o}-${n}]`).join(""); + return ends ? `${o}F${items}` : `${o}${items}F`; + }).join(""); + + let state = { outer: [1, 2, 3, 4], inner: inner([]) }; + await set_html(page, ` +
+

After

+
`); + await expect(page.locator("#block")).toHaveText(text(state) + "After"); + + for (const change of [ + // Alpine 3.15+ moves the nested items apart even when the order stays the same + ...permutations([1, 2, 3, 4]).map(outer => ({ outer })), + + // Alpine 3.15+ lists the added items last in x-for lookup + { inner: inner([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1]]) }, + { outer: [4, 3, 2, 1] }, + { outer: [2, 4, 1, 3] }, + + // Nested x-for runs together with the outer one. Alpine 3.15 runs them in the order + // of the changes, 3.16+ always runs the outer one first, which leaves the blocks + // scattered for the nested one. + { inner: inner([[3, 0, 1, 2], [1, 4, 0], [5, 2, 6, 1, 0], [2, 1]]), outer: [3, 1, 4, 2] }, + { inner: inner([[2, 3, 0], [1, 4, 7, 0], [6, 1, 0, 2], [8, 2, 1]]), outer: [1, 3, 2, 4] }, + { outer: [4, 2, 1, 3], inner: inner([[0, 2, 9, 3], [4, 1], [6, 5, 1, 2], [2, 3, 1, 8]]) }, + { outer: [2, 1, 3, 4], inner: inner([[9, 0, 3], [1, 7, 4], [6, 2, 5, 1], [8, 3, 2, 1]]) }, + + // Adding and removing the blocks + ...removes ? [ + { outer: [3, 5, 1] }, + { inner: inner([[1, 3], [], [0, 6, 1], [], [4, 3, 5]]), outer: [5, 1, 3, 2] }, + { outer: [2, 3] }, + { outer: [] }, + { outer: [4, 2, 5] }, + + // With a single block, moving its last node after the template may be all x-for does + { outer: [5] }, + { outer: [5] } + ] : [] + ]) { + state = { ...state, ...change }; + + await page.evaluate(change => Object.assign(Alpine.$data(document.querySelector("#block")), change), change); + await expect(page.locator("#block"), JSON.stringify(change)).toHaveText(text(state) + "After"); + } + + expect(errors).toEqual([]); + }); +} + for (const type of ["element", "text", "comment"]) { test(`anchor block: restores moved ${type} with a stationary anchor`, async ({ page }) => { await set_html(page, `
`); From 9559921313a64fd0eeb14c515304d379e32d3a2a Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 24 Oct 2026 21:18:21 +0500 Subject: [PATCH 4/5] plugin/fragment: let Alpine destroy nested x-if and x-for of removed blocks Alpine before 3.14.2 removes x-if content and x-for items without destroying them, leaving that to its mutation observer. Deleting a block within mutateDom paused the observer, so the items of x-for inside x-if, and the content of x-if items of x-for, stayed in the DOM. Delete the block outside of mutateDom, which also makes deleting the blocks left behind from the shared observer unnecessary. --- src/utilities/anchor_block.js | 57 +++++++++++---------------- tests/playwright/anchor-block.spec.js | 26 ++++++------ 2 files changed, 36 insertions(+), 47 deletions(-) diff --git a/src/utilities/anchor_block.js b/src/utilities/anchor_block.js index 7b537d1..786e86c 100644 --- a/src/utilities/anchor_block.js +++ b/src/utilities/anchor_block.js @@ -46,19 +46,19 @@ export function anchor_block(el, template, { addScopeToNode, cleanup, destroyTre return last; }, delete() { - mutateDom(() => { - el._r_block = null; - delete el._x_currentIfEl; - delete el._x_lastRenderedEl; - - for (let node of nodes) { - node._r_anchor = null; - is_element(node) && destroyTree(node); - node.remove(); - } + // Not in mutateDom: Alpine before 3.14.2 removes nested x-if content + // and x-for items without destroying them, leaving that to its observer + el._r_block = null; + delete el._x_currentIfEl; + delete el._x_lastRenderedEl; + + for (let node of nodes) { + node._r_anchor = null; + is_element(node) && destroyTree(node); + node.remove(); + } - nodes = null; - }); + nodes = null; } } @@ -203,30 +203,21 @@ function initialize() { const lists = new Set(); const inserted = new Map(); - // Deleting a block can remove nested ones, so the records it produces are processed too - for (; mutations.length; mutations = observer.takeRecords()) { - for (let mutation of mutations) { - // x-for moves the last node of the block right after its template, which - // may be all it does when the order stays the same. That node can belong - // to a nested x-for or x-if, rather than the block itself. - mutation.previousSibling?._r_block && parents.add(mutation.target); - - for (let list of [mutation.addedNodes, mutation.removedNodes]) { - for (let node of list) { - (node._r_block || node._r_anchor) && parents.add(mutation.target); - node._x_refreshXForScope && lists.add(mutation.target); - } - } + for (let mutation of mutations) { + // x-for moves the last node of the block right after its template, which + // may be all it does when the order stays the same. That node can belong + // to a nested x-for or x-if, rather than the block itself. + mutation.previousSibling?._r_block && parents.add(mutation.target); - for (let node of mutation.addedNodes) { - node._x_refreshXForScope && (inserted.has(node) || inserted.set(node, mutation.previousSibling)); + for (let list of [mutation.addedNodes, mutation.removedNodes]) { + for (let node of list) { + (node._r_block || node._r_anchor) && parents.add(mutation.target); + node._x_refreshXForScope && lists.add(mutation.target); } + } - for (let node of mutation.removedNodes) { - // Alpine before 3.14.2 removes x-if content without destroying it - // while its observer is paused, so the block would be left behind - node.isConnected || node._r_block?.delete(); - } + for (let node of mutation.addedNodes) { + node._x_refreshXForScope && (inserted.has(node) || inserted.set(node, mutation.previousSibling)); } } diff --git a/tests/playwright/anchor-block.spec.js b/tests/playwright/anchor-block.spec.js index 661637d..f50978d 100644 --- a/tests/playwright/anchor-block.spec.js +++ b/tests/playwright/anchor-block.spec.js @@ -228,11 +228,11 @@ test("anchor block: block containing keyed x-for of blocks", async ({ page }) => } }); -for (const [name, block, { ends = false, removes = true } = {}] of [ +for (const [name, block, { ends = false } = {}] of [ ["containing keyed x-for of elements", list => `

${list(``)}
F
`], ["containing keyed x-for of blocks", list => `

${list(``)}
F
`], - // Alpine before 3.14.2 leaves the x-for items behind when the block is removed - ["containing x-if of keyed x-for", list => `

F
`, { removes: false }], + ["containing keyed x-for of x-if", list => `

${list(``)}
F
`], + ["containing x-if of keyed x-for", list => `

F
`], ["ending with keyed x-for of elements", list => `

F
${list(``)}`, { ends: true }], ["ending with keyed x-for of blocks", list => `

F
${list(``)}`, { ends: true }], ["ending with x-if", () => `

F
`, { ends: true }] @@ -274,17 +274,15 @@ for (const [name, block, { ends = false, removes = true } = {}] of [ { outer: [2, 1, 3, 4], inner: inner([[9, 0, 3], [1, 7, 4], [6, 2, 5, 1], [8, 3, 2, 1]]) }, // Adding and removing the blocks - ...removes ? [ - { outer: [3, 5, 1] }, - { inner: inner([[1, 3], [], [0, 6, 1], [], [4, 3, 5]]), outer: [5, 1, 3, 2] }, - { outer: [2, 3] }, - { outer: [] }, - { outer: [4, 2, 5] }, - - // With a single block, moving its last node after the template may be all x-for does - { outer: [5] }, - { outer: [5] } - ] : [] + { outer: [3, 5, 1] }, + { inner: inner([[1, 3], [], [0, 6, 1], [], [4, 3, 5]]), outer: [5, 1, 3, 2] }, + { outer: [2, 3] }, + { outer: [] }, + { outer: [4, 2, 5] }, + + // With a single block, moving its last node after the template may be all x-for does + { outer: [5] }, + { outer: [5] } ]) { state = { ...state, ...change }; From bf9b93372145ff9df7d7b4acfda947631f681523 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 25 Oct 2026 20:33:09 +0500 Subject: [PATCH 5/5] plugin/fragment: keep only the block's own nodes in order under x-for Alpine's keyed x-for does not keep nested templates of its items in order when the list changes (alpinejs/alpine#4911). The previous changes worked around this for templates placed directly in a block, which took most of anchor_block: tracking the items of nested x-for lists and intercepting their lookups. This is a limitation of Alpine itself, and alpinejs/alpine#4912 is expected to fix it, so we no longer work around it. anchor_block now restores only the block's own nodes, and only when they are out of order. Content that a nested x-if, x-for or block renders between them is left as is. It is never lost or duplicated, but it can end up out of order, as with nested templates in plain Alpine x-for items. Mark the nested template reordering cases as test.fail, so they start failing once Alpine includes alpinejs/alpine#4912 --- src/plugins/fragment/README.md | 20 + src/plugins/match/README.md | 8 + src/plugins/when/README.md | 7 + src/utilities/anchor_block.js | 213 ++--------- tests/playwright/anchor-block.spec.js | 511 ++++++++++++-------------- tests/playwright/assets/utils.js | 33 +- 6 files changed, 335 insertions(+), 457 deletions(-) diff --git a/src/plugins/fragment/README.md b/src/plugins/fragment/README.md index e073cbb..450dafc 100644 --- a/src/plugins/fragment/README.md +++ b/src/plugins/fragment/README.md @@ -93,6 +93,26 @@ multiple sibling elements for each iteration without wrapping them: ``` 🚀 [Live demo | Alpine.js x-fragment: Multiple root elements with x-for](https://jsfiddle.net/rameel/201rmntc/) +> [!NOTE] +> Alpine's `x-for` does not keep in order the content rendered by templates nested in its items, +> such as `x-for` directly in `x-for` or a template directly in `x-if` +> ([alpinejs/alpine#4911](https://github.com/alpinejs/alpine/issues/4911)). +> Inside `x-for`, the same applies to `x-if`, `x-for` or `x-fragment` placed directly in `x-fragment`, +> and to `x-fragment` placed directly in `x-if`. Put the nested templates inside an element, +> and use `x-when` instead of `x-if` with `x-fragment`: +> ```html +> +> ``` + ## Source code You can find the source code for this plugin on GitHub: diff --git a/src/plugins/match/README.md b/src/plugins/match/README.md index f5e38cf..73676f7 100644 --- a/src/plugins/match/README.md +++ b/src/plugins/match/README.md @@ -104,6 +104,14 @@ Here's an example demonstrating the use of `