diff --git a/packages/react/src/patterns/F0Form/F0Form.tsx b/packages/react/src/patterns/F0Form/F0Form.tsx index 4349788541..4dcfa073fd 100644 --- a/packages/react/src/patterns/F0Form/F0Form.tsx +++ b/packages/react/src/patterns/F0Form/F0Form.tsx @@ -700,6 +700,9 @@ function F0FormSingleSchema( const successTimerRef = useRef | null>(null) const autosubmitTimerRef = useRef | null>(null) const actionBarRef = useRef(null) + // Tracks mount state so the async submit handler doesn't touch React state + // (or schedule the success-message timer) after the form has unmounted. + const isMountedRef = useRef(true) /** * Snapshot of the focused input element + caret position taken before a @@ -755,6 +758,12 @@ function F0FormSingleSchema( } const result = await onSubmit(cleanedData) + // The form may have unmounted while `onSubmit` was in flight. Bail out + // before any setState / timer scheduling: a timer scheduled here would be + // created after the unmount cleanup already ran, so it would never be + // cleared and would fire on a torn-down tree. + if (!isMountedRef.current) return + if (result.success) { form.reset(form.getValues()) resetErrorNavigation() @@ -783,6 +792,7 @@ function F0FormSingleSchema( useEffect(() => { return () => { + isMountedRef.current = false if (successTimerRef.current) { clearTimeout(successTimerRef.current) } @@ -883,6 +893,11 @@ function F0FormSingleSchema( return } + // Note: `isDirty` is deliberately NOT read here. `form.watch` fires + // synchronously on the change, before react-hook-form has recomputed + // `formState.isDirty`. Reading it here would see a stale value and drop + // the FIRST change of a pristine form (e.g. a single switch toggle). It + // is re-checked at fire time below, once the state has settled. if (autosubmitTimerRef.current) { clearTimeout(autosubmitTimerRef.current) } @@ -893,6 +908,7 @@ function F0FormSingleSchema( // form is back to its last-saved state — e.g. a row was added then // removed, netting no change from the snapshot. if (!form.formState.isDirty) return + if (form.formState.isSubmitting) return snapshotFocus() form.handleSubmit((data) => handleSubmitForAutosubmitRef.current(data) diff --git a/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx b/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx index db97f32f48..469c5775ad 100644 --- a/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx +++ b/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx @@ -21,6 +21,13 @@ const strictSchema = z.object({ name: f0FormField(z.string().min(3, "Too short"), { label: "Name" }), }) +const switchSchema = z.object({ + anonymous: f0FormField(z.boolean(), { + label: "Anonymous", + fieldType: "switch", + }), +}) + const autosubmitConfig = { type: "autosubmit" as const, } @@ -108,6 +115,91 @@ describe("F0Form autosubmit", () => { ) }) + // Regression: a single discrete change on a pristine form (e.g. toggling one + // switch) must autosubmit. Previously the dirty guard was read synchronously + // inside `form.watch`, before react-hook-form recomputed `isDirty`, so the + // first change was dropped and only a second change triggered a submit. + it("submits after a single switch toggle on a pristine form", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const onSubmit = vi.fn().mockResolvedValue({ success: true }) + + render( + + ) + + const toggle = screen.getByRole("switch", { name: "Anonymous" }) + await user.click(toggle) + + act(() => { + vi.advanceTimersByTime(800) + }) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ anonymous: true }) + ) + + // Flush the post-submit success flow so its auto-dismiss timer fires and + // self-clears while still mounted, instead of leaking past teardown. + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + }) + + // Regression: if the form unmounts while an autosubmit is in flight, the + // resolved submit must not touch React state or schedule the success-message + // timer on the dead component. A timer scheduled after unmount is never + // cleared and fires on a torn-down tree ("window is not defined" in tests). + it("does not schedule state updates after unmount mid-submit", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + let resolveSubmit: (result: { success: true }) => void = () => {} + const onSubmit = vi.fn().mockImplementation( + () => + new Promise<{ success: true }>((resolve) => { + resolveSubmit = resolve + }) + ) + + const { unmount } = render( + + ) + + await user.click(screen.getByRole("switch", { name: "Anonymous" })) + + act(() => { + vi.advanceTimersByTime(800) + }) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + // Unmount while onSubmit is still pending, then resolve it. The success + // branch must bail out and schedule no new timer. + const timersBeforeResolve = vi.getTimerCount() + unmount() + await act(async () => { + resolveSubmit({ success: true }) + await Promise.resolve() + }) + + expect(vi.getTimerCount()).toBeLessThanOrEqual(timersBeforeResolve) + }) + it("debounces multiple rapid changes into a single submit", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onSubmit = vi.fn().mockResolvedValue({ success: true })