From 3c9ddc5aec18864327165bd6b5f8d8411eb0a511 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Mon, 9 Nov 2026 20:30:25 +0800 Subject: [PATCH 1/2] fix(forms): let the latest submit own form state in Vue, Svelte and Solid A second submit aborts the first through the router, and the first's cleanup then cleared `pending` while the second was still running. An older submit that still completed could also apply its errors, success state and defaults. Island forms have no abort, so an older response could overwrite a newer result, reload the page or redirect. Only the latest submit now updates form state or changes the page, matching the request ID guard React's useIslandForm gained in #738. An older submit's real failure still rejects its own promise. Co-Authored-By: Claude Opus 5.5 --- packages/solid/src/runtime/use-form.ts | 10 +- packages/solid/src/runtime/use-island-form.ts | 10 +- .../solid/test/unit/form-submissions.test.ts | 197 ++++++++++++++++++ .../svelte/src/runtime/use-form.svelte.ts | 10 +- .../src/runtime/use-island-form.svelte.ts | 10 +- packages/svelte/test/unit/use-form.test.ts | 187 ++++++++++++++++- packages/vue/src/runtime/use-form.ts | 10 +- packages/vue/src/runtime/use-island-form.ts | 10 +- packages/vue/test/unit/use-form.test.ts | 157 +++++++++++++- 9 files changed, 588 insertions(+), 13 deletions(-) create mode 100644 packages/solid/test/unit/form-submissions.test.ts diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts index 38e649520..6035c39f9 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,7 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; setPending(true); setWasSuccessful(false); setRecentlySuccessful(false); @@ -144,6 +146,10 @@ export function useForm( data: { ...data }, ...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); @@ -172,7 +178,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..9ce423234 100644 --- a/packages/solid/src/runtime/use-island-form.ts +++ b/packages/solid/src/runtime/use-island-form.ts @@ -25,6 +25,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 +64,7 @@ export function useIslandForm>(defaults: T): I } async function submit(url: string, method: string) { + const requestId = ++latestRequestId; setPending(true); setWasSuccessful(false); setRecentlySuccessful(false); @@ -74,6 +76,10 @@ export function useIslandForm>(defaults: T): I try { const result = await submitIslandAction(url, method, data); + // 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; @@ -92,7 +98,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..a28be79d9 --- /dev/null +++ b/packages/solid/test/unit/form-submissions.test.ts @@ -0,0 +1,197 @@ +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, +) => 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() { + const visits: Array> = []; + const router = { + visit: () => { + 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: '' }); + return null; + }, + }); + }); + return { form, visits }; +} + +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); + }); +}); + +describe('useIslandForm overlapping submits', () => { + 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 }; + } + + 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' }); + }); +}); diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts index f9daf68f0..7ca550262 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,7 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; pending = true; wasSuccessful = false; recentlySuccessful = false; @@ -136,6 +138,10 @@ export function useForm( data: { ...data }, ...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); @@ -166,7 +172,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..27d31d0d3 100644 --- a/packages/svelte/src/runtime/use-island-form.svelte.ts +++ b/packages/svelte/src/runtime/use-island-form.svelte.ts @@ -23,6 +23,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 +54,7 @@ export function useIslandForm>(defaults: T): I } async function submit(url: RouteUrl, method: string) { + const requestId = ++latestRequestId; pending = true; wasSuccessful = false; recentlySuccessful = false; @@ -64,6 +66,10 @@ export function useIslandForm>(defaults: T): I try { const result = await submitIslandAction(url, method, data); + // 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; @@ -84,7 +90,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..46f6e1cda 100644 --- a/packages/svelte/test/unit/use-form.test.ts +++ b/packages/svelte/test/unit/use-form.test.ts @@ -7,13 +7,24 @@ 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; + post(): Promise; +}; + +type TestIslandForm = { + readonly errors: Record; + readonly pending: boolean; + readonly wasSuccessful: boolean; + post(url: string): Promise; }; // The unit project has no Svelte plugin and resolves `svelte` to its server entry, so compile @@ -42,6 +53,7 @@ function writeCompiled(file: string, code: string): void { let svelte: typeof import('svelte'); let Host: Component<{ defaults: Record; onform: (form: TestForm) => void }>; +let useIslandForm: (defaults: Record) => TestIslandForm; const mounted: Array> = []; beforeAll(async () => { @@ -60,6 +72,13 @@ beforeAll(async () => { `; 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,8 +91,10 @@ 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, +): TestForm { let form!: TestForm; mounted.push( svelte.mount(Host, { @@ -133,3 +154,163 @@ 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() { + const visits: Array> = []; + const router = { + visit: () => { + const visit = deferred(); + visits.push(visit); + return visit.promise; + }, + } as unknown as VoidRouter; + return { form: mountForm({ title: '' }, router), visits }; +} + +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); + }); +}); + +describe('useIslandForm overlapping submits', () => { + const originalLocation = window.location; + + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + 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 }; + } + + 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' }); + }); +}); diff --git a/packages/vue/src/runtime/use-form.ts b/packages/vue/src/runtime/use-form.ts index 25be0b9f5..88fbb972d 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,7 @@ export function useForm( } async function submit(method: string, opts: FormSubmitOptions = {}) { + const requestId = ++latestRequestId; pending.value = true; wasSuccessful.value = false; recentlySuccessful.value = false; @@ -119,6 +121,10 @@ export function useForm( data: { ...data }, ...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); @@ -147,7 +153,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..7c6b411ca 100644 --- a/packages/vue/src/runtime/use-island-form.ts +++ b/packages/vue/src/runtime/use-island-form.ts @@ -29,6 +29,7 @@ export function useIslandForm( let defaults_ = { ...defaults }; let successTimeout: ReturnType | null = null; + let latestRequestId = 0; const hasChanges = ref(false); watch( @@ -68,6 +69,7 @@ export function useIslandForm( } async function submit(url: RouteUrl, method: string) { + const requestId = ++latestRequestId; pending.value = true; wasSuccessful.value = false; recentlySuccessful.value = false; @@ -79,6 +81,10 @@ export function useIslandForm( try { const result = await submitIslandAction(url, method, data); + // 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; @@ -99,7 +105,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..2701472ff 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,152 @@ 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() { + const visits: Array> = []; + const router = { + visit: () => { + const visit = deferred(); + visits.push(visit); + return visit.promise; + }, + } as unknown as VoidRouter; + return { form: await mountForm({ title: '' }, undefined, router), visits }; +} + +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); + }); +}); + +describe('useIslandForm overlapping submits', () => { + 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 }; + } + + 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' }); + }); +}); From e4ca5a7130d64e3c76e4f87ea67675fa63f1c3da Mon Sep 17 00:00:00 2001 From: Yangshun Date: Mon, 9 Nov 2026 20:32:09 +0800 Subject: [PATCH 2/2] fix(forms): keep edits made during a submit unsaved in Vue, Svelte and Solid On success these forms saved their values as of the response, so anything typed while the request was in flight counted as saved: `hasChanges` read false and `reset()` restored text that was never submitted. `resetOnSuccess` also wiped those edits, and page forms sent a shallow copy that shared nested values with the live form. Each form now takes a deep copy when submit starts, sends it and saves it as the new defaults. `resetOnSuccess` only clears the data if it has not changed since submit. This matches what React's forms have done since #738. Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/actions-and-forms.md | 30 ++-- packages/solid/src/runtime/use-form.ts | 10 +- packages/solid/src/runtime/use-island-form.ts | 7 +- .../solid/test/unit/form-submissions.test.ts | 102 ++++++++++--- .../svelte/src/runtime/use-form.svelte.ts | 10 +- .../src/runtime/use-island-form.svelte.ts | 7 +- packages/svelte/test/unit/use-form.test.ts | 140 ++++++++++++++---- packages/vue/src/runtime/use-form.ts | 10 +- packages/vue/src/runtime/use-island-form.ts | 7 +- packages/vue/test/unit/use-form.test.ts | 98 ++++++++++-- 10 files changed, 324 insertions(+), 97 deletions(-) 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 6035c39f9..8090de7a3 100644 --- a/packages/solid/src/runtime/use-form.ts +++ b/packages/solid/src/runtime/use-form.ts @@ -125,6 +125,8 @@ 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); @@ -143,7 +145,7 @@ 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. @@ -164,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); diff --git a/packages/solid/src/runtime/use-island-form.ts b/packages/solid/src/runtime/use-island-form.ts index 9ce423234..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, @@ -65,6 +66,8 @@ 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); @@ -75,7 +78,7 @@ 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; @@ -89,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') { diff --git a/packages/solid/test/unit/form-submissions.test.ts b/packages/solid/test/unit/form-submissions.test.ts index a28be79d9..b03698b30 100644 --- a/packages/solid/test/unit/form-submissions.test.ts +++ b/packages/solid/test/unit/form-submissions.test.ts @@ -25,6 +25,7 @@ type TestForm = { const useForm = typedUseForm as unknown as ( url: string, defaults: Record, + options?: { resetOnSuccess?: boolean }, ) => TestForm; type Deferred = { @@ -59,10 +60,12 @@ afterEach(() => { } }); -function mountOverlappingForm() { +function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { const visits: Array> = []; + const sent: Array = []; const router = { - visit: () => { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); const visit = deferred(); visits.push(visit); return visit.promise; @@ -74,12 +77,12 @@ function mountOverlappingForm() { createComponent(RouterContext.Provider, { value: router, get children() { - form = useForm('/notes?create', { title: '' }); + form = useForm('/notes?create', { title: '' }, options); return null; }, }); }); - return { form, visits }; + return { form, visits, sent }; } describe('useForm overlapping submits', () => { @@ -147,23 +150,23 @@ describe('useForm overlapping submits', () => { }); }); -describe('useIslandForm overlapping submits', () => { - 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 }; - } +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'); @@ -195,3 +198,62 @@ describe('useIslandForm overlapping submits', () => { 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 7ca550262..3cf6cfe5f 100644 --- a/packages/svelte/src/runtime/use-form.svelte.ts +++ b/packages/svelte/src/runtime/use-form.svelte.ts @@ -121,6 +121,8 @@ 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; @@ -135,7 +137,7 @@ 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. @@ -158,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; diff --git a/packages/svelte/src/runtime/use-island-form.svelte.ts b/packages/svelte/src/runtime/use-island-form.svelte.ts index 27d31d0d3..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, @@ -55,6 +56,8 @@ 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; @@ -65,7 +68,7 @@ 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; @@ -79,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); diff --git a/packages/svelte/test/unit/use-form.test.ts b/packages/svelte/test/unit/use-form.test.ts index 46f6e1cda..115528d33 100644 --- a/packages/svelte/test/unit/use-form.test.ts +++ b/packages/svelte/test/unit/use-form.test.ts @@ -17,13 +17,16 @@ type TestForm = { 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; }; @@ -52,7 +55,11 @@ 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> = []; @@ -67,8 +74,8 @@ 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; @@ -94,12 +101,13 @@ afterEach(() => { 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]]), }), ); @@ -178,16 +186,19 @@ function validationResponse(errors: Record): Response { }); } -function mountOverlappingForm() { +function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { const visits: Array> = []; + const sent: Array = []; const router = { - visit: () => { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); const visit = deferred(); visits.push(visit); return visit.promise; }, } as unknown as VoidRouter; - return { form: mountForm({ title: '' }, router), visits }; + const defaults = { title: '', profile: { name: '' } }; + return { form: mountForm(defaults, router, options), visits, sent }; } describe('useForm overlapping submits', () => { @@ -253,34 +264,34 @@ describe('useForm overlapping submits', () => { }); }); -describe('useIslandForm overlapping submits', () => { - const originalLocation = window.location; - - afterEach(() => { - vi.unstubAllGlobals(); - Object.defineProperty(window, 'location', { - value: originalLocation, - writable: true, - configurable: true, - }); +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 }; +} - 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: '' }); @@ -314,3 +325,68 @@ describe('useIslandForm overlapping submits', () => { 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 88fbb972d..975dd04be 100644 --- a/packages/vue/src/runtime/use-form.ts +++ b/packages/vue/src/runtime/use-form.ts @@ -100,6 +100,8 @@ 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; @@ -118,7 +120,7 @@ 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. @@ -139,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; diff --git a/packages/vue/src/runtime/use-island-form.ts b/packages/vue/src/runtime/use-island-form.ts index 7c6b411ca..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, @@ -70,6 +71,8 @@ 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; @@ -80,7 +83,7 @@ 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; @@ -94,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); diff --git a/packages/vue/test/unit/use-form.test.ts b/packages/vue/test/unit/use-form.test.ts index 2701472ff..143d5a3a7 100644 --- a/packages/vue/test/unit/use-form.test.ts +++ b/packages/vue/test/unit/use-form.test.ts @@ -227,16 +227,19 @@ afterEach(() => { vi.unstubAllGlobals(); }); -async function mountOverlappingForm() { +async function mountOverlappingForm(options?: { resetOnSuccess?: boolean }) { const visits: Array> = []; + const sent: Array = []; const router = { - visit: () => { + visit: (_url: string, visitOptions: { data?: unknown }) => { + sent.push(visitOptions.data); const visit = deferred(); visits.push(visit); return visit.promise; }, } as unknown as VoidRouter; - return { form: await mountForm({ title: '' }, undefined, router), visits }; + const defaults = { title: '', profile: { name: '' } }; + return { form: await mountForm(defaults, options, router), visits, sent }; } describe('useForm overlapping submits', () => { @@ -302,19 +305,19 @@ describe('useForm overlapping submits', () => { }); }); -describe('useIslandForm overlapping submits', () => { - 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 }; - } +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: '' }); @@ -348,3 +351,68 @@ describe('useIslandForm overlapping submits', () => { 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'); + }); +});