Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/react/src/patterns/F0Form/F0Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,9 @@ function F0FormSingleSchema<TSchema extends F0FormSchema>(
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autosubmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const actionBarRef = useRef<F0ActionBarRef | null>(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
Expand Down Expand Up @@ -755,6 +758,12 @@ function F0FormSingleSchema<TSchema extends F0FormSchema>(
}
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()
Expand Down Expand Up @@ -783,6 +792,7 @@ function F0FormSingleSchema<TSchema extends F0FormSchema>(

useEffect(() => {
return () => {
isMountedRef.current = false
if (successTimerRef.current) {
clearTimeout(successTimerRef.current)
}
Expand Down Expand Up @@ -883,6 +893,11 @@ function F0FormSingleSchema<TSchema extends F0FormSchema>(
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)
}
Expand All @@ -893,6 +908,7 @@ function F0FormSingleSchema<TSchema extends F0FormSchema>(
// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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(
<F0Form
name="autosubmit-single-toggle"
schema={switchSchema}
defaultValues={{ anonymous: false }}
onSubmit={onSubmit}
submitConfig={{ ...autosubmitConfig, delay: 800, hideActionBar: true }}
/>
)

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(
<F0Form
name="autosubmit-unmount-midflight"
schema={switchSchema}
defaultValues={{ anonymous: false }}
onSubmit={onSubmit}
submitConfig={{ ...autosubmitConfig, delay: 800, hideActionBar: true }}
/>
)

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 })
Expand Down
Loading