From c07c252eec98c47fa7ffc7480a9e489de735e60a Mon Sep 17 00:00:00 2001 From: Kotataro taniguchi <119092698+kotarotaniguchi0523@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:41:24 +0000 Subject: [PATCH] fix(react): derive form state from submission outcomes and guard concurrent submits - Represent useForm submission results with a discriminated union (SubmissionOutcome) owned by useActionState, deriving errors, error, hasChanges, wasSuccessful, and recentlySuccessful without redundant state or refs. - Snapshot form data and dispatchData at dispatch time so synchronous setData() calls, in-flight edits (including under resetOnSuccess), and queued React actions preserve their submitted payload and post-success baseline. - Scope callbackOutcomeRef across async onError and synchronous onSuccess so reset(), setError(), clearErrors(), and clearError() inside callbacks operate on the incoming outcome without leaking uncommitted defaults to external UI interactions during await. - Return a fresh idle outcome when a submission aborts so previous validation errors or success states do not revive. - Represent useIslandForm state with a single IslandOutcome union and requestId so the latest started submission owns pending and UI state across completion orders. - Check 429 retryAt deadlines directly in
onSubmit instead of mounting timer state and effects. --- packages/react/src/runtime/form.tsx | 27 +- packages/react/src/runtime/use-form.ts | 305 +++++++++----- packages/react/src/runtime/use-island-form.ts | 185 +++++---- packages/react/test/unit/use-form.test.ts | 378 ++++++++++++++++++ 4 files changed, 702 insertions(+), 193 deletions(-) diff --git a/packages/react/src/runtime/form.tsx b/packages/react/src/runtime/form.tsx index 86ee8ed51..1eec855ba 100644 --- a/packages/react/src/runtime/form.tsx +++ b/packages/react/src/runtime/form.tsx @@ -1,6 +1,6 @@ import { - useEffect, - useState, + useCallback, + type FormEvent, type FormHTMLAttributes, type ReactElement, type ReactNode, @@ -9,7 +9,6 @@ import { formErrorMessage, formRetryMessage, formSubmissionBlocked, - scheduleFormRetry, type FormModel, type FormMethod, type VoidActionError, @@ -31,22 +30,18 @@ export function Form({ children, ...props }: FormProps): ReactElement { - const [now, setNow] = useState(Date.now); - useEffect(() => scheduleFormRetry(form.error, () => setNow(Date.now())), [form.error]); const message = formErrorMessage(form); const retry = formRetryMessage(form.error); - const blocked = formSubmissionBlocked(form, now); + const onSubmit = useCallback( + (event: FormEvent) => { + if (formSubmissionBlocked(form)) { + event.preventDefault(); + } + }, + [form], + ); return ( - { - if (blocked) { - event.preventDefault(); - } - }} - > + {message && (
{form.error && renderError ? renderError(form.error) : message} diff --git a/packages/react/src/runtime/use-form.ts b/packages/react/src/runtime/use-form.ts index 582b42538..bd818de97 100644 --- a/packages/react/src/runtime/use-form.ts +++ b/packages/react/src/runtime/use-form.ts @@ -22,7 +22,36 @@ import { import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; import { RouterContext } from './context.ts'; -type SubmitPayload = FormData | { method: string }; +type SubmitPayload = { + method: string; + snapshot: Record; + dispatchData: Record; +}; + +type SubmissionOutcome = + | { status: 'idle'; defaults: Record } + | { status: 'success'; defaults: Record } + | { status: 'validation'; defaults: Record; errors: Record } + | { status: 'error'; defaults: Record; error: VoidActionError }; + +type ErrorEdits = { + source: SubmissionOutcome; + errors?: Record; + dismissActionError?: boolean; +}; + +const EMPTY_ERRORS: Record = {}; + +function resolveOutcomeErrors( + outcome: SubmissionOutcome, + edits: ErrorEdits | null, +): Record { + if (edits?.source === outcome && edits.errors !== undefined) { + return edits.errors; + } + return outcome.status === 'validation' ? outcome.errors : EMPTY_ERRORS; +} + // Submit methods are native form actions that take FormData. type FormReturn = FormState & Readonly<{ @@ -55,105 +84,47 @@ export function useForm( ): FormReturn> { const router = useContext(RouterContext); const [data, setDataState] = useState>(() => cloneFormValue(defaults)); - const [defaultsState, setDefaultsState] = useState>(() => - cloneFormValue(defaults), - ); - const [errors, setErrors] = useState>({}); - const [error, setActionError] = useState(null); - const [wasSuccessful, setWasSuccessful] = useState(false); - const [recentlySuccessful, setRecentlySuccessful] = useState(false); + const [initialDefaults] = useState>(() => cloneFormValue(defaults)); + const [errorEdits, setErrorEdits] = useState(null); + const [expiredOutcome, setExpiredOutcome] = useState(null); - const defaultsRef = useRef(defaultsState); - const initialRef = useRef(defaultsState); const dataRef = useRef(data); - const successTimeoutRef = useRef | null>(null); + const callbackOutcomeRef = useRef(null); - const hasChanges = useMemo(() => { - return Object.keys(defaultsState).some( - (key) => !isEqualFormValue(data[key], defaultsState[key]), - ); - }, [data, defaultsState]); - - const setData = useCallback((field: string, value: unknown) => { - dataRef.current = { ...dataRef.current, [field]: value }; - setDataState(dataRef.current); - setErrors((prev) => { - if (!(field in prev)) { - return prev; + const runErrorCallback = async ( + nextOutcome: SubmissionOutcome, + errorArg: Record | VoidActionError, + ): Promise => { + callbackOutcomeRef.current = nextOutcome; + try { + await options?.onError?.(errorArg); + } finally { + if (callbackOutcomeRef.current === nextOutcome) { + callbackOutcomeRef.current = null; } - const next = { ...prev }; - delete next[field]; - return next; - }); - }, []); - - const reset = useCallback((...args: ReadonlyArray) => { - const { initial, fields } = parseFormResetArgs(args); - const source = initial ? initialRef.current : defaultsRef.current; - if (fields.length === 0) { - dataRef.current = cloneFormValue(source); - } else { - const next = { ...dataRef.current }; - for (const field of fields) { - next[field] = cloneFormValue(source[field]); - } - dataRef.current = next; } - setDataState(dataRef.current); - }, []); - - const clearErrors = useCallback((...fields: ReadonlyArray) => { - if (fields.length === 0) { - setErrors({}); - } else { - setErrors((prev) => { - const next = { ...prev }; - for (const field of fields) { - delete next[field]; - } - return next; - }); - } - }, []); - - const setError = useCallback((field: string, message: string) => { - setErrors((prev) => ({ ...prev, [field]: message })); - }, []); - - const clearError = useCallback(() => { - setActionError(null); - }, []); + return nextOutcome; + }; - const [, dispatchAction, pending] = useActionState( - async (previousSuccessVersion: number, payload: SubmitPayload) => { + const [outcome, dispatchAction, pending] = useActionState( + async (previousOutcome, payload) => { if (!router) { throw new Error('useForm(): requires the Void Router.'); } - const method = payload instanceof FormData ? 'POST' : payload.method; - - setWasSuccessful(false); - setRecentlySuccessful(false); - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; - } - setErrors({}); - setActionError(null); - let result; try { // Params are filled in on submit, so a param that would change the // request path (such as "..") fails this submit, not the page render. result = await submitAction(router, interpolateRouteParams(url, options?.params), { - method, - data: { ...dataRef.current }, + method: payload.method, + data: payload.snapshot, preserveScroll: options?.preserveScroll, _resolveOnShell: true, }); } catch (submitError) { if (isAbortError(submitError)) { - return previousSuccessVersion; + return { status: 'idle', defaults: previousOutcome.defaults }; } throw submitError; } @@ -161,50 +132,166 @@ export function useForm( if (!result.ok) { const validationErrors = getValidationErrors(result.error.body); if (validationErrors) { - setErrors(validationErrors); - await options?.onError?.(validationErrors); - } else { - setActionError(result.error); - await options?.onError?.(result.error); + return runErrorCallback( + { + status: 'validation', + defaults: previousOutcome.defaults, + errors: validationErrors, + }, + validationErrors, + ); } - return previousSuccessVersion; + return runErrorCallback( + { + status: 'error', + defaults: previousOutcome.defaults, + error: result.error, + }, + result.error, + ); } - if (options?.resetOnSuccess) { - dataRef.current = cloneFormValue(initialRef.current); + const nextDefaults = options?.resetOnSuccess + ? cloneFormValue(initialDefaults) + : cloneFormValue(payload.snapshot); + const nextOutcome: SubmissionOutcome = { status: 'success', defaults: nextDefaults }; + let pendingSuccess: void | Promise; + callbackOutcomeRef.current = nextOutcome; + try { + pendingSuccess = options?.onSuccess?.(); + } finally { + callbackOutcomeRef.current = null; + } + await pendingSuccess; + if (options?.resetOnSuccess && dataRef.current === payload.dispatchData) { + dataRef.current = cloneFormValue(initialDefaults); setDataState(dataRef.current); } - const nextDefaults = cloneFormValue(dataRef.current); - defaultsRef.current = nextDefaults; - setDefaultsState(nextDefaults); - setWasSuccessful(true); - setRecentlySuccessful(true); - successTimeoutRef.current = setTimeout(() => { - setRecentlySuccessful(false); - successTimeoutRef.current = null; - }, 2000); - await options?.onSuccess?.(); - return previousSuccessVersion + 1; + return nextOutcome; }, - 0, + { status: 'idle', defaults: initialDefaults }, ); - useEffect(() => { - return () => { - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; + const defaultsState = outcome.defaults; + const hasChanges = useMemo(() => { + return Object.keys(defaultsState).some( + (key) => !isEqualFormValue(data[key], defaultsState[key]), + ); + }, [data, defaultsState]); + + const setData = useCallback( + (field: string, value: unknown) => { + const previousValue = dataRef.current[field]; + dataRef.current = { ...dataRef.current, [field]: value }; + setDataState(dataRef.current); + if (isEqualFormValue(previousValue, value)) { + return; + } + const target = callbackOutcomeRef.current ?? outcome; + setErrorEdits((prev) => { + const current = resolveOutcomeErrors(target, prev); + if (!(field in current)) { + return prev; + } + const next = { ...current }; + delete next[field]; + return { + ...(prev?.source === target ? prev : { source: target }), + errors: next, + }; + }); + }, + [outcome], + ); + + const reset = useCallback( + (...args: ReadonlyArray) => { + const { initial, fields } = parseFormResetArgs(args); + const target = callbackOutcomeRef.current ?? outcome; + const source = initial ? initialDefaults : target.defaults; + if (fields.length === 0) { + dataRef.current = cloneFormValue(source); + } else { + const next = { ...dataRef.current }; + for (const field of fields) { + next[field] = cloneFormValue(source[field]); + } + dataRef.current = next; } - }; - }, []); + setDataState(dataRef.current); + }, + [initialDefaults, outcome], + ); + + const clearErrors = useCallback( + (...fields: ReadonlyArray) => { + const target = callbackOutcomeRef.current ?? outcome; + setErrorEdits((prev) => { + const base = prev?.source === target ? prev : { source: target }; + if (fields.length === 0) { + return { ...base, errors: EMPTY_ERRORS }; + } + const next = { ...resolveOutcomeErrors(target, prev) }; + for (const field of fields) { + delete next[field]; + } + return { ...base, errors: next }; + }); + }, + [outcome], + ); + + const setError = useCallback( + (field: string, message: string) => { + const target = callbackOutcomeRef.current ?? outcome; + setErrorEdits((prev) => ({ + ...(prev?.source === target ? prev : { source: target }), + errors: { ...resolveOutcomeErrors(target, prev), [field]: message }, + })); + }, + [outcome], + ); + + const clearError = useCallback(() => { + const target = callbackOutcomeRef.current ?? outcome; + setErrorEdits((prev) => ({ + ...(prev?.source === target ? prev : { source: target }), + dismissActionError: true, + })); + }, [outcome]); + + const errors = pending ? EMPTY_ERRORS : resolveOutcomeErrors(outcome, errorEdits); + const error: VoidActionError | null = + pending || + outcome.status !== 'error' || + (errorEdits?.source === outcome && errorEdits.dismissActionError) + ? null + : outcome.error; + const wasSuccessful = !pending && outcome.status === 'success'; + const recentlySuccessful = wasSuccessful && expiredOutcome !== outcome; + + useEffect(() => { + if (outcome.status !== 'success') { + return; + } + const timer = setTimeout(() => { + setExpiredOutcome(outcome); + }, 2000); + return () => clearTimeout(timer); + }, [outcome]); const dispatchMethodAction = useCallback( (method: string) => (_formData: FormData) => { - dispatchAction({ method }); + dispatchAction({ + method, + snapshot: cloneFormValue(dataRef.current), + dispatchData: dataRef.current, + }); }, [dispatchAction], ); + const post = useMemo(() => dispatchMethodAction('POST'), [dispatchMethodAction]); const put = useMemo(() => dispatchMethodAction('PUT'), [dispatchMethodAction]); const patch = useMemo(() => dispatchMethodAction('PATCH'), [dispatchMethodAction]); const deleteAction = useMemo(() => dispatchMethodAction('DELETE'), [dispatchMethodAction]); @@ -218,7 +305,7 @@ export function useForm( hasChanges, wasSuccessful, recentlySuccessful, - post: dispatchAction, + post, put, patch, delete: deleteAction, diff --git a/packages/react/src/runtime/use-island-form.ts b/packages/react/src/runtime/use-island-form.ts index bc8d8cd9b..94779213c 100644 --- a/packages/react/src/runtime/use-island-form.ts +++ b/packages/react/src/runtime/use-island-form.ts @@ -1,28 +1,44 @@ -import { useState, useRef, useCallback, useMemo } from 'react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { + cloneFormValue, isEqualFormValue, submitIslandAction, type IslandFormState, type VoidActionError, } from 'void/pages-client'; +const EMPTY_ERRORS: Record = {}; + export type IslandFormReturn> = IslandFormState & Readonly<{ setData: (field: K, value: T[K]) => void }>; +type IslandOutcome> = + | { status: 'idle'; defaults: T } + | { status: 'pending'; defaults: T; requestId: number } + | { status: 'success'; defaults: T } + | { status: 'validation'; defaults: T; errors: Record } + | { status: 'error'; defaults: T; error: VoidActionError }; + /** * Form hook for island pages. Uses fetch + page reload instead of the * Void Router (which is not available in island mode). */ export function useIslandForm>(defaults: T): IslandFormReturn { - const [data, setDataState] = useState(() => ({ ...defaults })); - const [defaultsState, setDefaultsState] = useState(() => ({ ...defaults })); - const [errors, setErrors] = useState>({}); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - const [wasSuccessful, setWasSuccessful] = useState(false); - const [recentlySuccessful, setRecentlySuccessful] = useState(false); - const defaultsRef = useRef(defaultsState); - const successTimeoutRef = useRef | null>(null); + const [data, setDataState] = useState(() => cloneFormValue(defaults)); + const [outcome, setOutcome] = useState>(() => ({ + status: 'idle', + defaults: cloneFormValue(defaults), + })); + const [expiredOutcome, setExpiredOutcome] = useState | null>(null); + const dataRef = useRef(data); + const latestRequestIdRef = useRef(0); + + const defaultsState = outcome.defaults; + const pending = outcome.status === 'pending'; + const errors = outcome.status === 'validation' ? outcome.errors : EMPTY_ERRORS; + const error: VoidActionError | null = outcome.status === 'error' ? outcome.error : null; + const wasSuccessful = outcome.status === 'success'; + const recentlySuccessful = wasSuccessful && expiredOutcome !== outcome; const hasChanges = useMemo(() => { return Object.keys(defaultsState).some( @@ -30,80 +46,113 @@ export function useIslandForm>(defaults: T): I ); }, [data, defaultsState]); + useEffect(() => { + if (outcome.status !== 'success') { + return; + } + const timer = setTimeout(() => setExpiredOutcome(outcome), 2000); + return () => clearTimeout(timer); + }, [outcome]); + const setData = useCallback((field: K, value: T[K]) => { - setDataState((prev) => ({ ...prev, [field]: value })); + const previousValue = dataRef.current[field]; + dataRef.current = { ...dataRef.current, [field]: value }; + setDataState(dataRef.current); + if (isEqualFormValue(previousValue, value)) { + return; + } + setOutcome((prev) => { + if (prev.status !== 'validation' || !(field in prev.errors)) { + return prev; + } + const next = { ...prev.errors }; + delete next[field]; + return { status: 'validation', defaults: prev.defaults, errors: next }; + }); }, []); - const reset = useCallback((...fields: ReadonlyArray) => { - if (fields.length === 0) { - setDataState({ ...defaultsRef.current } as T); - } else { - setDataState((prev) => { - const next = { ...prev }; + const reset = useCallback( + (...fields: ReadonlyArray) => { + if (fields.length === 0) { + dataRef.current = cloneFormValue(defaultsState); + } else { + const next = { ...dataRef.current }; for (const field of fields) { - next[field] = defaultsRef.current[field] as T[typeof field]; + next[field] = cloneFormValue(defaultsState[field]); } - return next; - }); - } - }, []); + dataRef.current = next; + } + setDataState(dataRef.current); + }, + [defaultsState], + ); const clearErrors = useCallback((...fields: ReadonlyArray) => { - if (fields.length === 0) { - setErrors({}); - } else { - setErrors((prev) => { - const next = { ...prev }; - for (const field of fields) { - delete next[field]; - } - return next; - }); - } + setOutcome((prev) => { + if (prev.status !== 'validation') { + return prev; + } + if (fields.length === 0) { + return { status: 'idle', defaults: prev.defaults }; + } + const next = { ...prev.errors }; + for (const field of fields) { + delete next[field]; + } + return { status: 'validation', defaults: prev.defaults, errors: next }; + }); }, []); const clearError = useCallback(() => { - setError(null); + setOutcome((prev) => + prev.status === 'error' ? { status: 'idle', defaults: prev.defaults } : prev, + ); }, []); - const submit = useCallback( - async (url: string, method: string) => { - setPending(true); - setWasSuccessful(false); - setRecentlySuccessful(false); - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - } - setErrors({}); - setError(null); + const submit = useCallback(async (url: string, method: string) => { + const requestId = ++latestRequestIdRef.current; + const submittedData = cloneFormValue(dataRef.current); + setOutcome((prev) => ({ status: 'pending', defaults: prev.defaults, requestId })); - try { - const result = await submitIslandAction(url, method, data); + try { + const result = await submitIslandAction(url, method, submittedData); + if (requestId !== latestRequestIdRef.current) { + return; + } - if (result.kind === 'redirect') { - window.location.href = result.url; - return; - } + if (result.kind === 'redirect') { + setOutcome((prev) => ({ status: 'idle', defaults: prev.defaults })); + window.location.href = result.url; + return; + } - if (result.kind === 'success') { - setWasSuccessful(true); - setRecentlySuccessful(true); - const nextDefaults = { ...data }; - defaultsRef.current = nextDefaults; - setDefaultsState(nextDefaults); - successTimeoutRef.current = setTimeout(() => setRecentlySuccessful(false), 2000); - window.location.reload(); - } else if (result.kind === 'validation') { - setErrors(result.errors); - } else { - setError(result.error); - } - } finally { - setPending(false); + if (result.kind === 'success') { + setOutcome({ status: 'success', defaults: submittedData }); + window.location.reload(); + } else if (result.kind === 'validation') { + setOutcome((prev) => ({ + status: 'validation', + defaults: prev.defaults, + errors: result.errors, + })); + } else { + setOutcome((prev) => ({ + status: 'error', + defaults: prev.defaults, + error: result.error, + })); } - }, - [data], - ); + } catch (submitError) { + if (requestId === latestRequestIdRef.current) { + setOutcome((prev) => + prev.status === 'pending' && prev.requestId === requestId + ? { status: 'idle', defaults: prev.defaults } + : prev, + ); + } + throw submitError; + } + }, []); return { data, diff --git a/packages/react/test/unit/use-form.test.ts b/packages/react/test/unit/use-form.test.ts index 9c97fd174..1018bafc7 100644 --- a/packages/react/test/unit/use-form.test.ts +++ b/packages/react/test/unit/use-form.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { VoidActionError, type VoidRouter } from 'void/pages-client'; import { RouterContext } from '../../src/runtime/context.ts'; import { useForm } from '../../src/runtime/use-form.ts'; +import { useIslandForm, type IslandFormReturn } from '../../src/runtime/use-island-form.ts'; declare module 'void/routes' { interface PageActionMap { @@ -33,6 +34,7 @@ type FormOptions = NonNullable>[2]>; const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; afterEach(() => { + vi.restoreAllMocks(); for (const { root, container } of mountedRoots.splice(0)) { act(() => root.unmount()); container.remove(); @@ -323,3 +325,379 @@ describe('useForm callbacks', () => { }, ); }); + +describe('useForm state derivation and error management', () => { + it('snapshots synchronous setData() before submit, in-flight edits, and queued actions', async () => { + const firstFlight = deferred>(); + const secondFlight = deferred>(); + let callCount = 0; + const visit = vi.fn(() => (++callCount === 1 ? firstFlight.promise : secondFlight.promise)); + const { formRef } = await mountForm(visit); + + // 1. Synchronous setData() immediately followed by post() in the same turn + await act(async () => { + formRef.current!.setData('title', 'First'); + startTransition(() => { + formRef.current!.post(new FormData()); + }); + }); + + // 2 & 3. Queued action with its own snapshot + in-flight edit while both are pending + await act(async () => { + formRef.current!.setData('title', 'Second'); + startTransition(() => { + formRef.current!.post(new FormData()); + }); + formRef.current!.setData('title', 'Edited in-flight'); + }); + + await act(async () => { + firstFlight.resolve({}); + }); + expect(visit).toHaveBeenNthCalledWith( + 1, + actionUrl, + expect.objectContaining({ data: { title: 'First' } }), + ); + expect(visit).toHaveBeenNthCalledWith( + 2, + actionUrl, + expect.objectContaining({ data: { title: 'Second' } }), + ); + + await act(async () => { + secondFlight.resolve({}); + }); + expect(formRef.current!.data.title).toBe('Edited in-flight'); + expect(formRef.current!.hasChanges).toBe(true); + + await act(async () => { + formRef.current!.reset(); + }); + expect(formRef.current!.data.title).toBe('Second'); + expect(formRef.current!.hasChanges).toBe(false); + }); + + it('applies reset(), setError(), and clearError() inside onSuccess/onError without leaking uncommitted outcomes to external UI during await', async () => { + let mode: 'success-reset' | 'async-external-reset' | 'validation' | 'action-error' = + 'success-reset'; + const callbackGate = deferred(); + const visit = vi.fn(async () => { + if (mode === 'validation') { + return { errors: { title: 'Server invalid', extra: 'Keep me' } }; + } + if (mode === 'action-error') { + throw new VoidActionError({ + body: 'Conflict', + status: 409, + statusText: 'Conflict', + url: actionUrl, + }); + } + return {}; + }); + + const { formRef } = await mountForm(visit, { + onSuccess: async () => { + if (mode === 'async-external-reset') { + await callbackGate.promise; + return; + } + formRef.current!.reset(); + }, + onError: async (err) => { + await Promise.resolve(); + if (err instanceof VoidActionError) { + formRef.current!.clearError(); + } else { + formRef.current!.clearErrors(); + formRef.current!.setError('title', 'Custom error from onError'); + } + }, + }); + + // onSuccess -> reset() uses the newly saved snapshot baseline + await act(async () => { + formRef.current!.setData('title', 'Saved'); + }); + await submitPost(formRef.current!); + expect(formRef.current!.data).toEqual({ title: 'Saved' }); + expect(formRef.current!.hasChanges).toBe(false); + + // While async onSuccess is suspended at await, external UI reset() uses committed baseline ('Saved'), not uncommitted ('Uncommitted') + mode = 'async-external-reset'; + await act(async () => { + formRef.current!.setData('title', 'Uncommitted'); + }); + await submitPost(formRef.current!); + expect(formRef.current!.pending).toBe(true); + await act(async () => { + formRef.current!.reset(); + }); + expect(formRef.current!.data).toEqual({ title: 'Saved' }); + await act(async () => { + callbackGate.resolve(); + }); + + // onError -> clearErrors() + setError() + mode = 'validation'; + await act(async () => { + formRef.current!.setData('title', 'Bad'); + }); + await submitPost(formRef.current!); + expect(formRef.current!.errors).toEqual({ title: 'Custom error from onError' }); + + // onError -> clearError() + mode = 'action-error'; + await submitPost(formRef.current!); + expect(formRef.current!.error).toBeNull(); + }); + + it('clears stale errors while resubmitting, preserves in-flight edits under resetOnSuccess, and does not revive errors or success after abort', async () => { + const firstSubmit = deferred>(); + const secondSubmit = deferred>(); + const thirdSubmit = deferred>(); + const thirdCallback = deferred(); + const fourthSubmit = deferred>(); + const fifthSubmit = deferred>(); + let call = 0; + const visit = vi.fn(async () => { + call += 1; + if (call === 1) { + return firstSubmit.promise; + } + if (call === 2) { + await secondSubmit.promise; + throw new DOMException('Navigation aborted', 'AbortError'); + } + if (call === 3) { + return thirdSubmit.promise; + } + if (call === 4) { + await fourthSubmit.promise; + throw new DOMException('Navigation aborted', 'AbortError'); + } + return fifthSubmit.promise; + }); + + const { formRef } = await mountForm(visit, { + resetOnSuccess: true, + onSuccess: () => (call === 3 ? thirdCallback.promise : undefined), + }); + + await act(async () => { + formRef.current!.setData('title', 'Attempt 1'); + }); + await submitPost(formRef.current!); + await act(async () => { + firstSubmit.resolve({ errors: { title: 'First failed' } }); + }); + expect(formRef.current!.errors).toEqual({ title: 'First failed' }); + + // Submit 2 starts (clearing errors) and aborts — Submit 1's validation errors must not revive + await submitPost(formRef.current!); + expect(formRef.current!.pending).toBe(true); + expect(formRef.current!.errors).toEqual({}); + + await act(async () => { + secondSubmit.resolve({}); + }); + expect(formRef.current!.pending).toBe(false); + expect(formRef.current!.errors).toEqual({}); + + // Submit 3 succeeds with async onSuccess callback and resetOnSuccess (no in-flight edit) + await submitPost(formRef.current!); + await act(async () => { + thirdSubmit.resolve({}); + }); + expect(formRef.current!.pending).toBe(true); + expect(formRef.current!.data.title).toBe('Attempt 1'); + + await act(async () => { + thirdCallback.resolve(); + }); + expect(formRef.current!.wasSuccessful).toBe(true); + expect(formRef.current!.recentlySuccessful).toBe(true); + expect(formRef.current!.data.title).toBe(''); + expect(formRef.current!.hasChanges).toBe(false); + + // Submit 4 starts and aborts — Submit 3's success state must not revive + await submitPost(formRef.current!); + expect(formRef.current!.wasSuccessful).toBe(false); + expect(formRef.current!.recentlySuccessful).toBe(false); + + await act(async () => { + fourthSubmit.resolve({}); + }); + expect(formRef.current!.pending).toBe(false); + expect(formRef.current!.wasSuccessful).toBe(false); + expect(formRef.current!.recentlySuccessful).toBe(false); + + // Submit 5: in-flight edit while request is pending must NOT be wiped by resetOnSuccess + await act(async () => { + formRef.current!.setData('title', 'Submitted 5'); + }); + await submitPost(formRef.current!); + await act(async () => { + formRef.current!.setData('title', 'Typed while in flight'); + }); + await act(async () => { + fifthSubmit.resolve({}); + }); + expect(formRef.current!.data.title).toBe('Typed while in flight'); + expect(formRef.current!.hasChanges).toBe(true); + }); + + it('useIslandForm lets latest submission own UI state across completion orders and preserves post-success state', async () => { + const originalLocation = window.location; + const reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { href: 'http://localhost/test', reload: reloadMock }, + writable: true, + configurable: true, + }); + + try { + const submit1 = deferred(); + const submit2 = deferred(); + const submit3 = deferred(); + const submit4 = deferred(); + const requestBodies: Array> = []; + let call = 0; + + vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + call += 1; + requestBodies.push(JSON.parse(String(init?.body)) as Record); + if (call === 1) { + return submit1.promise; + } + if (call === 2) { + return submit2.promise; + } + return call === 3 ? submit3.promise : submit4.promise; + }); + + const defaults = { profile: { name: 'Initial' }, tags: ['a'] }; + const islandFormRef: { current: IslandFormReturn | null } = { + current: null, + }; + + function IslandApp() { + islandFormRef.current = useIslandForm(defaults); + return null; + } + + await mountApp(createElement(IslandApp)); + const getForm = () => islandFormRef.current!; + + // Order 1: older submission (submit1) finishes before latest (submit2) + await act(async () => { + getForm().setData('profile', { name: 'First' }); + }); + let p1!: Promise; + let p2!: Promise; + await act(async () => { + p1 = getForm().post('/island-action'); + }); + await act(async () => { + getForm().setData('profile', { name: 'Second' }); + }); + await act(async () => { + p2 = getForm().post('/island-action'); + }); + expect(requestBodies).toEqual([ + { profile: { name: 'First' }, tags: ['a'] }, + { profile: { name: 'Second' }, tags: ['a'] }, + ]); + + await act(async () => { + submit1.resolve( + new Response(JSON.stringify({ errors: { profile: 'Stale error' } }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await p1; + }); + expect(getForm().pending).toBe(true); + expect(getForm().errors).toEqual({}); + + await act(async () => { + submit2.resolve( + new Response(JSON.stringify({ errors: { profile: 'Profile invalid' } }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await p2; + }); + expect(getForm().pending).toBe(false); + expect(getForm().errors).toEqual({ profile: 'Profile invalid' }); + + // Order 2: latest submission (submit4) finishes before older (submit3) + let p3!: Promise; + let p4!: Promise; + await act(async () => { + p3 = getForm().post('/island-action'); + }); + await act(async () => { + getForm().setData('profile', { name: 'Saved V4' }); + }); + await act(async () => { + p4 = getForm().post('/island-action'); + }); + await act(async () => { + getForm().setData('profile', { name: 'Edited after V4' }); + }); + + vi.useFakeTimers(); + + await act(async () => { + submit4.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await p4; + }); + expect(getForm().pending).toBe(false); + expect(getForm().wasSuccessful).toBe(true); + expect(getForm().recentlySuccessful).toBe(true); + expect(getForm().hasChanges).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + + await act(async () => { + getForm().reset(); + }); + expect(getForm().data).toEqual({ profile: { name: 'Saved V4' }, tags: ['a'] }); + expect(getForm().hasChanges).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(getForm().wasSuccessful).toBe(true); + expect(getForm().recentlySuccessful).toBe(false); + + await act(async () => { + submit3.resolve( + new Response(JSON.stringify({ errors: { profile: 'Stale V3 error' } }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await p3; + }); + expect(getForm().pending).toBe(false); + expect(getForm().errors).toEqual({}); + expect(getForm().wasSuccessful).toBe(true); + } finally { + vi.useRealTimers(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + } + }); +});