Skip to content
Open
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
15 changes: 15 additions & 0 deletions assets/js/phoenix_live_view/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
13 changes: 13 additions & 0 deletions assets/js/phoenix_live_view/dom_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
112 changes: 112 additions & 0 deletions test/e2e/support/issues/issue_4323.ex
Original file line number Diff line number Diff line change
@@ -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"""
<script>
// Case 1 & 2: Basic FACE, no shadow DOM
class FaceBasic extends HTMLElement {
static formAssociated = true;
constructor() { super(); this.attachInternals(); }
}
customElements.define("face-basic", FaceBasic);

// Case 3: FACE with shadow DOM + slot (no delegatesFocus)
class FaceSlotted extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachInternals();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = "<div><slot></slot></div>";
}
}
customElements.define("face-slotted", FaceSlotted);

// Case 4: FACE with shadow DOM + delegatesFocus
class FaceDelegates extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachInternals();
this.attachShadow({ mode: "open", delegatesFocus: true });
this.shadowRoot.innerHTML = '<input type="text" />';
}
}
customElements.define("face-delegates", FaceDelegates);

// Case 5: Non-FACE custom element
class NonFaceEl extends HTMLElement {}
customElements.define("non-face-el", NonFaceEl);

// Case 6: FACE with shadow DOM + slot + delegatesFocus
class FaceSlottedDelegates extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachInternals();
this.attachShadow({ mode: "open", delegatesFocus: true });
this.shadowRoot.innerHTML = '<input type="text" /><div><slot></slot></div>';
}
}
customElements.define("face-slotted-delegates", FaceSlottedDelegates);
</script>
"""
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"""
<form id="test-form" phx-change="validate">
<%!-- Case 1: Basic FACE, focusable via tabindex --%>
<face-basic id="case1" tabindex="0">
<span id="case1-child">count:{@counter}</span>
</face-basic>

<%!-- Case 2: FACE with light DOM input child --%>
<face-basic id="case2" tabindex="0">
<span id="case2-child">count:{@counter}</span>
<input id="case2-input" type="text" name="case2_input" />
</face-basic>

<%!-- Case 3: FACE with slotted input --%>
<face-slotted id="case3">
<input id="case3-input" type="text" name="case3_input" />
<span id="case3-child">count:{@counter}</span>
</face-slotted>

<%!-- Case 4: FACE with delegatesFocus --%>
<face-delegates id="case4">
<span id="case4-child">count:{@counter}</span>
</face-delegates>

<%!-- Case 5: Non-FACE custom element --%>
<non-face-el id="case5" tabindex="0">
<span id="case5-child">count:{@counter}</span>
</non-face-el>

<%!-- Case 6: FACE with slot + delegatesFocus --%>
<face-slotted-delegates id="case6">
<input id="case6-input" type="text" name="case6_input" />
<span id="case6-child">count:{@counter}</span>
</face-slotted-delegates>

<button id="inc-btn" type="button" phx-click="inc">Increment</button>
</form>
"""
end
end
1 change: 1 addition & 0 deletions test/e2e/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions test/e2e/tests/issues/4323.spec.js
Original file line number Diff line number Diff line change
@@ -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");
});
});