diff --git a/assets/js/phoenix_live_view/dom.ts b/assets/js/phoenix_live_view/dom.ts index 5fa8dfa80f..7fc0a89752 100644 --- a/assets/js/phoenix_live_view/dom.ts +++ b/assets/js/phoenix_live_view/dom.ts @@ -696,6 +696,21 @@ const DOM = { ); }, + /** + * Returns true if the element is a Form-Associated Custom Element (FACE). + * Unlike native inputs, FACEs store their value in ElementInternals (via + * setFormValue), not in DOM attributes or children. Their light-DOM + * children and slotted elements must be patched normally by morphdom even + * while the element has focus. + */ + isFormAssociatedCustomElement(el: Element | EventTarget | null): boolean { + if (!(el instanceof HTMLElement) || !el.localName) return false; + const ctor = customElements.get(el.localName); + return ( + !!ctor && (ctor as { formAssociated?: boolean }).formAssociated === true + ); + }, + syncAttrsToProps(el) { if ( el instanceof HTMLInputElement && diff --git a/assets/js/phoenix_live_view/dom_patch.ts b/assets/js/phoenix_live_view/dom_patch.ts index c52e44a585..a6bd1563a0 100644 --- a/assets/js/phoenix_live_view/dom_patch.ts +++ b/assets/js/phoenix_live_view/dom_patch.ts @@ -474,6 +474,19 @@ export default class DOMPatch { fromEl.type !== "hidden" && !focusedSelectChanged ) { + // Form-Associated Custom Elements (FACEs) keep their value in + // ElementInternals, not in DOM children. Their light-DOM + // children and slotted elements must be patched by morphdom + // even while the host has focus. Returning the element instead + // of false tells morphdom to descend into children normally. + if (DOM.isFormAssociatedCustomElement(fromEl)) { + this.trackBeforeUpdated(fromEl, toEl); + DOM.mergeAttrs(fromEl, toEl); + DOM.syncAttrsToProps(fromEl); + updates.push(fromEl); + DOM.applyStickyOperations(fromEl); + return fromEl; + } this.trackBeforeUpdated(fromEl, toEl); DOM.mergeFocusedInput(fromEl, toEl); DOM.syncAttrsToProps(fromEl); diff --git a/test/e2e/support/issues/issue_4323.ex b/test/e2e/support/issues/issue_4323.ex new file mode 100644 index 0000000000..90c4e50080 --- /dev/null +++ b/test/e2e/support/issues/issue_4323.ex @@ -0,0 +1,112 @@ +defmodule Phoenix.LiveViewTest.E2E.Issue4323Live do + use Phoenix.LiveView + + def mount(_params, _session, socket) do + socket = + socket + |> assign(:counter, 0) + |> assign(:render_in_root, fn assigns -> + ~H""" + + """ + end) + + {:ok, socket} + end + + def handle_event("inc", _params, socket) do + {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} + end + + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + def render(assigns) do + ~H""" +
+ """ + end +end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 5c48cc2cf4..7ce0e96d10 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -218,6 +218,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do live "/4212", Issue4212Live live "/4290/a", Issue4290.ALive live "/4290/b", Issue4290.BLive + live "/4323", Issue4323Live live "/4325", Issue4325Live end end diff --git a/test/e2e/tests/issues/4323.spec.js b/test/e2e/tests/issues/4323.spec.js new file mode 100644 index 0000000000..e5e5c5ade5 --- /dev/null +++ b/test/e2e/tests/issues/4323.spec.js @@ -0,0 +1,118 @@ +import { test, expect } from "../../test-fixtures"; +import { syncLV, evalLV } from "../../utils"; + +// https://github.com/phoenixframework/phoenix_live_view/pull/4323 +// +// Form-Associated Custom Elements (FACEs) store their value in +// ElementInternals (via setFormValue), not in DOM children. When a FACE +// host has focus, morphdom should still descend into its light-DOM children +// to patch them normally. These tests cover the fix and verify no +// regressions for related scenarios. + +const incrementCounter = async (page) => { + await evalLV( + page, + "{:noreply, Phoenix.Component.assign(socket, :counter, socket.assigns.counter + 1)}", + ); + await syncLV(page); +}; + +test.describe("FACE children patching", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/issues/4323"); + await syncLV(page); + }); + + test("FACE host focused: light DOM children are patched", async ({ + page, + }) => { + await expect(page.locator("#case1-child")).toHaveText("count:0"); + + await page.locator("#case1").focus(); + await expect(page.locator("#case1")).toBeFocused(); + + await incrementCounter(page); + + await expect(page.locator("#case1-child")).toHaveText("count:1"); + }); + + test("FACE host focused with input child: children are patched", async ({ + page, + }) => { + await expect(page.locator("#case2-child")).toHaveText("count:0"); + + // Focus the FACE host, NOT the input inside it + await page.locator("#case2").focus(); + await expect(page.locator("#case2")).toBeFocused(); + + await incrementCounter(page); + + await expect(page.locator("#case2-child")).toHaveText("count:1"); + }); + + test("FACE with slotted input focused: input is protected, siblings patched", async ({ + page, + }) => { + await expect(page.locator("#case3-child")).toHaveText("count:0"); + + // Focus the slotted input (not the FACE host) + await page.locator("#case3-input").focus(); + await expect(page.locator("#case3-input")).toBeFocused(); + await page.locator("#case3-input").fill("user-typed"); + + await incrementCounter(page); + + // Input value is preserved by normal focus-skip + await expect(page.locator("#case3-input")).toHaveValue("user-typed"); + // Sibling children are still patched (FACE host is not focused) + await expect(page.locator("#case3-child")).toHaveText("count:1"); + }); + + test("FACE with delegatesFocus: light DOM children are patched", async ({ + page, + }) => { + await expect(page.locator("#case4-child")).toHaveText("count:0"); + + // Click the FACE host; delegatesFocus sends focus to the shadow input, + // but document.activeElement still returns the shadow host + await page.locator("#case4").click(); + const activeId = await page.evaluate(() => document.activeElement?.id); + expect(activeId).toBe("case4"); + + await incrementCounter(page); + + await expect(page.locator("#case4-child")).toHaveText("count:1"); + }); + + test("FACE with slot + delegatesFocus: slotted children are patched", async ({ + page, + }) => { + await expect(page.locator("#case6-child")).toHaveText("count:0"); + + // Click the FACE host; delegatesFocus sends focus to the shadow input, + // but document.activeElement returns the shadow host + await page.locator("#case6").click(); + const activeId = await page.evaluate(() => document.activeElement?.id); + expect(activeId).toBe("case6"); + + await incrementCounter(page); + + // Slotted light DOM children should be patched (FACE branch descends) + await expect(page.locator("#case6-child")).toHaveText("count:1"); + }); + + test("non-FACE custom element: children are patched normally", async ({ + page, + }) => { + await expect(page.locator("#case5-child")).toHaveText("count:0"); + + await page.locator("#case5").focus(); + await expect(page.locator("#case5")).toBeFocused(); + + await incrementCounter(page); + + // Non-FACE elements are not treated as editable inputs, + // so morphdom patches them normally regardless of focus + await expect(page.locator("#case5-child")).toHaveText("count:1"); + }); +});