Skip to content

Commit ce3107c

Browse files
Deep clone form for form recovery (#3921)
The previous algorithm had a flaw: ```javascript const clonedForm = form.cloneNode(false); DOM.copyPrivates(clonedForm, form); Array.from(form.elements).forEach((el) => { morphdom(clonedEl, el); DOM.copyPrivates(clonedEl, el); clonedForm.appendChild(clonedEl); } ``` While this kept the size of the clonedForm minimal and includes all elements, it did not consider `<fieldset>` elements. If you had something like this: ```html <form> <fieldset> <input name="foo" value="bar"> </fieldset> </form> ``` `form.elements` would return `[fieldset, input]`, thus we would actually include the input twice. Furthermore, imagine someone did `<fieldset disabled>` which disables all inputs inside and should exclude them from the form payload. But with the old code, LiveView would copy the input separately. Because it is not disabled, it would be included in the recovery payload. The fix is to actually copy the whole form and then query the DOM for any associated elements `[form=${form.id}]` to include them. In the future, we should consider simplifying this code to serialize the form early and not need to clone DOM nodes, but for now, I think it is fine. Closes #3914. Co-authored-by: Giacomo Mazzamuto <[email protected]>
1 parent e0d1fcd commit ce3107c

File tree

4 files changed

+158
-45
lines changed

4 files changed

+158
-45
lines changed

assets/js/phoenix_live_view/view.js

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,6 +2061,19 @@ export default class View {
20612061
}
20622062

20632063
getFormsForRecovery() {
2064+
// Form recovery is complex in LiveView:
2065+
// We want to support nested LiveViews and also provide a good user experience.
2066+
// Therefore, when the channel rejoins, we copy all forms that are eligible for
2067+
// recovery to be able to access them later.
2068+
// Why do we need to copy them? Because when the main LiveView joins, any forms
2069+
// in nested LiveViews would be lost.
2070+
//
2071+
// We should rework this in the future to serialize the form payload here
2072+
// instead of cloning the DOM nodes, but making this work correctly is tedious,
2073+
// as sending the correct form payload relies on JS.push to extract values
2074+
// from JS commands (phx-change={JS.push("event", value: ..., target: ...)}),
2075+
// as well as view.pushInput, which expects DOM elements.
2076+
20642077
if (this.joinCount === 0) {
20652078
return {};
20662079
}
@@ -2075,19 +2088,33 @@ export default class View {
20752088
form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore",
20762089
)
20772090
.map((form) => {
2078-
// we perform a shallow clone and manually copy all elements
2079-
const clonedForm = form.cloneNode(false);
2080-
// we need to copy the private data as it contains
2081-
// the information about touched fields
2082-
DOM.copyPrivates(clonedForm, form);
2083-
Array.from(form.elements).forEach((el) => {
2084-
// we need to clone all child nodes as well,
2085-
// because those could also be selects
2091+
// We need to clone the whole form, as relying on form.elements can lead to
2092+
// situations where we have
2093+
//
2094+
// <form><fieldset disabled><input name="foo" value="bar"></fieldset></form>
2095+
//
2096+
// and form.elements returns both the fieldset and the input separately.
2097+
// Because the fieldset is disabled, the input should NOT be sent though.
2098+
// We can only reliably serialize the form by cloning it fully.
2099+
const clonedForm = form.cloneNode(true);
2100+
// we call morphdom to copy any special state
2101+
// like the selected option of a <select> element;
2102+
// any also copy over privates (which contain information about touched fields)
2103+
morphdom(clonedForm, form, {
2104+
onBeforeElUpdated: (fromEl, toEl) => {
2105+
DOM.copyPrivates(fromEl, toEl);
2106+
return true;
2107+
},
2108+
});
2109+
// next up, we also need to clone any elements with form="id" parameter
2110+
const externalElements = document.querySelectorAll(
2111+
`[form="${form.id}"]`,
2112+
);
2113+
Array.from(externalElements).forEach((el) => {
2114+
if (form.contains(el)) {
2115+
return;
2116+
}
20862117
const clonedEl = el.cloneNode(true);
2087-
// we call morphdom to copy any special state
2088-
// like the selected option of a <select> element;
2089-
// this should be plenty fast as we call it on a small subset of the DOM,
2090-
// single inputs or a select with children
20912118
morphdom(clonedEl, el);
20922119
DOM.copyPrivates(clonedEl, el);
20932120
clonedForm.appendChild(clonedEl);

test/e2e/support/form_dynamic_inputs_live.ex

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -76,38 +76,40 @@ defmodule Phoenix.LiveViewTest.E2E.FormDynamicInputsLive do
7676
phx-submit="save"
7777
style="display: flex; flex-direction: column; gap: 4px; max-width: 500px;"
7878
>
79-
<input
80-
type="text"
81-
id={@form[:name].id}
82-
name={@form[:name].name}
83-
value={@form[:name].value}
84-
placeholder="name"
85-
/>
86-
<.inputs_for :let={ef} field={@form[:users]} default={[]}>
87-
<div style="padding: 4px; border: 1px solid gray;">
88-
<input type="hidden" name="my_form[users_sort][]" value={ef.index} />
89-
<input
90-
type="text"
91-
id={ef[:name].id}
92-
name={ef[:name].name}
93-
value={ef[:name].value}
94-
placeholder="name"
95-
/>
79+
<fieldset>
80+
<input
81+
type="text"
82+
id={@form[:name].id}
83+
name={@form[:name].name}
84+
value={@form[:name].value}
85+
placeholder="name"
86+
/>
87+
<.inputs_for :let={ef} field={@form[:users]} default={[]}>
88+
<div style="padding: 4px; border: 1px solid gray;">
89+
<input type="hidden" name="my_form[users_sort][]" value={ef.index} />
90+
<input
91+
type="text"
92+
id={ef[:name].id}
93+
name={ef[:name].name}
94+
value={ef[:name].value}
95+
placeholder="name"
96+
/>
9697
97-
<button
98-
:if={!@checkboxes}
99-
type="button"
100-
name="my_form[users_drop][]"
101-
value={ef.index}
102-
phx-click={JS.dispatch("change")}
103-
>
104-
Remove
105-
</button>
106-
<label :if={@checkboxes}>
107-
<input type="checkbox" name="my_form[users_drop][]" value={ef.index} /> Remove
108-
</label>
109-
</div>
110-
</.inputs_for>
98+
<button
99+
:if={!@checkboxes}
100+
type="button"
101+
name="my_form[users_drop][]"
102+
value={ef.index}
103+
phx-click={JS.dispatch("change")}
104+
>
105+
Remove
106+
</button>
107+
<label :if={@checkboxes}>
108+
<input type="checkbox" name="my_form[users_drop][]" value={ef.index} /> Remove
109+
</label>
110+
</div>
111+
</.inputs_for>
112+
</fieldset>
111113
112114
<input type="hidden" name="my_form[users_drop][]" />
113115

test/e2e/support/form_live.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ defmodule Phoenix.LiveViewTest.E2E.FormLive do
141141
phx-target={assigns[:"phx-target"]}
142142
class="myformclass"
143143
>
144-
<input type="text" name="a" readonly value={@params["a"]} />
145-
<input type="text" name="b" value={@params["b"]} />
144+
<fieldset disabled={@params["disabled-fieldset"]}>
145+
<input type="text" name="a" readonly value={@params["a"]} />
146+
<input type="text" name="b" value={@params["b"]} />
147+
</fieldset>
146148
<input
147149
type="text"
148150
name="c"

test/e2e/tests/forms.spec.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,48 @@ for (const path of ["/form/nested", "/form"]) {
263263
]),
264264
);
265265
});
266+
267+
test("respects disabled state of a fieldset", async ({ page }) => {
268+
let webSocketEvents = [];
269+
page.on("websocket", (ws) => {
270+
ws.on("framesent", (event) =>
271+
webSocketEvents.push({ type: "sent", payload: event.payload }),
272+
);
273+
ws.on("framereceived", (event) =>
274+
webSocketEvents.push({ type: "received", payload: event.payload }),
275+
);
276+
ws.on("close", () => webSocketEvents.push({ type: "close" }));
277+
});
278+
279+
await page.goto(path + "?disabled-fieldset=true&" + additionalParams);
280+
await syncLV(page);
281+
282+
await page.locator("input[name=c]").fill("hello world");
283+
await page.locator("select[name=d]").selectOption("bar");
284+
await expect(page.locator("input[name=c]")).toBeFocused();
285+
await syncLV(page);
286+
287+
await page.evaluate(
288+
() => new Promise((resolve) => window.liveSocket.disconnect(resolve)),
289+
);
290+
await expect(page.locator(".phx-loading")).toHaveCount(1);
291+
292+
webSocketEvents = [];
293+
await page.evaluate(() => window.liveSocket.connect());
294+
await syncLV(page);
295+
await expect(page.locator(".phx-loading")).toHaveCount(0);
296+
297+
expect(webSocketEvents).toEqual(
298+
expect.arrayContaining([
299+
{ type: "sent", payload: expect.stringContaining("phx_join") },
300+
{ type: "received", payload: expect.stringContaining("phx_reply") },
301+
{
302+
type: "sent",
303+
payload: expect.stringMatching(/event.*"c=hello\+world&d=bar"/),
304+
},
305+
]),
306+
);
307+
});
266308
});
267309
}
268310

@@ -604,6 +646,46 @@ test("can dynamically add/remove inputs using checkboxes", async ({ page }) => {
604646
);
605647
});
606648

649+
// this happened from v1.0.17 when the sort_params field is nested in a fieldset
650+
test("form recovery does not create duplicates of dynamically added fields", async ({
651+
page,
652+
}) => {
653+
await page.goto("/form/dynamic-inputs");
654+
await syncLV(page);
655+
656+
const formData = () =>
657+
page
658+
.locator("form")
659+
.evaluate((form) => Object.fromEntries(new FormData(form).entries()));
660+
661+
expect(await formData()).toEqual({
662+
"my_form[name]": "",
663+
"my_form[users_drop][]": "",
664+
});
665+
666+
await page.locator("#my-form_name").fill("Test");
667+
await page.getByRole("button", { name: "add more" }).click();
668+
await syncLV(page);
669+
670+
expect(await formData()).toEqual(
671+
expect.objectContaining({
672+
"my_form[name]": "Test",
673+
"my_form[users][0][name]": "",
674+
}),
675+
);
676+
677+
await page.evaluate(
678+
() => new Promise((resolve) => window.liveSocket.disconnect(resolve)),
679+
);
680+
681+
await page.evaluate(() => window.liveSocket.connect());
682+
await syncLV(page);
683+
684+
const data = await formData();
685+
686+
expect("my_form[users][1][name]" in data).toBe(false);
687+
});
688+
607689
// phx-feedback-for was removed in LiveView 1.0, but we still test the shim applied in
608690
// test_helper.exs layout for backwards compatibility
609691
test("phx-no-feedback is applied correctly for backwards-compatible-shims", async ({

0 commit comments

Comments
 (0)