Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/plugins/bound/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down
45 changes: 44 additions & 1 deletion tests/playwright/x-bound.spec.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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, `
<div x-data="{ show: false, value: '2' }">
<template x-if="show">
<select &value>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
</template>
</div>`);

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", () => {
Expand Down
Loading