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, + }); + } + }); +});