From fdf45cce9e302d86b87299683c5cf1da50e02215 Mon Sep 17 00:00:00 2001 From: Albert Prieto Date: Wed, 15 Jul 2026 12:07:43 +0200 Subject: [PATCH 1/2] fix(F0Form): autosubmit drops the first change on a pristine form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autosubmit debounce read `form.formState.isDirty` synchronously inside the `form.watch` callback. `watch` fires on the change before react-hook-form has recomputed `isDirty`, so the very first change of a pristine form saw a stale `false` and no submit was scheduled. Only a second change (by which point `isDirty` was already `true`) triggered a submit. This surfaced on single-control autosubmit forms — e.g. a lone switch toggle (performance review visibility settings): toggling one switch appeared to do nothing (no Save button, no action bar), and the change was silently lost. Fix: move the `isDirty` / `isSubmitting` guards inside the debounced `setTimeout`, where RHF state has settled. `watch` never fires on mount, so no spurious submit; the not-dirty / invalid / reset-after-submit paths still no-op. Adds a regression test that toggles a single switch on a pristine form and asserts one submit fires — it fails on the old code, passes on the new. Claude-Session: https://claude.ai/code/session_01Bb3Ln6Rk4Tn6mi1X2MBom4 --- packages/react/src/patterns/F0Form/F0Form.tsx | 10 +++-- .../__tests__/F0FormAutosubmit.test.tsx | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/react/src/patterns/F0Form/F0Form.tsx b/packages/react/src/patterns/F0Form/F0Form.tsx index c6509690ea..95d45f5a96 100644 --- a/packages/react/src/patterns/F0Form/F0Form.tsx +++ b/packages/react/src/patterns/F0Form/F0Form.tsx @@ -843,14 +843,18 @@ function F0FormSingleSchema( if (!isAutosubmit) return const subscription = form.watch(() => { - if (!form.formState.isDirty) return - if (form.formState.isSubmitting) return - + // The dirty/submitting guards are checked inside the debounced callback + // rather than 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). By the time the timer fires, the state has settled. if (autosubmitTimerRef.current) { clearTimeout(autosubmitTimerRef.current) } autosubmitTimerRef.current = setTimeout(() => { autosubmitTimerRef.current = null + 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..6b57d08f3c 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,39 @@ 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 }) + ) + }) + it("debounces multiple rapid changes into a single submit", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onSubmit = vi.fn().mockResolvedValue({ success: true }) From 4e91b4f82619588736cbd8645745a0a349c6a033 Mon Sep 17 00:00:00 2001 From: Albert Prieto Date: Fri, 17 Jul 2026 15:14:32 +0200 Subject: [PATCH 2/2] fix(F0Form): don't schedule success timer after unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async submit handler could resolve after the form unmounted (e.g. an in-flight autosubmit when navigating away). It then scheduled the success-message auto-dismiss timer on a dead component; because the unmount cleanup had already run, the timer was never cleared and fired a setState on a torn-down tree — surfacing in CI as an unhandled "window is not defined" from a leaked node timer. Guard the post-await path with an isMountedRef and bail. Adds a regression test asserting no timer is scheduled after unmount mid-submit, and settles the success flow in the switch-toggle test so it can't leak. --- packages/react/src/patterns/F0Form/F0Form.tsx | 10 ++++ .../__tests__/F0FormAutosubmit.test.tsx | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/react/src/patterns/F0Form/F0Form.tsx b/packages/react/src/patterns/F0Form/F0Form.tsx index 056dc58f62..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) } diff --git a/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx b/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx index 6b57d08f3c..469c5775ad 100644 --- a/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx +++ b/packages/react/src/patterns/F0Form/__tests__/F0FormAutosubmit.test.tsx @@ -146,6 +146,58 @@ describe("F0Form autosubmit", () => { 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 () => {