diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 90220f4f5..c4666f845 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -149,21 +149,21 @@ The types are inferred from your action's `withValidator()` schema in the compan `useForm` returns a reactive object with: -| Property / Method | Purpose | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data` / `setData` | Current form values, typed to match the action's body schema. | -| `errors` | Field-level validation errors, keys typed to body field names. | -| `error` | Action error, or `null`. | -| `post`, `put`, `patch`, `delete` | Submit the form with that method. In React these are native form action callbacks. | -| `pending` | `true` while the submission is in flight. | -| `hasChanges` | `true` if form data differs from the current defaults. After a successful submit, defaults update to the submitted values so `hasChanges` becomes `false`. | -| `wasSuccessful` | `true` after a successful submission. Stays `true` until the next submission. | -| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. | -| `reset(...fields)` | Reset form data to the current defaults (last submitted values after success). Field names autocomplete. Pass `{ initial: true }` first to reset to the values passed to `useForm`. | -| `resetOnSuccess` (option) | When `true`, a successful submit resets both form data and defaults to the mount-time values. Useful for create forms that should clear after save. | -| `setError(field, message)` | Set a client-side validation error on a field. | -| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. | -| `clearError()` | Clear `error`. | +| Property / Method | Purpose | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data` / `setData` | Current form values, typed to match the action's body schema. | +| `errors` | Field-level validation errors, keys typed to body field names. | +| `error` | Action error, or `null`. | +| `post`, `put`, `patch`, `delete` | Submit the form with that method. In React these are native form action callbacks. | +| `pending` | `true` while the submission is in flight. | +| `hasChanges` | `true` if form data differs from the current defaults. After a successful submit, defaults update to the submitted values, so `hasChanges` becomes `false` unless the form was edited during the submit. | +| `wasSuccessful` | `true` after a successful submission. Stays `true` until the next submission. | +| `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. | +| `reset(...fields)` | Reset form data to the current defaults (last submitted values after success). Field names autocomplete. Pass `{ initial: true }` first to reset to the values passed to `useForm`. | +| `resetOnSuccess` (option) | When `true`, a successful submit resets defaults to the mount-time values, and form data too unless it was edited during the submit. Useful for create forms that should clear after save. | +| `setError(field, message)` | Set a client-side validation error on a field. | +| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. | +| `clearError()` | Clear `error`. | Use `setError()` for client-side validation. Editing a field clears its error automatically. diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts index 38e649520..8090de7a3 100644 --- a/packages/solid/src/runtime/use-form.ts +++ b/packages/solid/src/runtime/use-form.ts @@ -79,6 +79,7 @@ export function useForm( const [defaults_, setDefaults] = createSignal>(initialDefaults); let successTimeout: ReturnType | null = null; + let latestRequestId = 0; const hasChanges = createMemo(() => { const current = defaults_(); @@ -123,6 +124,9 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); setPending(true); setWasSuccessful(false); setRecentlySuccessful(false); @@ -141,9 +145,13 @@ export function useForm( // request path (such as "..") fails this submit, not the page render. const result = await submitAction(router, interpolateRouteParams(url, options?.params), { method, - data: { ...data }, + data: submittedData, ...opts, }); + // A newer submit owns the form's state, even if this one still completed. + if (requestId !== latestRequestId) { + return; + } if (!result.ok) { const validationErrors = getValidationErrors(result.error.body); @@ -158,10 +166,12 @@ export function useForm( setWasSuccessful(true); setRecentlySuccessful(true); if (options?.resetOnSuccess) { - setDataStore(reconcile(cloneFormValue(initialDefaults))); + if (isEqualFormValue(data, submittedData)) { + setDataStore(reconcile(cloneFormValue(initialDefaults))); + } setDefaults(cloneFormValue(initialDefaults)); } else { - setDefaults(cloneFormValue(data)); + setDefaults(cloneFormValue(submittedData)); } successTimeout = setTimeout(() => { setRecentlySuccessful(false); @@ -172,7 +182,9 @@ export function useForm( } throw submitError; } finally { - setPending(false); + if (requestId === latestRequestId) { + setPending(false); + } } } diff --git a/packages/solid/src/runtime/use-island-form.ts b/packages/solid/src/runtime/use-island-form.ts index 5b6470f8c..f21ec8754 100644 --- a/packages/solid/src/runtime/use-island-form.ts +++ b/packages/solid/src/runtime/use-island-form.ts @@ -1,6 +1,7 @@ import { createSignal, createMemo } from 'solid-js'; import { createStore, reconcile } from 'solid-js/store'; import { + cloneFormValue, isEqualFormValue, submitIslandAction, type IslandFormState, @@ -25,6 +26,7 @@ export function useIslandForm>(defaults: T): I let defaults_ = { ...defaults }; let successTimeout: ReturnType | null = null; + let latestRequestId = 0; const hasChanges = createMemo(() => { return Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])); @@ -63,6 +65,9 @@ export function useIslandForm>(defaults: T): I } async function submit(url: string, method: string) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); setPending(true); setWasSuccessful(false); setRecentlySuccessful(false); @@ -73,7 +78,11 @@ export function useIslandForm>(defaults: T): I clearError(); try { - const result = await submitIslandAction(url, method, data); + const result = await submitIslandAction(url, method, submittedData); + // A newer submit owns the form's state and decides whether the page changes. + if (requestId !== latestRequestId) { + return; + } if (result.kind === 'redirect') { window.location.href = result.url; @@ -83,7 +92,7 @@ export function useIslandForm>(defaults: T): I if (result.kind === 'success') { setWasSuccessful(true); setRecentlySuccessful(true); - defaults_ = { ...data } as typeof defaults_; + defaults_ = submittedData as typeof defaults_; successTimeout = setTimeout(() => setRecentlySuccessful(false), 2000); window.location.reload(); } else if (result.kind === 'validation') { @@ -92,7 +101,9 @@ export function useIslandForm>(defaults: T): I setError(result.error); } } finally { - setPending(false); + if (requestId === latestRequestId) { + setPending(false); + } } } diff --git a/packages/solid/test/unit/form-submissions.test.ts b/packages/solid/test/unit/form-submissions.test.ts new file mode 100644 index 000000000..b03698b30 --- /dev/null +++ b/packages/solid/test/unit/form-submissions.test.ts @@ -0,0 +1,259 @@ +import { createComponent, createRoot } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { VoidRouter } from 'void/pages-client'; +import { RouterContext } from '../../src/runtime/context.ts'; +import { useForm as typedUseForm } from '../../src/runtime/use-form.ts'; +import { useIslandForm } from '../../src/runtime/use-island-form.ts'; + +// Client memos must react to edits; Solid's server runtime evaluates them only once. +vi.mock('solid-js', () => vi.importActual('solid-js/dist/solid.js')); +vi.mock('solid-js/store', () => + vi.importActual('solid-js/store/dist/store.js'), +); + +type TestForm = { + readonly data: Record; + readonly errors: Record; + readonly pending: boolean; + readonly wasSuccessful: boolean; + setData(field: string, value: unknown): void; + reset(): void; + post(): Promise; +}; + +// The public overload only accepts generated action URLs; these tests use plain paths. +const useForm = typedUseForm as unknown as ( + url: string, + defaults: Record, + options?: { resetOnSuccess?: boolean }, +) => TestForm; + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function validationResponse(errors: Record): Response { + return new Response(JSON.stringify({ errors }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const disposers: Array<() => void> = []; + +afterEach(() => { + vi.unstubAllGlobals(); + for (const dispose of disposers.splice(0)) { + dispose(); + } +}); + +function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { + const visits: Array> = []; + const sent: Array = []; + const router = { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); + const visit = deferred(); + visits.push(visit); + return visit.promise; + }, + } as unknown as VoidRouter; + let form!: TestForm; + createRoot((dispose) => { + disposers.push(dispose); + createComponent(RouterContext.Provider, { + value: router, + get children() { + form = useForm('/notes?create', { title: '' }, options); + return null; + }, + }); + }); + return { form, visits, sent }; +} + +describe('useForm overlapping submits', () => { + it('stays pending when the router aborts an older submit', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new DOMException('Navigation aborted', 'AbortError')); + await first; + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + expect(form.wasSuccessful).toBe(true); + }); + + it('ignores an older submit that finishes first', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].resolve({ errors: { title: 'Stale' } }); + await first; + expect(form.pending).toBe(true); + expect(form.errors).toEqual({}); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + expect(form.pending).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); + + it('ignores an older submit that finishes last', async () => { + const { form, visits } = mountOverlappingForm(); + form.setData('title', 'Edited'); + const first = form.post(); + const second = form.post(); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + visits[0].resolve({}); + await first; + + expect(form.wasSuccessful).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + // The older success did not save its values as the new defaults. + form.reset(); + expect(form.data.title).toBe(''); + }); + + it('rejects an older submit that fails without changing the form', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new Error('Older failure')); + await expect(first).rejects.toThrow('Older failure'); + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + }); +}); + +function mountIslandForm() { + const responses: Array> = []; + const reload = vi.fn(); + vi.stubGlobal('window', { location: { href: 'http://localhost/test', reload } }); + vi.stubGlobal('fetch', () => { + const response = deferred(); + responses.push(response); + return response.promise; + }); + const form = createRoot((dispose) => { + disposers.push(dispose); + return useIslandForm({ title: '' }); + }); + return { form, responses, reload }; +} + +describe('useIslandForm overlapping submits', () => { + it('ignores an older submit that finishes first', async () => { + const { form, responses } = mountIslandForm(); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[0].resolve(validationResponse({ title: 'Stale' })); + await first; + expect(form.pending).toBe(true); + expect(form.errors).toEqual({}); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + expect(form.pending).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); + + it('does not reload for an older submit that finishes last', async () => { + const { form, responses, reload } = mountIslandForm(); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + responses[0].resolve(new Response('{}', { status: 200 })); + await first; + + expect(reload).not.toHaveBeenCalled(); + expect(form.wasSuccessful).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); +}); + +describe('useForm edits made during a submit', () => { + it('sends and saves the values from when the submit started', async () => { + const { form, visits, sent } = mountOverlappingForm(); + form.setData('title', 'Sent'); + const submit = form.post(); + form.setData('title', 'Typed while in flight'); + + visits[0].resolve({}); + await submit; + + expect(sent).toEqual([{ title: 'Sent' }]); + expect(form.data.title).toBe('Typed while in flight'); + form.reset(); + expect(form.data).toEqual({ title: 'Sent' }); + }); + + it('resetOnSuccess keeps an edit made during the submit', async () => { + const { form, visits } = mountOverlappingForm({ resetOnSuccess: true }); + form.setData('title', 'Sent'); + const submit = form.post(); + form.setData('title', 'Typed while in flight'); + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe('Typed while in flight'); + form.reset(); + expect(form.data.title).toBe(''); + }); + + it('resetOnSuccess clears the form when nothing changed during the submit', async () => { + const { form, visits } = mountOverlappingForm({ resetOnSuccess: true }); + form.setData('title', 'Sent'); + const submit = form.post(); + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe(''); + }); +}); + +describe('useIslandForm edits made during a submit', () => { + it('saves the values from when the submit started', async () => { + const { form, responses, reload } = mountIslandForm(); + form.setData('title', 'Sent'); + const submit = form.post('/island'); + form.setData('title', 'Typed while in flight'); + + responses[0].resolve(new Response('{}', { status: 200 })); + await submit; + + expect(reload).toHaveBeenCalledTimes(1); + expect(form.data.title).toBe('Typed while in flight'); + form.reset(); + expect(form.data.title).toBe('Sent'); + }); +}); diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts index f9daf68f0..3cf6cfe5f 100644 --- a/packages/svelte/src/runtime/use-form.svelte.ts +++ b/packages/svelte/src/runtime/use-form.svelte.ts @@ -47,6 +47,7 @@ export function useForm( let defaults_ = $state.raw(initialDefaults); let successTimeout: ReturnType | null = null; + let latestRequestId = 0; let dataSnapshot = cloneFormValue(initialDefaults); $effect(() => { @@ -119,6 +120,9 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); pending = true; wasSuccessful = false; recentlySuccessful = false; @@ -133,9 +137,13 @@ export function useForm( // request path (such as "..") fails this submit, not the page render. const result = await submitAction(router, interpolateRouteParams(url, options?.params), { method, - data: { ...data }, + data: submittedData, ...opts, }); + // A newer submit owns the form's state, even if this one still completed. + if (requestId !== latestRequestId) { + return; + } if (!result.ok) { const validationErrors = getValidationErrors(result.error.body); @@ -152,10 +160,12 @@ export function useForm( wasSuccessful = true; recentlySuccessful = true; if (options?.resetOnSuccess) { - data = cloneFormValue(initialDefaults); + if (isEqualFormValue(data, submittedData)) { + data = cloneFormValue(initialDefaults); + } defaults_ = cloneFormValue(initialDefaults); } else { - defaults_ = cloneFormValue(data); + defaults_ = cloneFormValue(submittedData); } successTimeout = setTimeout(() => { recentlySuccessful = false; @@ -166,7 +176,9 @@ export function useForm( } throw submitError; } finally { - pending = false; + if (requestId === latestRequestId) { + pending = false; + } } } diff --git a/packages/svelte/src/runtime/use-island-form.svelte.ts b/packages/svelte/src/runtime/use-island-form.svelte.ts index 8ab006b34..4e5545822 100644 --- a/packages/svelte/src/runtime/use-island-form.svelte.ts +++ b/packages/svelte/src/runtime/use-island-form.svelte.ts @@ -1,4 +1,5 @@ import { + cloneFormValue, isEqualFormValue, submitIslandAction, type IslandFormState, @@ -23,6 +24,7 @@ export function useIslandForm>(defaults: T): I let defaults_ = { ...defaults }; let successTimeout: ReturnType | null = null; + let latestRequestId = 0; const hasChanges = $derived( Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])), @@ -53,6 +55,9 @@ export function useIslandForm>(defaults: T): I } async function submit(url: RouteUrl, method: string) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); pending = true; wasSuccessful = false; recentlySuccessful = false; @@ -63,7 +68,11 @@ export function useIslandForm>(defaults: T): I clearError(); try { - const result = await submitIslandAction(url, method, data); + const result = await submitIslandAction(url, method, submittedData); + // A newer submit owns the form's state and decides whether the page changes. + if (requestId !== latestRequestId) { + return; + } if (result.kind === 'redirect') { window.location.href = result.url; @@ -73,7 +82,7 @@ export function useIslandForm>(defaults: T): I if (result.kind === 'success') { wasSuccessful = true; recentlySuccessful = true; - defaults_ = { ...data }; + defaults_ = submittedData; successTimeout = setTimeout(() => { recentlySuccessful = false; }, 2000); @@ -84,7 +93,9 @@ export function useIslandForm>(defaults: T): I error = result.error; } } finally { - pending = false; + if (requestId === latestRequestId) { + pending = false; + } } } diff --git a/packages/svelte/test/unit/use-form.test.ts b/packages/svelte/test/unit/use-form.test.ts index 1b565611b..115528d33 100644 --- a/packages/svelte/test/unit/use-form.test.ts +++ b/packages/svelte/test/unit/use-form.test.ts @@ -7,13 +7,27 @@ import { pathToFileURL } from 'node:url'; import type { Component } from 'svelte'; import { compile, compileModule } from 'svelte/compiler'; import { transformWithOxc } from 'vite'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'; import type { VoidRouter } from 'void/pages-client'; type TestForm = { readonly data: Record; readonly errors: Record; + readonly hasChanges: boolean; + readonly pending: boolean; + readonly wasSuccessful: boolean; setError(field: string, message: string): void; + reset(): void; + post(): Promise; +}; + +type TestIslandForm = { + readonly data: Record; + readonly errors: Record; + readonly pending: boolean; + readonly wasSuccessful: boolean; + reset(): void; + post(url: string): Promise; }; // The unit project has no Svelte plugin and resolves `svelte` to its server entry, so compile @@ -41,7 +55,12 @@ function writeCompiled(file: string, code: string): void { } let svelte: typeof import('svelte'); -let Host: Component<{ defaults: Record; onform: (form: TestForm) => void }>; +let Host: Component<{ + defaults: Record; + options?: { resetOnSuccess?: boolean }; + onform: (form: TestForm) => void; +}>; +let useIslandForm: (defaults: Record) => TestIslandForm; const mounted: Array> = []; beforeAll(async () => { @@ -55,11 +74,18 @@ beforeAll(async () => { ); const host = ``; writeCompiled('Host.js', compile(host, { filename: 'Host.svelte', generate: 'client' }).js.code); Host = (await import(pathToFileURL(join(TMP, 'Host.js')).href)).default; + const islandSource = join(import.meta.dirname, '../../src/runtime/use-island-form.svelte.ts'); + const island = await transformWithOxc(readFileSync(islandSource, 'utf8'), islandSource); + writeCompiled( + 'use-island-form.svelte.js', + compileModule(island.code, { filename: islandSource, generate: 'client' }).js.code, + ); + ({ useIslandForm } = await import(pathToFileURL(join(TMP, 'use-island-form.svelte.js')).href)); }); afterAll(() => { @@ -72,13 +98,16 @@ afterEach(() => { } }); -function mountForm(defaults: Record): TestForm { - const router = { visit: async () => ({}) } as unknown as VoidRouter; +function mountForm( + defaults: Record, + router = { visit: async () => ({}) } as unknown as VoidRouter, + options?: { resetOnSuccess?: boolean }, +): TestForm { let form!: TestForm; mounted.push( svelte.mount(Host, { target: document.createElement('div'), - props: { defaults, onform: (value) => (form = value) }, + props: { defaults, options, onform: (value) => (form = value) }, context: new Map([['__void_router', router]]), }), ); @@ -133,3 +162,231 @@ describe('useForm field errors', () => { expect({ ...form.errors }).toEqual({}); }); }); + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function validationResponse(errors: Record): Response { + return new Response(JSON.stringify({ errors }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { + const visits: Array> = []; + const sent: Array = []; + const router = { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); + const visit = deferred(); + visits.push(visit); + return visit.promise; + }, + } as unknown as VoidRouter; + const defaults = { title: '', profile: { name: '' } }; + return { form: mountForm(defaults, router, options), visits, sent }; +} + +describe('useForm overlapping submits', () => { + it('stays pending when the router aborts an older submit', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new DOMException('Navigation aborted', 'AbortError')); + await first; + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + expect(form.wasSuccessful).toBe(true); + }); + + it('ignores an older submit that finishes first', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].resolve({ errors: { title: 'Stale' } }); + await first; + expect(form.pending).toBe(true); + expect({ ...form.errors }).toEqual({}); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + expect(form.pending).toBe(false); + expect({ ...form.errors }).toEqual({ title: 'Invalid' }); + }); + + it('ignores an older submit that finishes last', async () => { + const { form, visits } = mountOverlappingForm(); + form.data.title = 'Edited'; + const first = form.post(); + const second = form.post(); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + visits[0].resolve({}); + await first; + + expect(form.wasSuccessful).toBe(false); + expect({ ...form.errors }).toEqual({ title: 'Invalid' }); + expect(form.hasChanges).toBe(true); + }); + + it('rejects an older submit that fails without changing the form', async () => { + const { form, visits } = mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new Error('Older failure')); + await expect(first).rejects.toThrow('Older failure'); + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + }); +}); + +const originalLocation = window.location; + +function stubIslandPage() { + const responses: Array> = []; + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + value: { href: 'http://localhost/test', reload }, + writable: true, + configurable: true, + }); + vi.stubGlobal('fetch', () => { + const response = deferred(); + responses.push(response); + return response.promise; + }); + return { responses, reload }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); + +describe('useIslandForm overlapping submits', () => { + it('ignores an older submit that finishes first', async () => { + const { responses } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[0].resolve(validationResponse({ title: 'Stale' })); + await first; + expect(form.pending).toBe(true); + expect({ ...form.errors }).toEqual({}); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + expect(form.pending).toBe(false); + expect({ ...form.errors }).toEqual({ title: 'Invalid' }); + }); + + it('does not reload for an older submit that finishes last', async () => { + const { responses, reload } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + responses[0].resolve(new Response('{}', { status: 200 })); + await first; + + expect(reload).not.toHaveBeenCalled(); + expect(form.wasSuccessful).toBe(false); + expect({ ...form.errors }).toEqual({ title: 'Invalid' }); + }); +}); + +describe('useForm edits made during a submit', () => { + it('sends and saves the values from when the submit started', async () => { + const { form, visits, sent } = mountOverlappingForm(); + form.data.title = 'Sent'; + (form.data.profile as { name: string }).name = 'Ann'; + const submit = form.post(); + form.data.title = 'Typed while in flight'; + (form.data.profile as { name: string }).name = 'Typed'; + + visits[0].resolve({}); + await submit; + + expect(sent).toEqual([{ title: 'Sent', profile: { name: 'Ann' } }]); + expect(form.data.title).toBe('Typed while in flight'); + expect(form.hasChanges).toBe(true); + form.reset(); + expect(form.data).toEqual({ title: 'Sent', profile: { name: 'Ann' } }); + }); + + it('resetOnSuccess keeps an edit made during the submit', async () => { + const { form, visits } = mountOverlappingForm({ resetOnSuccess: true }); + form.data.title = 'Sent'; + const submit = form.post(); + form.data.title = 'Typed while in flight'; + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe('Typed while in flight'); + expect(form.hasChanges).toBe(true); + form.reset(); + expect(form.data.title).toBe(''); + }); + + it('resetOnSuccess clears the form when nothing changed during the submit', async () => { + const { form, visits } = mountOverlappingForm({ resetOnSuccess: true }); + form.data.title = 'Sent'; + const submit = form.post(); + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe(''); + expect(form.hasChanges).toBe(false); + }); +}); + +describe('useIslandForm edits made during a submit', () => { + it('saves the values from when the submit started', async () => { + const { responses, reload } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + form.data.title = 'Sent'; + const submit = form.post('/island'); + form.data.title = 'Typed while in flight'; + + responses[0].resolve(new Response('{}', { status: 200 })); + await submit; + + expect(reload).toHaveBeenCalledTimes(1); + expect(form.data.title).toBe('Typed while in flight'); + form.reset(); + expect(form.data.title).toBe('Sent'); + }); +}); diff --git a/packages/vue/src/runtime/use-form.ts b/packages/vue/src/runtime/use-form.ts index 25be0b9f5..975dd04be 100644 --- a/packages/vue/src/runtime/use-form.ts +++ b/packages/vue/src/runtime/use-form.ts @@ -45,6 +45,7 @@ export function useForm( const defaults_ = shallowRef(initialDefaults); let successTimeout: ReturnType | null = null; + let latestRequestId = 0; let dataSnapshot = cloneFormValue(initialDefaults); watch( @@ -98,6 +99,9 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); pending.value = true; wasSuccessful.value = false; recentlySuccessful.value = false; @@ -116,9 +120,13 @@ export function useForm( // request path (such as "..") fails this submit, not the page render. const result = await submitAction(router, interpolateRouteParams(url, options?.params), { method, - data: { ...data }, + data: submittedData, ...opts, }); + // A newer submit owns the form's state, even if this one still completed. + if (requestId !== latestRequestId) { + return; + } if (!result.ok) { const validationErrors = getValidationErrors(result.error.body); @@ -133,10 +141,12 @@ export function useForm( wasSuccessful.value = true; recentlySuccessful.value = true; if (options?.resetOnSuccess) { - Object.assign(data, cloneFormValue(initialDefaults)); + if (isEqualFormValue(data, submittedData)) { + Object.assign(data, cloneFormValue(initialDefaults)); + } defaults_.value = cloneFormValue(initialDefaults); } else { - defaults_.value = cloneFormValue(data); + defaults_.value = cloneFormValue(submittedData); } successTimeout = setTimeout(() => { recentlySuccessful.value = false; @@ -147,7 +157,9 @@ export function useForm( } throw submitError; } finally { - pending.value = false; + if (requestId === latestRequestId) { + pending.value = false; + } } } diff --git a/packages/vue/src/runtime/use-island-form.ts b/packages/vue/src/runtime/use-island-form.ts index f25b7c55c..1c6225e1e 100644 --- a/packages/vue/src/runtime/use-island-form.ts +++ b/packages/vue/src/runtime/use-island-form.ts @@ -1,4 +1,5 @@ import { + cloneFormValue, isEqualFormValue, submitIslandAction, type IslandFormState, @@ -29,6 +30,7 @@ export function useIslandForm( let defaults_ = { ...defaults }; let successTimeout: ReturnType | null = null; + let latestRequestId = 0; const hasChanges = ref(false); watch( @@ -68,6 +70,9 @@ export function useIslandForm( } async function submit(url: RouteUrl, method: string) { + const requestId = ++latestRequestId; + // Edits made while this request is in flight stay unsaved. + const submittedData = cloneFormValue(data); pending.value = true; wasSuccessful.value = false; recentlySuccessful.value = false; @@ -78,7 +83,11 @@ export function useIslandForm( clearError(); try { - const result = await submitIslandAction(url, method, data); + const result = await submitIslandAction(url, method, submittedData); + // A newer submit owns the form's state and decides whether the page changes. + if (requestId !== latestRequestId) { + return; + } if (result.kind === 'redirect') { window.location.href = result.url; @@ -88,7 +97,7 @@ export function useIslandForm( if (result.kind === 'success') { wasSuccessful.value = true; recentlySuccessful.value = true; - defaults_ = { ...data }; + defaults_ = submittedData; successTimeout = setTimeout(() => { recentlySuccessful.value = false; }, 2000); @@ -99,7 +108,9 @@ export function useIslandForm( error.value = result.error; } } finally { - pending.value = false; + if (requestId === latestRequestId) { + pending.value = false; + } } } diff --git a/packages/vue/test/unit/use-form.test.ts b/packages/vue/test/unit/use-form.test.ts index 0c1c94c1c..143d5a3a7 100644 --- a/packages/vue/test/unit/use-form.test.ts +++ b/packages/vue/test/unit/use-form.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import type { VoidRouter } from 'void/pages-client'; import { createSSRApp, defineComponent, h, nextTick } from 'vue'; import { renderToString } from 'vue/server-renderer'; import { useForm as typedUseForm } from '../../src/runtime/use-form.ts'; +import { useIslandForm } from '../../src/runtime/use-island-form.ts'; type TestForm = { setError(field: string, message: string): void; @@ -10,6 +11,7 @@ type TestForm = { data: Record; hasChanges: boolean; pending: boolean; + wasSuccessful: boolean; post(): Promise; reset: (...args: Array) => void; }; @@ -47,10 +49,8 @@ async function renderForm(url: string, params: Record) { async function mountForm( defaults: Record, options?: { resetOnSuccess?: boolean }, + router = { visit: async () => ({}) } as unknown as VoidRouter, ): Promise { - const router = { - visit: async () => ({}), - } as unknown as VoidRouter; let form!: TestForm; const app = createSSRApp( defineComponent({ @@ -199,3 +199,220 @@ describe('useForm params', () => { expect(form.pending).toBe(false); }); }); + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function validationResponse(errors: Record): Response { + return new Response(JSON.stringify({ errors }), { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { + const visits: Array> = []; + const sent: Array = []; + const router = { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); + const visit = deferred(); + visits.push(visit); + return visit.promise; + }, + } as unknown as VoidRouter; + const defaults = { title: '', profile: { name: '' } }; + return { form: await mountForm(defaults, options, router), visits, sent }; +} + +describe('useForm overlapping submits', () => { + it('stays pending when the router aborts an older submit', async () => { + const { form, visits } = await mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new DOMException('Navigation aborted', 'AbortError')); + await first; + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + expect(form.wasSuccessful).toBe(true); + }); + + it('ignores an older submit that finishes first', async () => { + const { form, visits } = await mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].resolve({ errors: { title: 'Stale' } }); + await first; + expect(form.pending).toBe(true); + expect(form.errors).toEqual({}); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + expect(form.pending).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); + + it('ignores an older submit that finishes last', async () => { + const { form, visits } = await mountOverlappingForm(); + form.data.title = 'Edited'; + const first = form.post(); + const second = form.post(); + + visits[1].resolve({ errors: { title: 'Invalid' } }); + await second; + visits[0].resolve({}); + await first; + + expect(form.wasSuccessful).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + expect(form.hasChanges).toBe(true); + }); + + it('rejects an older submit that fails without changing the form', async () => { + const { form, visits } = await mountOverlappingForm(); + const first = form.post(); + const second = form.post(); + + visits[0].reject(new Error('Older failure')); + await expect(first).rejects.toThrow('Older failure'); + expect(form.pending).toBe(true); + + visits[1].resolve({}); + await second; + expect(form.pending).toBe(false); + }); +}); + +function stubIslandPage() { + const responses: Array> = []; + const reload = vi.fn(); + vi.stubGlobal('window', { location: { href: 'http://localhost/test', reload } }); + vi.stubGlobal('fetch', () => { + const response = deferred(); + responses.push(response); + return response.promise; + }); + return { responses, reload }; +} + +describe('useIslandForm overlapping submits', () => { + it('ignores an older submit that finishes first', async () => { + const { responses } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[0].resolve(validationResponse({ title: 'Stale' })); + await first; + expect(form.pending).toBe(true); + expect(form.errors).toEqual({}); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + expect(form.pending).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); + + it('does not reload for an older submit that finishes last', async () => { + const { responses, reload } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + const first = form.post('/island'); + const second = form.post('/island'); + + responses[1].resolve(validationResponse({ title: 'Invalid' })); + await second; + responses[0].resolve(new Response('{}', { status: 200 })); + await first; + + expect(reload).not.toHaveBeenCalled(); + expect(form.wasSuccessful).toBe(false); + expect(form.errors).toEqual({ title: 'Invalid' }); + }); +}); + +describe('useForm edits made during a submit', () => { + it('sends and saves the values from when the submit started', async () => { + const { form, visits, sent } = await mountOverlappingForm(); + form.data.title = 'Sent'; + (form.data.profile as { name: string }).name = 'Ann'; + const submit = form.post(); + form.data.title = 'Typed while in flight'; + (form.data.profile as { name: string }).name = 'Typed'; + + visits[0].resolve({}); + await submit; + + expect(sent).toEqual([{ title: 'Sent', profile: { name: 'Ann' } }]); + expect(form.data.title).toBe('Typed while in flight'); + expect(form.hasChanges).toBe(true); + form.reset(); + expect(form.data).toEqual({ title: 'Sent', profile: { name: 'Ann' } }); + }); + + it('resetOnSuccess keeps an edit made during the submit', async () => { + const { form, visits } = await mountOverlappingForm({ resetOnSuccess: true }); + form.data.title = 'Sent'; + const submit = form.post(); + form.data.title = 'Typed while in flight'; + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe('Typed while in flight'); + expect(form.hasChanges).toBe(true); + form.reset(); + expect(form.data.title).toBe(''); + }); + + it('resetOnSuccess clears the form when nothing changed during the submit', async () => { + const { form, visits } = await mountOverlappingForm({ resetOnSuccess: true }); + form.data.title = 'Sent'; + const submit = form.post(); + + visits[0].resolve({}); + await submit; + + expect(form.data.title).toBe(''); + expect(form.hasChanges).toBe(false); + }); +}); + +describe('useIslandForm edits made during a submit', () => { + it('saves the values from when the submit started', async () => { + const { responses, reload } = stubIslandPage(); + const form = useIslandForm({ title: '' }); + form.data.title = 'Sent'; + const submit = form.post('/island'); + form.data.title = 'Typed while in flight'; + + responses[0].resolve(new Response('{}', { status: 200 })); + await submit; + + expect(reload).toHaveBeenCalledTimes(1); + expect(form.data.title).toBe('Typed while in flight'); + form.reset(); + expect(form.data.title).toBe('Sent'); + }); +});