diff --git a/src/plugins/bound/index.js b/src/plugins/bound/index.js index ebcb93d..ce8fdd6 100644 --- a/src/plugins/bound/index.js +++ b/src/plugins/bound/index.js @@ -177,7 +177,7 @@ function plugin({ directive, entangle, evaluateLater, mapAttributes, mutateDom, // will have no effect since there are no options yet. // Therefore, we use a small trick to set the value a bit later // when the "x-for" directive has finished its work. - setTimeout(() => { + const timer_id = setTimeout(() => { // if the value of the bound property is "null" or "undefined", // we initialize it with the value from the element. is_nullish(get_value()) && update_variable(); @@ -186,6 +186,10 @@ function plugin({ directive, entangle, evaluateLater, mapAttributes, mutateDom, cleanup(listen(el, "change", () => set_value(collect_selected_values(el)))); }); + // the element can be destroyed before the timer fires, + // and the effect and listener created after that would never be released. + cleanup(() => clearTimeout(timer_id)); + processed = true; break; } diff --git a/tests/playwright/x-bound.spec.js b/tests/playwright/x-bound.spec.js index 7b3674c..e546c2a 100644 --- a/tests/playwright/x-bound.spec.js +++ b/tests/playwright/x-bound.spec.js @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { set_html } from "./assets/utils"; +import { set_data, set_html } from "./assets/utils"; import { fileURLToPath } from "url"; import path from "path"; @@ -406,6 +406,49 @@ test.describe("x-bound: select", () => { await expect(page.locator("select")).toHaveValues(["Two", "Three"]); await expect(page.locator("span")).toHaveText(`["Two","Three"]`); }); + + test("removed before the deferred binding", async ({ page }) => { + await set_html(page, ` +
+ +
`); + + const root = page.locator("div"); + + // Render and remove the select within microtasks, before the binding's timer fires + const select = await root.evaluateHandle(async el => { + const data = Alpine.$data(el); + + data.show = true; + await new Promise(queueMicrotask); + + const select = el.querySelector("select"); + data.show = false; + await new Promise(resolve => setTimeout(resolve)); + + return select; + }); + + // A leaked effect would keep the removed select in sync with the property; + // without it, the select keeps its default value, the first option + await set_data(root, { value: "3" }); + expect(await select.evaluate(el => el.value)).toBe("1"); + + // A leaked listener would write the select's value back to the property. + // The value differs from "3", which a leaked effect would have already applied, + // so the check cannot pass just because the values match + await select.evaluate(el => { + el.value = "2"; + el.dispatchEvent(new Event("change")); + }); + expect(await root.evaluate(el => Alpine.$data(el).value)).toBe("3"); + }); }); test.describe("x-bound: contenteditable", () => {