Skip to content

Commit 04a8e2a

Browse files
carlosescriclaude
andcommitted
test: add e2e tests for FACE children patching
Five scenarios covering Form-Associated Custom Element morphdom behavior: basic FACE with focus, FACE with input child, slotted input with focus, delegatesFocus, and non-FACE control case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ca8d77 commit 04a8e2a

3 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
defmodule Phoenix.LiveViewTest.E2E.Issue4323Live do
2+
use Phoenix.LiveView
3+
4+
def mount(_params, _session, socket) do
5+
socket =
6+
socket
7+
|> assign(:counter, 0)
8+
|> assign(:render_in_root, fn assigns ->
9+
~H"""
10+
<script>
11+
// Case 1 & 2: Basic FACE, no shadow DOM
12+
class FaceBasic extends HTMLElement {
13+
static formAssociated = true;
14+
constructor() { super(); this.attachInternals(); }
15+
}
16+
customElements.define("face-basic", FaceBasic);
17+
18+
// Case 3: FACE with shadow DOM + slot (no delegatesFocus)
19+
class FaceSlotted extends HTMLElement {
20+
static formAssociated = true;
21+
constructor() {
22+
super();
23+
this.attachInternals();
24+
this.attachShadow({ mode: "open" });
25+
this.shadowRoot.innerHTML = "<div><slot></slot></div>";
26+
}
27+
}
28+
customElements.define("face-slotted", FaceSlotted);
29+
30+
// Case 4: FACE with shadow DOM + delegatesFocus
31+
class FaceDelegates extends HTMLElement {
32+
static formAssociated = true;
33+
constructor() {
34+
super();
35+
this.attachInternals();
36+
this.attachShadow({ mode: "open", delegatesFocus: true });
37+
this.shadowRoot.innerHTML = '<input type="text" />';
38+
}
39+
}
40+
customElements.define("face-delegates", FaceDelegates);
41+
42+
// Case 5: Non-FACE custom element
43+
class NonFaceEl extends HTMLElement {}
44+
customElements.define("non-face-el", NonFaceEl);
45+
</script>
46+
"""
47+
end)
48+
49+
{:ok, socket}
50+
end
51+
52+
def handle_event("inc", _params, socket) do
53+
{:noreply, assign(socket, :counter, socket.assigns.counter + 1)}
54+
end
55+
56+
def handle_event("validate", _params, socket) do
57+
{:noreply, socket}
58+
end
59+
60+
def render(assigns) do
61+
~H"""
62+
<form id="test-form" phx-change="validate">
63+
<%!-- Case 1: Basic FACE, focusable via tabindex --%>
64+
<face-basic id="case1" tabindex="0">
65+
<span id="case1-child">count:{@counter}</span>
66+
</face-basic>
67+
68+
<%!-- Case 2: FACE with light DOM input child --%>
69+
<face-basic id="case2" tabindex="0">
70+
<span id="case2-child">count:{@counter}</span>
71+
<input id="case2-input" type="text" name="case2_input" />
72+
</face-basic>
73+
74+
<%!-- Case 3: FACE with slotted input --%>
75+
<face-slotted id="case3">
76+
<input id="case3-input" type="text" name="case3_input" />
77+
<span id="case3-child">count:{@counter}</span>
78+
</face-slotted>
79+
80+
<%!-- Case 4: FACE with delegatesFocus --%>
81+
<face-delegates id="case4">
82+
<span id="case4-child">count:{@counter}</span>
83+
</face-delegates>
84+
85+
<%!-- Case 5: Non-FACE custom element --%>
86+
<non-face-el id="case5" tabindex="0">
87+
<span id="case5-child">count:{@counter}</span>
88+
</non-face-el>
89+
90+
<button id="inc-btn" type="button" phx-click="inc">Increment</button>
91+
</form>
92+
"""
93+
end
94+
end

test/e2e/test_helper.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
218218
live "/4212", Issue4212Live
219219
live "/4290/a", Issue4290.ALive
220220
live "/4290/b", Issue4290.BLive
221+
live "/4323", Issue4323Live
221222
live "/4325", Issue4325Live
222223
end
223224
end

test/e2e/tests/issues/4323.spec.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { test, expect } from "../../test-fixtures";
2+
import { syncLV, evalLV } from "../../utils";
3+
4+
// https://github.com/phoenixframework/phoenix_live_view/pull/4323
5+
//
6+
// Form-Associated Custom Elements (FACEs) store their value in
7+
// ElementInternals (via setFormValue), not in DOM children. When a FACE
8+
// host has focus, morphdom should still descend into its light-DOM children
9+
// to patch them normally. These tests cover the fix and verify no
10+
// regressions for related scenarios.
11+
12+
const incrementCounter = async (page) => {
13+
await evalLV(
14+
page,
15+
"{:noreply, Phoenix.Component.assign(socket, :counter, socket.assigns.counter + 1)}",
16+
);
17+
await syncLV(page);
18+
};
19+
20+
test.describe("FACE children patching", () => {
21+
test.beforeEach(async ({ page }) => {
22+
await page.goto("/issues/4323");
23+
await syncLV(page);
24+
});
25+
26+
test("FACE host focused: light DOM children are patched", async ({
27+
page,
28+
}) => {
29+
await expect(page.locator("#case1-child")).toHaveText("count:0");
30+
31+
await page.locator("#case1").focus();
32+
await expect(page.locator("#case1")).toBeFocused();
33+
34+
await incrementCounter(page);
35+
36+
await expect(page.locator("#case1-child")).toHaveText("count:1");
37+
});
38+
39+
test("FACE host focused with input child: children are patched", async ({
40+
page,
41+
}) => {
42+
await expect(page.locator("#case2-child")).toHaveText("count:0");
43+
44+
// Focus the FACE host, NOT the input inside it
45+
await page.locator("#case2").focus();
46+
await expect(page.locator("#case2")).toBeFocused();
47+
48+
await incrementCounter(page);
49+
50+
await expect(page.locator("#case2-child")).toHaveText("count:1");
51+
});
52+
53+
test("FACE with slotted input focused: input is protected, siblings patched", async ({
54+
page,
55+
}) => {
56+
await expect(page.locator("#case3-child")).toHaveText("count:0");
57+
58+
// Focus the slotted input (not the FACE host)
59+
await page.locator("#case3-input").focus();
60+
await expect(page.locator("#case3-input")).toBeFocused();
61+
await page.locator("#case3-input").fill("user-typed");
62+
63+
await incrementCounter(page);
64+
65+
// Input value is preserved by normal focus-skip
66+
await expect(page.locator("#case3-input")).toHaveValue("user-typed");
67+
// Sibling children are still patched (FACE host is not focused)
68+
await expect(page.locator("#case3-child")).toHaveText("count:1");
69+
});
70+
71+
test("FACE with delegatesFocus: light DOM children are patched", async ({
72+
page,
73+
}) => {
74+
await expect(page.locator("#case4-child")).toHaveText("count:0");
75+
76+
// Click the FACE host; delegatesFocus sends focus to the shadow input,
77+
// but document.activeElement still returns the shadow host
78+
await page.locator("#case4").click();
79+
const activeId = await page.evaluate(() => document.activeElement?.id);
80+
expect(activeId).toBe("case4");
81+
82+
await incrementCounter(page);
83+
84+
await expect(page.locator("#case4-child")).toHaveText("count:1");
85+
});
86+
87+
test("non-FACE custom element: children are patched normally", async ({
88+
page,
89+
}) => {
90+
await expect(page.locator("#case5-child")).toHaveText("count:0");
91+
92+
await page.locator("#case5").focus();
93+
await expect(page.locator("#case5")).toBeFocused();
94+
95+
await incrementCounter(page);
96+
97+
// Non-FACE elements are not treated as editable inputs,
98+
// so morphdom patches them normally regardless of focus
99+
await expect(page.locator("#case5-child")).toHaveText("count:1");
100+
});
101+
});

0 commit comments

Comments
 (0)