From 76d74c83f52cfd1419f80411868d996e444654c6 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 26 Oct 2026 02:10:18 +0500 Subject: [PATCH] plugin/bound: reapply the select value when its options change x-bound applied the bound value to a were affected the same way. Observe the options and apply the bound value again whenever they change. The deferred start stays: when the property is null or undefined, it is initialized from the element, and before Alpine initializes the options that value is wrong, e.g. an option with :value="0" would give its text instead of 0. --- src/plugins/bound/index.js | 25 +++++++----- tests/playwright/x-bound.spec.js | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/plugins/bound/index.js b/src/plugins/bound/index.js index ce8fdd6..a93a85a 100644 --- a/src/plugins/bound/index.js +++ b/src/plugins/bound/index.js @@ -170,24 +170,29 @@ function plugin({ directive, entangle, evaluateLater, mapAttributes, mutateDom, break; case "SELECT": - // WORKAROUND: - // For the "select" element, there might be a situation - // where options are generated dynamically using the "x-for" directive, - // and in this case, attempting to set the "value" property - // 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. + // Alpine initializes the options after this directive runs: + // "x-for" renders them and ":value" binds their values. + // The value read from the element before that would be wrong, + // so the binding starts a bit later. const timer_id = setTimeout(() => { + const apply = () => apply_select_values(el, as_array(get_value() ?? [])); + // 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(); - effect(() => apply_select_values(el, as_array(get_value() ?? []))); + effect(apply); cleanup(listen(el, "change", () => set_value(collect_selected_values(el)))); + + // the options and their values can change later, e.g. when + // the "x-for" items do, so the selection is applied again + const observer = new MutationObserver(apply); + observer.observe(el, { childList: true, subtree: true, characterData: true, attributeFilter: ["value"] }); + cleanup(() => observer.disconnect()); }); - // the element can be destroyed before the timer fires, - // and the effect and listener created after that would never be released. + // the element can be destroyed before the timer fires, and the effect, + // listener and observer created after that would never be released. cleanup(() => clearTimeout(timer_id)); processed = true; diff --git a/tests/playwright/x-bound.spec.js b/tests/playwright/x-bound.spec.js index e546c2a..f5e1be3 100644 --- a/tests/playwright/x-bound.spec.js +++ b/tests/playwright/x-bound.spec.js @@ -331,6 +331,72 @@ test.describe("x-bound: select", () => { await expect(page.locator("span")).toHaveText("1"); }); + test("select (options rendered later)", async ({ page }) => { + await set_html(page, ` +
+ +
`); + + const root = page.locator("div"); + + await set_data(root, { options: ["1", "2", "3"] }); + await expect(page.locator("select")).toHaveValue("2"); + + // Without its option the select shows the first one, + // while the property keeps its value until the option is back + await set_data(root, { options: ["1", "3"] }); + await expect(page.locator("select")).toHaveValue("1"); + + await set_data(root, { options: ["1", "2", "3"] }); + await expect(page.locator("select")).toHaveValue("2"); + expect(await root.evaluate(el => Alpine.$data(el).value)).toBe("2"); + }); + + test("select (option values change)", async ({ page }) => { + await set_html(page, ` +
+ +
`); + + await expect(page.locator("select")).toHaveValue("2"); + + // The selected option gets the value "1", and the first option + // gets the value "2", which becomes selected instead + await page.locator("div").evaluate(async el => { + const { options } = Alpine.$data(el); + [options[0].id, options[1].id] = ["2", "1"]; + await Alpine.nextTick(); + }); + + await expect(page.locator("select")).toHaveValue("2"); + }); + + test("select multiple (options rendered later)", async ({ page }) => { + await set_html(page, ` +
+ +
`); + + await expect(page.locator("select")).toHaveValues(["2"]); + + await set_data(page.locator("div"), { options: ["1", "2", "3"] }); + await expect(page.locator("select")).toHaveValues(["2", "3"]); + }); + test("initialize from element when property is null", async ({ page }) => { await set_html(page, `