`.
+> [!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-match` placed directly in an `x-case`
+> or `x-default` template, and to `x-match` placed directly in `x-if`. Put the nested templates
+> inside an element instead.
+
## Source code
You can find the source code for this plugin on GitHub:
diff --git a/src/plugins/when/README.md b/src/plugins/when/README.md
index 66ae371..9664843 100644
--- a/src/plugins/when/README.md
+++ b/src/plugins/when/README.md
@@ -89,6 +89,13 @@ The `x-when` directive can also be used with the directive `x-for` to conditiona
```
🚀 [Live demo | Alpine.js x-when: Multiple root elements with x-for](https://jsfiddle.net/rameel/cuoh3297/)
+> [!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-when` placed directly in `x-when`,
+> and to `x-when` placed directly in `x-if`. Put the nested templates inside an element instead.
+
## Source code
You can find the source code for this plugin on GitHub:
diff --git a/src/utilities/anchor_block.js b/src/utilities/anchor_block.js
index a194a7b..0db49db 100644
--- a/src/utilities/anchor_block.js
+++ b/src/utilities/anchor_block.js
@@ -11,46 +11,112 @@ 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 = () => nodes.findLast(node => node.parentNode);
+
+ Object.defineProperties(el, {
+ // x-for before alpinejs/alpine#4912 moves an x-if item together with
+ // its single rendered element and continues after it. Pretending to be x-if
+ // makes it move the last node of the block, and the observer brings
+ // the rest back. 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);
- }
- })
+ // Do not bring back nodes that were removed rather than moved
+ const attached = nodes.filter(node => node.parentNode);
+
+ // While the nodes keep their order, the block is left as is, along with
+ // the content rendered between them by a nested x-if, x-for or block
+ if (el.parentNode && !in_order(el, attached)) {
+ mutateDom(() => {
+ let prev = el;
+ for (let node of attached) {
+ prev.nextSibling === node || prev.after(node);
+ prev = node;
+ }
+ });
+ }
},
delete() {
+ // 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;
- mutateDom(() => {
- for (let node of nodes ?? []) {
- is_element(node) && destroyTree(node);
- node.remove();
- }
- });
+ 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;
}
}
+ mutateDom(() => {
+ for (let node of nodes) {
+ 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());
}
+// Checks that the nodes follow the anchor in the same parent, in their order
+function in_order(anchor, nodes) {
+ let prev = anchor;
+
+ for (let node of nodes) {
+ if (node.parentNode !== anchor.parentNode
+ || !(prev.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)) {
+ return false;
+ }
+
+ prev = node;
+ }
+
+ return true;
+}
+
function initialize() {
document.body._r_block ??= (() => {
const observer = new MutationObserver(mutations => {
+ const anchors = new Set();
+
+ // x-for moves the template and the last node of a block, and may swap
+ // its other nodes with the ones of other items. Only the nodes of the block
+ // itself are restored: like Alpine with nested templates in x-for items
+ // (alpinejs/alpine#4911), the content rendered for a nested x-if, x-for
+ // or block at the top level of the block is not kept in order.
for (let mutation of mutations) {
for (let node of mutation.addedNodes) {
- node._r_block?.update();
+ anchors.add(node._r_block ? node : node._r_anchor);
}
}
+
+ for (let anchor of anchors) {
+ anchor?._r_block?.update();
+ }
+
+ // 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 });
diff --git a/tests/playwright/anchor-block.spec.js b/tests/playwright/anchor-block.spec.js
index 28d5f88..f37ae31 100644
--- a/tests/playwright/anchor-block.spec.js
+++ b/tests/playwright/anchor-block.spec.js
@@ -1,80 +1,279 @@
import { expect, test } from "@playwright/test";
-import { set_html } from "./assets/utils";
+import { set_data, set_html } from "./assets/utils";
-for (const directive of ["fragment", "when", "match"]) {
+// Wraps the content in each directive built on anchor_block
+const blocks = {
+ "x-fragment": content => `
${content}`,
+ "x-when": content => `
${content}`,
+ "x-match": content => `
${content}`
+};
+
+const permutations = items => items.length
+ ? items.flatMap((item, i) => permutations(items.filter((_, j) => i !== j)).map(rest => [item, ...rest]))
+ : [[]];
+
+// Child nodes of the element as HTML, including templates, text and comments.
+// Templates are shown without their content and elements with their text only.
+const layout = locator => locator.evaluate(el => [...el.childNodes].map(node => {
+ const tag = node.nodeName.toLowerCase();
+
+ return node.nodeType === Node.TEXT_NODE ? node.data
+ : node.nodeType === Node.COMMENT_NODE ? ``
+ : tag === "template" ? "
"
+ : `<${tag}>${node.textContent}${tag}>`;
+}).join(""));
+
+for (const [directive, block] of Object.entries(blocks)) {
test(`anchor block: nested cleanup (${directive})`, async ({ page }) => {
const errors = [];
page.on("pageerror", e => errors.push(e.message));
- const content = `
- Before
- Term
-
-
- Description
-
-
- After
- `;
-
- const block = directive === "match"
- ? `${content}`
- : `${content}`;
-
await set_html(page, `
- `);
+
+
+ ${block(`
+ Before
+ - Term
+
+ - Description
+
+ After
+ `)}
+
+
`, { trim: true });
+
+ // Triggers the effect and the event listener
+ const probe = tick => page.evaluate(async tick => {
+ Alpine.$data(document.querySelector("div")).tick = tick;
+ window.dispatchEvent(new Event("probe"));
+ await Alpine.nextTick();
+
+ return { ...trace, destroyed: trace.destroyed.toSorted() };
+ }, tick);
await expect(page.locator("dt, dd")).toHaveText(["Term", "Description"]);
+ expect(await probe(1)).toEqual({ destroyed: [], effects: [0, 1], events: 1 });
- await page.evaluate(async () => {
- Alpine.$data(document.querySelector("#app")).tick = 1;
- window.dispatchEvent(new Event("cleanup-probe"));
- await Alpine.nextTick();
- });
+ // Alpine 3.14.2+ removes x-if and x-for clones this way, with its observer paused
+ await page.evaluate(() => Alpine.mutateDom(() => {
+ const anchor = document.querySelector("dl > template");
+ Alpine.destroyTree(anchor);
+ anchor.remove();
+ }));
- expect(await page.evaluate(() => window.trace)).toEqual({
- destroy: [],
- effects: [0, 1],
- events: 1
- });
+ // Including the text, comments and nested anchors
+ expect(await layout(page.locator("dl"))).toBe("");
+ expect(await probe(2)).toEqual({ destroyed: ["dd", "dt"], effects: [0, 1], events: 1 });
+ expect(errors).toEqual([]);
+ });
+}
- // Alpine 3.14.2+ cleans x-if/x-for clones with its observer disconnected
- await page.evaluate(async () => {
- const anchor = document.querySelector("#block");
+test("anchor block: removes nested x-if and x-for content", async ({ page }) => {
+ await set_html(page, `
+
+
+
+
+
+
+
+
+
+
`);
- Alpine.mutateDom(() => {
- Alpine.destroyTree(anchor);
- anchor.remove();
- });
+ await expect(page.locator("b, i")).toHaveText(["1", "2", "3", "4"]);
- await Alpine.nextTick();
- });
+ // Alpine before 3.14.2 leaves destroying the removed clones to its observer
+ await set_data(page.locator("div"), { show: false });
+ await expect(page.locator("b, i")).toHaveCount(0);
+});
- // Include text, comments and nested anchors, not just visible elements
- expect(await page.locator("#content").evaluate(el => el.childNodes.length)).toBe(0);
- expect(await page.evaluate(() => window.trace.destroy.toSorted())).toEqual(["dd", "dt"]);
+test("anchor block: keyed x-for reordering (x-fragment)", async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", e => errors.push(e.message));
- await page.evaluate(async () => {
- Alpine.$data(document.querySelector("#app")).tick = 2;
- window.dispatchEvent(new Event("cleanup-probe"));
- await Alpine.nextTick();
- });
+ await set_html(page, `
+
+
+ []
+
+
`, { trim: true });
+
+ // Like x-if, a block renders its content right after its template
+ const expected = items => "" + items
+ .map(n => `[${n}${n}]`)
+ .join("");
+
+ const dl = page.locator("dl");
+
+ // Marks the nodes to tell later whether reordering moved or recreated them
+ await dl.evaluate(dl => dl.childNodes.forEach(node => node.original = true));
+
+ // All transitions between the orders of four items
+ for (const from of permutations([1, 2, 3, 4])) {
+ for (const to of permutations([1, 2, 3, 4])) {
+ await set_data(dl, { items: from });
+ await set_data(dl, { items: to });
+ expect(await layout(dl), `${from} → ${to}`).toBe(expected(to));
+ }
+ }
+
+ expect(await dl.evaluate(dl => [...dl.childNodes].every(node => node.original))).toBe(true);
+
+ // Adding and removing the items
+ for (const items of [[1, 2, 3], [3, 2, 1], [3, 2, 1, 4], [3, 5, 2, 1, 4], [5, 1], [], [2, 5, 3, 1]]) {
+ await set_data(dl, { items });
+ expect(await layout(dl), `${items}`).toBe(expected(items));
+ }
+
+ // Restoring the order settles rather than triggers an observer feedback loop
+ const mutations = await dl.evaluate(dl => new Promise(resolve => {
+ let count = 0;
+ const observer = new MutationObserver(records => count += records.length);
+ observer.observe(dl, { childList: true });
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ observer.disconnect();
+ resolve(count);
+ }));
+ }));
+
+ expect(mutations).toBe(0);
+ expect(errors).toEqual([]);
+});
+
+// x-when and x-match render the same block as x-fragment, so a single pass
+// over the orders is enough here, along with hiding and showing the content
+for (const directive of ["x-when", "x-match"]) {
+ test(`anchor block: keyed x-for reordering (${directive})`, async ({ page }) => {
+ const errors = [];
+ page.on("pageerror", e => errors.push(e.message));
+
+ await set_html(page, `
+
+
+ ${blocks[directive](`[]`)}
+
+
`, { trim: true });
+
+ const expected = (items, show) => "" + items
+ .map(n => show ? `[${n}${n}]` : "")
+ .join("");
+
+ const dl = page.locator("dl");
+
+ for (const [items, show] of [
+ ...permutations([1, 2, 3, 4]).map(items => [items, true]),
- expect(await page.evaluate(() => ({ ...window.trace, destroy: window.trace.destroy.toSorted() }))).toEqual({
- destroy: ["dd", "dt"],
- effects: [0, 1],
- events: 1
- });
+ // Adding and removing the items
+ ...[[1, 2, 3], [3, 2, 1], [3, 2, 1, 4], [3, 5, 2, 1, 4], [5, 1], [], [2, 5, 3, 1]].map(items => [items, true]),
+
+ // Hiding and showing the content
+ ...[false, true, false, true].map(show => [[1, 3, 5, 2], show])
+ ]) {
+ await set_data(dl, { items, show });
+ expect(await layout(dl), `${items}, show: ${show}`).toBe(expected(items, show));
+ }
expect(errors).toEqual([]);
});
}
+
+test("anchor block: block containing keyed x-for of blocks", async ({ page }) => {
+ await set_html(page, `
+
+
+ Title
+
+ []
+
+
+
+
`, { trim: true });
+
+ await expect(page.locator("div")).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], [5, 1, 6, 2, 3], [2, 6, 7, 3]]) {
+ await set_data(page.locator("div"), { items });
+ await expect(page.locator("div")).toHaveText(`Title${items.map(n => `[${n}${n}]`).join("")}Footer`);
+ }
+});
+
+test("anchor block: restores moved nodes with a stationary anchor", async ({ page }) => {
+ await set_html(page, `
+
+
+ before
+
+ - Term
+ between
+ - Description
+ after
+
+
`, { trim: true });
+
+ const dl = page.locator("dl");
+ const expected = "beforeTermbetweenDescriptionafter";
+ await expect.poll(() => layout(dl)).toBe(expected);
+
+ // An element, a text and a comment moved to the end are brought back
+ for (const text of ["Term", "between", "marker"]) {
+ await dl.evaluate((dl, text) => dl.append([...dl.childNodes].find(node => node.textContent === text)), text);
+ await expect.poll(() => layout(dl), text).toBe(expected);
+ }
+});
+
+test("anchor block: preserves initial x-if and x-for content", async ({ page }) => {
+ await set_html(page, `
+
+
+ [
+ Content
+ |
+
+ ]
+
+
`, { trim: true });
+
+ await expect(page.locator("div")).toHaveText("[Content|123]");
+});
+
+// Like Alpine with nested templates in x-for items (alpinejs/alpine#4911), keyed x-for
+// does not keep in order the content of the templates placed directly in a block,
+// or of a block in x-if. Expected to pass once Alpine includes alpinejs/alpine#4912.
+for (const [name, item] of [
+ ["a block in a block", `
+
+ [
+ ]
+ `],
+ ["x-if in a block", `
+
+ [
+ ]
+ `],
+ ["x-for in a block", `
+
+ [
+ ]
+ `],
+ ["a block in x-if", `
+
+ []
+ `]
+]) {
+ test.fail(`anchor block: keyed x-for reordering with ${name}`, async ({ page }) => {
+ await set_html(page, `
+
+ ${item}
+
`, { trim: true });
+
+ await set_data(page.locator("div"), { items: [3, 2, 1] });
+ expect(await page.locator("div").textContent()).toBe("[33][22][11]");
+
+ await set_data(page.locator("div"), { items: [3, 4, 2, 1] });
+ expect(await page.locator("div").textContent()).toBe("[33][44][22][11]");
+ });
+}
diff --git a/tests/playwright/assets/utils.js b/tests/playwright/assets/utils.js
index 8c10ffe..5d8eb70 100644
--- a/tests/playwright/assets/utils.js
+++ b/tests/playwright/assets/utils.js
@@ -1,14 +1,37 @@
import { fileURLToPath } from "url";
import path from "path";
-export const set_html = async (page, tpl) => {
+// With trim, the text nodes are trimmed and the empty ones removed, including in templates,
+// so that the formatting of the markup does not add whitespace text nodes
+export const set_html = async (page, tpl, { trim = false } = {}) => {
const dirname = path.dirname(fileURLToPath(import.meta.url));
await page.goto(`file://${dirname}/page.html`);
await page.waitForSelector("body[ready]", { state: "attached", timeout: 1000 });
- await page.evaluate(template => {
- let root = document.getElementById("root");
- root.innerHTML = template;
- }, tpl);
+ await page.evaluate(({ html, trim }) => {
+ const trim_text = node => {
+ for (let child of [...node.childNodes]) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ child.data = child.data.trim();
+ child.data || child.remove();
+ }
+ else {
+ trim_text(child.nodeName === "TEMPLATE" ? child.content : child);
+ }
+ }
+ };
+
+ let template = document.createElement("template");
+ template.innerHTML = html;
+ trim && trim_text(template.content);
+
+ document.getElementById("root").replaceChildren(template.content);
+ }, { html: tpl, trim });
}
+
+// Assigns the data of the Alpine component and waits for the DOM to update
+export const set_data = (locator, data) => locator.evaluate(async (el, data) => {
+ Object.assign(Alpine.$data(el), data);
+ await Alpine.nextTick();
+}, data);