From 650895d03da68b97b66c06ee0105fe78f8f2731b Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 13 Sep 2026 23:13:23 +0500 Subject: [PATCH] plugin/fragment: clean up nested fragments when removing a block Call destroyTree before removing block contents so nested fragments, effects and event listeners are cleaned up even when Alpine's mutation observer is paused. Fixes #63 --- src/plugins/fragment/index.js | 4 +- src/plugins/match/index.js | 3 +- src/plugins/when/index.js | 4 +- src/utilities/anchor_block.js | 11 ++-- tests/playwright/anchor-block.spec.js | 80 +++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests/playwright/anchor-block.spec.js diff --git a/src/plugins/fragment/index.js b/src/plugins/fragment/index.js index d6f44e0..3c52991 100644 --- a/src/plugins/fragment/index.js +++ b/src/plugins/fragment/index.js @@ -1,14 +1,14 @@ import { anchor_block } from "@/utilities/anchor_block"; import { is_template, warn } from "@/utilities/utils"; -function plugin({ addScopeToNode, directive, initTree, mutateDom }) { +function plugin({ addScopeToNode, directive, destroyTree, initTree, mutateDom }) { directive("fragment", (el, {}, { cleanup }) => { if (!is_template(el)) { warn("x-fragment can only be used on a 'template' tag"); return; } - anchor_block(el, el, { addScopeToNode, cleanup, initTree, mutateDom }); + anchor_block(el, el, { addScopeToNode, cleanup, destroyTree, initTree, mutateDom }); }); } diff --git a/src/plugins/match/index.js b/src/plugins/match/index.js index 16dc92e..43af1bf 100644 --- a/src/plugins/match/index.js +++ b/src/plugins/match/index.js @@ -2,7 +2,7 @@ import { anchor_block } from "@/utilities/anchor_block"; import { create_getter } from "@/utilities/evaluator"; import { is_template, warn } from "@/utilities/utils"; -function plugin({ addScopeToNode, directive, initTree, mutateDom }) { +function plugin({ addScopeToNode, directive, destroyTree, initTree, mutateDom }) { directive("match", (el, { }, { cleanup, effect, evaluateLater }) => { if (!is_template(el)) { warn("x-match can only be used on a 'template' tag"); @@ -33,6 +33,7 @@ function plugin({ addScopeToNode, directive, initTree, mutateDom }) { anchor_block(el, branch.el, { addScopeToNode, cleanup, + destroyTree, initTree, mutateDom }); diff --git a/src/plugins/when/index.js b/src/plugins/when/index.js index f02f7a2..85dd890 100644 --- a/src/plugins/when/index.js +++ b/src/plugins/when/index.js @@ -2,14 +2,14 @@ import { anchor_block } from "@/utilities/anchor_block"; import { create_getter } from "@/utilities/evaluator"; import { is_template, warn } from "@/utilities/utils"; -function plugin({ addScopeToNode, directive, initTree, mutateDom }) { +function plugin({ addScopeToNode, directive, destroyTree, initTree, mutateDom }) { directive("when", (el, { expression }, { cleanup, effect, evaluateLater }) => { if (!is_template(el)) { warn("x-when can only be used on a 'template' tag"); return; } - const activate = () => anchor_block(el, el, { addScopeToNode, cleanup, initTree, mutateDom }); + const activate = () => anchor_block(el, el, { addScopeToNode, cleanup, destroyTree, initTree, mutateDom }); const clear = () => el._r_block?.delete(); const get = create_getter(evaluateLater, expression); diff --git a/src/utilities/anchor_block.js b/src/utilities/anchor_block.js index 493c0a8..a194a7b 100644 --- a/src/utilities/anchor_block.js +++ b/src/utilities/anchor_block.js @@ -1,6 +1,6 @@ import { is_element, is_template } from "@/utilities/utils"; -export function anchor_block(el, template, { addScopeToNode, cleanup, initTree, mutateDom, scope = {} }) { +export function anchor_block(el, template, { addScopeToNode, cleanup, destroyTree, initTree, mutateDom, scope = {} }) { if (el._r_block) { return; } @@ -30,9 +30,12 @@ export function anchor_block(el, template, { addScopeToNode, cleanup, initTree, }, delete() { el._r_block = null; - for (let node of nodes ?? []) { - node.remove(); - } + mutateDom(() => { + for (let node of nodes ?? []) { + is_element(node) && destroyTree(node); + node.remove(); + } + }); nodes = null; } } diff --git a/tests/playwright/anchor-block.spec.js b/tests/playwright/anchor-block.spec.js new file mode 100644 index 0000000..28d5f88 --- /dev/null +++ b/tests/playwright/anchor-block.spec.js @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { set_html } from "./assets/utils"; + +for (const directive of ["fragment", "when", "match"]) { + test(`anchor block: nested cleanup (${directive})`, async ({ page }) => { + const errors = []; + page.on("pageerror", e => errors.push(e.message)); + + const content = ` + Before +
Term
+ `; + + const block = directive === "match" + ? `` + : ``; + + await set_html(page, ` +
+
${block}
+
`); + + await expect(page.locator("dt, dd")).toHaveText(["Term", "Description"]); + + await page.evaluate(async () => { + Alpine.$data(document.querySelector("#app")).tick = 1; + window.dispatchEvent(new Event("cleanup-probe")); + await Alpine.nextTick(); + }); + + expect(await page.evaluate(() => window.trace)).toEqual({ + destroy: [], + effects: [0, 1], + events: 1 + }); + + // Alpine 3.14.2+ cleans x-if/x-for clones with its observer disconnected + await page.evaluate(async () => { + const anchor = document.querySelector("#block"); + + Alpine.mutateDom(() => { + Alpine.destroyTree(anchor); + anchor.remove(); + }); + + await Alpine.nextTick(); + }); + + // 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"]); + + await page.evaluate(async () => { + Alpine.$data(document.querySelector("#app")).tick = 2; + window.dispatchEvent(new Event("cleanup-probe")); + await Alpine.nextTick(); + }); + + expect(await page.evaluate(() => ({ ...window.trace, destroy: window.trace.destroy.toSorted() }))).toEqual({ + destroy: ["dd", "dt"], + effects: [0, 1], + events: 1 + }); + + expect(errors).toEqual([]); + }); +}