fix: Reset Form fields after successful submission (#6583)

After a successful Form submission, a visible Form returns to its
authored field defaults. The newly inserted Form starts blank, so
visitors can send another message without clearing the previous one.

## Checklist
- [x] Call the browser's Form reset after a successful Resource
submission.
- [x] Preserve entered values after failed submissions and keep
Navigation mode behavior unchanged.
- [x] Cover text, selection, and file inputs, including authored
defaults, in focused tests.
- [x] Handle authored fields named `reset` without skipping the reset or
resource refresh.
- [x] Verify Builder Preview and published-site behavior use the same
client path.
- [x] Update the Form guide for the changed behavior.
- [x] Explain the prototype reset call in a short code comment.
- [x] Run focused browser tests, typecheck, formatting, and lint on the
follow-up.
- [x] Verify required CI on the follow-up commit.

The reset test fails when the reset call is removed. The `reset` field
test failed before the review fix. Both pass now.
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-10-22 15:40:01 +02:00
1 parent 26ba4728fa
commit 6d73186a8b
4 files changed
+99 -4

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ When files are attached to an email, the server checks for at most **32 attachme
Cloud email can also fail because the provider rejects a recipient or Reply to address, suppresses a recipient, reaches an account sending limit, or is temporarily unavailable. The Form reports the resulting resource error and does not claim successful owner delivery. A timeout can leave delivery uncertain; the Form does not retry an email because another send could create a duplicate.
The inserted Form has **Form content**, **Success message**, and **Error message** sections. When every primary destination succeeds, the success state appears; otherwise the error state appears. A failed visitor-addressed email resource is reported in `errors` but does not change the overall success state. The Form exposes `formState` (the current Form state: initial, success, or error), `results` (responses from selected actions, in order, including each resource's HTTP status code and response body), and `errors` (failed actions in order, including each resource's HTTP status code, response body, message, and an error code for managed email when available). A configured **Success redirect** runs only after overall success. Without a redirect, successful submissions refresh the current page's resources without a full-page reload or a second submission.
The inserted Form has **Form content**, **Success message**, and **Error message** sections. When every primary destination succeeds, the success state appears; otherwise the error state appears. A failed visitor-addressed email resource is reported in `errors` but does not change the overall success state. The Form exposes `formState` (the current Form state: initial, success, or error), `results` (responses from selected actions, in order, including each resource's HTTP status code and response body), and `errors` (failed actions in order, including each resource's HTTP status code, response body, message, and an error code for managed email when available). A configured **Success redirect** runs only after overall success. Without a redirect, successful submissions reset the Form to its authored field defaults and refresh the current page's resources without a full-page reload or a second submission. The newly inserted Form has empty defaults. Failed submissions leave the entered values in place.
## Hosting and plain HTML forms
@@ -53,5 +53,5 @@ export const useFormFeedbackScroll = (
[]
);
return { setFormRef, revealFeedback };
return { formRef, setFormRef, revealFeedback };
};
@@ -4,6 +4,9 @@ import { createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { expect, test, vi } from "vitest";
import { ResourceForm } from "./resource-form";
import { Input } from "./input";
import { Textarea } from "./textarea";
import { Select } from "./select";
import {
managedFormRequestParamName,
managedFormIdFieldName,
@@ -49,6 +52,83 @@ test("a StrictMode Form submits after the effect cleanup probe", async () => {
}
});
test.each([true, false])(
"a visible Form resets edited fields only after success: %s",
async (success) => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const onManagedSubmit = vi.fn(async () => ({
success,
status: success ? 200 : 422,
results: [],
errors: success ? [] : [{ status: 422, body: null, message: "Failed" }],
}));
try {
await act(async () => {
root.render(
<ResourceForm
action={[{ dataSourceId: "resource-one" }]}
onManagedSubmit={onManagedSubmit}
>
<Input name="name" defaultValue="Preset name" />
<Input name="reset" defaultValue="Preset reset" />
<Textarea name="message" defaultValue="Preset message" />
<Select name="category" defaultValue="sales">
<option value="sales">Sales</option>
<option value="support">Support</option>
</Select>
<Input name="subscribe" type="checkbox" defaultChecked />
<Input name="contact" type="radio" value="email" defaultChecked />
<Input name="attachment" type="file" />
<button type="submit">Send</button>
</ResourceForm>
);
});
const name = container.querySelector<HTMLInputElement>("[name=name]")!;
const reset = container.querySelector<HTMLInputElement>("[name=reset]")!;
const message =
container.querySelector<HTMLTextAreaElement>("[name=message]")!;
const category =
container.querySelector<HTMLSelectElement>("[name=category]")!;
const subscribe =
container.querySelector<HTMLInputElement>("[name=subscribe]")!;
const contact =
container.querySelector<HTMLInputElement>("[name=contact]")!;
const attachment =
container.querySelector<HTMLInputElement>("[name=attachment]")!;
name.value = "Entered name";
reset.value = "Entered reset";
message.value = "Entered message";
category.value = "support";
subscribe.checked = false;
contact.checked = false;
const files = new DataTransfer();
files.items.add(new File(["image"], "image.png", { type: "image/png" }));
attachment.files = files.files;
await act(async () => container.querySelector("button")?.click());
await vi.waitFor(() => expect(onManagedSubmit).toHaveBeenCalledOnce());
await vi.waitFor(() =>
expect(container.querySelector("form")?.dataset.state).toBe(
success ? "success" : "error"
)
);
expect(name.value).toBe(success ? "Preset name" : "Entered name");
expect(reset.value).toBe(success ? "Preset reset" : "Entered reset");
expect(message.value).toBe(
success ? "Preset message" : "Entered message"
);
expect(category.value).toBe(success ? "sales" : "support");
expect(subscribe.checked).toBe(success);
expect(contact.checked).toBe(success);
expect(attachment.files).toHaveLength(success ? 0 : 1);
} finally {
await act(async () => root.unmount());
container.remove();
}
}
);
test("a StrictMode Form aborts an active request on real unmount", async () => {
const container = document.createElement("div");
document.body.append(container);
@@ -144,6 +224,9 @@ test("empty Resource list uses query navigation instead of Resource submission",
expect(onQueryNavigate).toHaveBeenCalledExactlyOnceWith(
`${window.location.pathname}?query=chairs&tag=wood&tag=blue`
);
expect(
container.querySelector<HTMLInputElement>("[name=query]")?.value
).toBe("chairs");
} finally {
await act(async () => root.unmount());
container.remove();
@@ -581,6 +664,12 @@ test("managed Form submits by HTTP outside a router provider and reports pending
)
);
await vi.waitFor(() => expect(states).toEqual(["initial", "success"]));
expect(container.querySelector<HTMLInputElement>("[name=tag]")?.value).toBe(
"a"
);
expect(
container.querySelector<HTMLInputElement>('[name="upload"]')?.files
).toHaveLength(0);
expect(container.querySelector("form")?.hasAttribute("aria-busy")).toBe(
false
);
@@ -80,7 +80,7 @@ export const ResourceForm = forwardRef<
submissionController.current ??= new FormSubmissionController(
() => callbacks.current!
);
const { setFormRef, revealFeedback } = useFormFeedbackScroll(ref);
const { formRef, setFormRef, revealFeedback } = useFormFeedbackScroll(ref);
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
useEffect(() => {
@@ -120,7 +120,13 @@ export const ResourceForm = forwardRef<
onResult: (result) => onResultChange?.(result),
onState: reportState,
onFeedback: revealFeedback,
onRefresh: onSubmissionSuccess,
onRefresh: () => {
if (formRef.current) {
// A field named or identified as "reset" can shadow form.reset.
HTMLFormElement.prototype.reset.call(formRef.current);
}
return onSubmissionSuccess?.();
},
onRedirect: (destination) => {
if (onSuccessRedirect) {
return onSuccessRedirect(destination);