From 0d2ec58df945a5532af1ae4efc99d26808ecda34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 23 Oct 2026 02:51:14 +0000 Subject: [PATCH] fix(react): let useForm reset to mount-time defaults after submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #523 ## Summary After a successful submit, `useForm` still promotes submitted values to defaults (edit forms / `hasChanges` unchanged). You can now get back to mount-time values: - `reset({ initial: true }, ...fields)` — reset to the values passed to `useForm` on mount - `resetOnSuccess: true` on `useForm` options — clear create forms after success - `reset()` without options still targets current defaults (last submitted values after success) Shared via `parseFormResetArgs` in `void/pages-client`; React / Vue / Svelte / Solid + docs / codegen. ## Tests - `packages/void/test/unit/form-reset.test.ts` - `packages/vue/test/unit/use-form.test.ts` (reset / `reset({ initial: true })` / `resetOnSuccess`) Typecheck: `void`, `@void/react`, `@void/vue`, `@void/svelte`, `@void/solid`. Co-authored-by: froQ --- docs/guide/pages-routing/actions-and-forms.md | 7 ++- docs/guide/type-safety.md | 2 +- packages/react/src/runtime/use-form.ts | 32 +++++++--- packages/solid/src/runtime/use-form.ts | 25 ++++++-- .../svelte/src/runtime/use-form.svelte.ts | 25 ++++++-- packages/void/src/codegen/route-types.ts | 10 +-- packages/void/src/pages/client.ts | 5 ++ packages/void/src/pages/form-reset.ts | 17 ++++++ packages/void/src/routes-stub.ts | 8 ++- packages/void/test/unit/form-reset.test.ts | 31 ++++++++++ packages/vue/src/runtime/use-form.ts | 25 ++++++-- packages/vue/test/unit/use-form.test.ts | 61 ++++++++++++++++++- 12 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 packages/void/src/pages/form-reset.ts create mode 100644 packages/void/test/unit/form-reset.test.ts diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 620701ce4..4d33df87c 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -154,10 +154,11 @@ The types are inferred from your action's `withValidator()` schema in the compan | `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 initial values. | +| `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 initial values. Field names autocomplete. | +| `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. | | `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. | | `clearError()` | Clear `error`. | @@ -171,6 +172,8 @@ const form = useForm('/users/:id', { name: '' }, { params: { id: '42' } });
{/* submits to /users/42 */}
``` +Create forms often need empty fields again after a successful save. Either pass `resetOnSuccess: true` in the third argument, or call `form.reset({ initial: true })` when `form.wasSuccessful` becomes `true`. + ## Named Actions When a page needs multiple mutations, such as updating and deleting a user on the same page, export `actions` (plural) instead of `action`: diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index c7621cc44..a72e94650 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -109,7 +109,7 @@ The `action()` helper gets the same type checking. See [Actions & Forms](./pages | Database | Table columns, value types, insert/update shapes from Drizzle schema | | Handlers | `c.env` bindings, validator input, return type | | Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type | -| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args, `params` | +| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` | | `action()` | Action URLs, `data` payload, `params` | ## Serialization diff --git a/packages/react/src/runtime/use-form.ts b/packages/react/src/runtime/use-form.ts index 1b11862ae..420649a23 100644 --- a/packages/react/src/runtime/use-form.ts +++ b/packages/react/src/runtime/use-form.ts @@ -13,7 +13,9 @@ import { interpolateRouteParams, isAbortError, isEqualFormValue, + parseFormResetArgs, submitAction, + type FormResetOptions, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; import { RouterContext } from './context.ts'; @@ -32,6 +34,7 @@ type UntypedForm = { put(formData: FormData): void; patch(formData: FormData): void; delete(formData: FormData): void; + reset(options: FormResetOptions, ...fields: ReadonlyArray): void; reset(...fields: ReadonlyArray): void; clearErrors(...fields: ReadonlyArray): void; clearError(): void; @@ -58,6 +61,10 @@ export function useForm( put(formData: FormData): void; patch(formData: FormData): void; delete(formData: FormData): void; + reset( + options: FormResetOptions, + ...fields: ReadonlyArray & string> + ): void; reset(...fields: ReadonlyArray & string>): void; clearErrors(...fields: ReadonlyArray & string>): void; clearError(): void; @@ -66,7 +73,7 @@ export function useForm( export function useForm( url: string, defaults: Record, - options?: { params?: Record }, + options?: { params?: Record; resetOnSuccess?: boolean }, ): UntypedForm { const router = useContext(RouterContext); const [data, setDataState] = useState>(() => ({ ...defaults })); @@ -79,6 +86,7 @@ export function useForm( const [recentlySuccessful, setRecentlySuccessful] = useState(false); const defaultsRef = useRef(defaultsState); + const initialRef = useRef>({ ...defaults }); const dataRef = useRef(data); const successTimeoutRef = useRef | null>(null); @@ -93,13 +101,15 @@ export function useForm( setDataState(dataRef.current); }, []); - const reset = useCallback((...fields: ReadonlyArray) => { + const reset = useCallback((...args: ReadonlyArray) => { + const { initial, fields } = parseFormResetArgs(args); + const source = initial ? initialRef.current : defaultsRef.current; if (fields.length === 0) { - dataRef.current = { ...defaultsRef.current }; + dataRef.current = { ...source }; } else { const next = { ...dataRef.current }; for (const field of fields) { - next[field] = defaultsRef.current[field]; + next[field] = source[field]; } dataRef.current = next; } @@ -167,9 +177,17 @@ export function useForm( return previousSuccessVersion; } - const nextDefaults = { ...dataRef.current }; - defaultsRef.current = nextDefaults; - setDefaultsState(nextDefaults); + if (options?.resetOnSuccess) { + const initial = { ...initialRef.current }; + dataRef.current = initial; + setDataState(initial); + defaultsRef.current = initial; + setDefaultsState(initial); + } else { + const nextDefaults = { ...dataRef.current }; + defaultsRef.current = nextDefaults; + setDefaultsState(nextDefaults); + } setWasSuccessful(true); setRecentlySuccessful(true); successTimeoutRef.current = setTimeout(() => { diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts index 39a049431..60b462144 100644 --- a/packages/solid/src/runtime/use-form.ts +++ b/packages/solid/src/runtime/use-form.ts @@ -6,7 +6,9 @@ import { interpolateRouteParams, isAbortError, isEqualFormValue, + parseFormResetArgs, submitAction, + type FormResetOptions, type VisitOptions, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; @@ -28,6 +30,7 @@ type UntypedForm = { put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset(options: FormResetOptions, ...fields: ReadonlyArray): void; reset(...fields: ReadonlyArray): void; clearErrors(...fields: ReadonlyArray): void; clearError(): void; @@ -54,6 +57,10 @@ export function useForm( put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset( + options: FormResetOptions, + ...fields: ReadonlyArray & string> + ): void; reset(...fields: ReadonlyArray & string>): void; clearErrors(...fields: ReadonlyArray & string>): void; clearError(): void; @@ -62,7 +69,7 @@ export function useForm( export function useForm( url: string, defaults: Record, - options?: { params?: Record }, + options?: { params?: Record; resetOnSuccess?: boolean }, ): UntypedForm { const router = useContext(RouterContext); // ErrorsContext provides the errors signal accessor — a reactive function @@ -93,6 +100,7 @@ export function useForm( const [recentlySuccessful, setRecentlySuccessful] = createSignal(false); const [defaults_, setDefaults] = createSignal>({ ...defaults }); + const initialDefaults = { ...defaults }; let successTimeout: ReturnType | null = null; const hasChanges = createMemo(() => { @@ -104,12 +112,14 @@ export function useForm( setDataStore(field, value); } - function reset(...fields: ReadonlyArray) { + function reset(...args: ReadonlyArray) { + const { initial, fields } = parseFormResetArgs(args); + const source = initial ? initialDefaults : defaults_(); if (fields.length === 0) { - setDataStore(reconcile({ ...defaults_() })); + setDataStore(reconcile({ ...source })); } else { for (const field of fields) { - setDataStore(field, defaults_()[field]); + setDataStore(field, source[field]); } } } @@ -167,7 +177,12 @@ export function useForm( setWasSuccessful(true); setRecentlySuccessful(true); - setDefaults({ ...data }); + if (options?.resetOnSuccess) { + setDataStore(reconcile({ ...initialDefaults })); + setDefaults({ ...initialDefaults }); + } else { + setDefaults({ ...data }); + } successTimeout = setTimeout(() => { setRecentlySuccessful(false); }, 2000); diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts index b63ba4157..d6dc663dd 100644 --- a/packages/svelte/src/runtime/use-form.svelte.ts +++ b/packages/svelte/src/runtime/use-form.svelte.ts @@ -5,7 +5,9 @@ import { interpolateRouteParams, isAbortError, isEqualFormValue, + parseFormResetArgs, submitAction, + type FormResetOptions, type VisitOptions, type VoidRouter, } from 'void/pages-client'; @@ -26,6 +28,7 @@ type UntypedForm = { put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset(options: FormResetOptions, ...fields: ReadonlyArray): void; reset(...fields: ReadonlyArray): void; clearErrors(...fields: ReadonlyArray): void; clearError(): void; @@ -48,6 +51,10 @@ export function useForm( put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset( + options: FormResetOptions, + ...fields: ReadonlyArray & string> + ): void; reset(...fields: ReadonlyArray & string>): void; clearErrors(...fields: ReadonlyArray & string>): void; clearError(): void; @@ -56,7 +63,7 @@ export function useForm( export function useForm( url: string, defaults: Record, - options?: { params?: Record }, + options?: { params?: Record; resetOnSuccess?: boolean }, ): UntypedForm { const router = getContext('__void_router'); @@ -68,18 +75,21 @@ export function useForm( let recentlySuccessful = $state(false); let defaults_ = { ...defaults }; + const initialDefaults = { ...defaults }; let successTimeout: ReturnType | null = null; const hasChanges = $derived( Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])), ); - function reset(...fields: ReadonlyArray) { + function reset(...args: ReadonlyArray) { + const { initial, fields } = parseFormResetArgs(args); + const source = initial ? initialDefaults : defaults_; if (fields.length === 0) { - data = { ...defaults_ }; + data = { ...source }; } else { for (const field of fields) { - data[field] = defaults_[field]; + data[field] = source[field]; } } } @@ -129,7 +139,12 @@ export function useForm( wasSuccessful = true; recentlySuccessful = true; - defaults_ = { ...data }; + if (options?.resetOnSuccess) { + data = { ...initialDefaults }; + defaults_ = { ...initialDefaults }; + } else { + defaults_ = { ...data }; + } successTimeout = setTimeout(() => { recentlySuccessful = false; }, 2000); diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts index e6a909510..5bc9eb386 100644 --- a/packages/void/src/codegen/route-types.ts +++ b/packages/void/src/codegen/route-types.ts @@ -127,10 +127,12 @@ export function generateRouteTypes( lines.push(' export type HasActionParams ='); lines.push(' ResolveActionParams extends Record ? true : false;'); lines.push(''); - lines.push(' export type ActionFormOptions ='); - lines.push( - ' HasActionParams extends true ? { params: ResolveActionParams } : { params?: never };', - ); + lines.push(' type ActionFormBaseOptions = { resetOnSuccess?: boolean };'); + lines.push(''); + lines.push(' export type ActionFormOptions = ActionFormBaseOptions &'); + lines.push(' (HasActionParams extends true'); + lines.push(' ? { params: ResolveActionParams }'); + lines.push(' : { params?: never });'); lines.push(''); lines.push(' export type ActionUrl ='); lines.push(' | {'); diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index c2f27094e..9609187a4 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -28,6 +28,11 @@ export { } from './action-result.ts'; export type { ActionErrorHandlingScope, ActionResult } from './action-result.ts'; export { isEqualFormValue } from './form-value-equality.ts'; +export { + isFormResetOptions, + parseFormResetArgs, + type FormResetOptions, +} from './form-reset.ts'; export { interpolateRouteParams }; export { createPageNavigationEngine, diff --git a/packages/void/src/pages/form-reset.ts b/packages/void/src/pages/form-reset.ts new file mode 100644 index 000000000..88cd4a4cc --- /dev/null +++ b/packages/void/src/pages/form-reset.ts @@ -0,0 +1,17 @@ +export type FormResetOptions = { initial?: boolean }; + +export function isFormResetOptions(value: unknown): value is FormResetOptions { + return typeof value === 'object' && value !== null && 'initial' in value; +} + +export function parseFormResetArgs( + args: ReadonlyArray, +): { initial: boolean; fields: ReadonlyArray } { + if (args.length > 0 && isFormResetOptions(args[0])) { + return { + initial: args[0].initial === true, + fields: args.slice(1) as ReadonlyArray, + }; + } + return { initial: false, fields: args as ReadonlyArray }; +} diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index 01bceae10..4fb4e5de2 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -54,9 +54,13 @@ export type ResolveActionParams = export type HasActionParams = ResolveActionParams extends Record ? true : false; +type ActionFormBaseOptions = { resetOnSuccess?: boolean }; + /** Form options type (requires params for dynamic routes) */ -export type ActionFormOptions = - HasActionParams extends true ? { params: ResolveActionParams } : { params?: never }; +export type ActionFormOptions = ActionFormBaseOptions & + (HasActionParams extends true + ? { params: ResolveActionParams } + : { params?: never }); /** Valid useForm URLs — single-action pages + "page?name" combos + default actions */ export type ActionUrl = diff --git a/packages/void/test/unit/form-reset.test.ts b/packages/void/test/unit/form-reset.test.ts new file mode 100644 index 000000000..b17a52ccd --- /dev/null +++ b/packages/void/test/unit/form-reset.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { isFormResetOptions, parseFormResetArgs } from '../../src/pages/form-reset.ts'; + +describe('parseFormResetArgs', () => { + it('treats string arguments as field names', () => { + expect(parseFormResetArgs(['title', 'body'])).toEqual({ + initial: false, + fields: ['title', 'body'], + }); + }); + + it('reads initial from the first options object', () => { + expect(parseFormResetArgs([{ initial: true }])).toEqual({ initial: true, fields: [] }); + expect(parseFormResetArgs([{ initial: true }, 'title'])).toEqual({ + initial: true, + fields: ['title'], + }); + expect(parseFormResetArgs([{ initial: false }, 'title'])).toEqual({ + initial: false, + fields: ['title'], + }); + }); +}); + +describe('isFormResetOptions', () => { + it('detects reset option objects', () => { + expect(isFormResetOptions({ initial: true })).toBe(true); + expect(isFormResetOptions('title')).toBe(false); + expect(isFormResetOptions(null)).toBe(false); + }); +}); diff --git a/packages/vue/src/runtime/use-form.ts b/packages/vue/src/runtime/use-form.ts index 9fff54bb0..3403faabe 100644 --- a/packages/vue/src/runtime/use-form.ts +++ b/packages/vue/src/runtime/use-form.ts @@ -4,7 +4,9 @@ import { interpolateRouteParams, isAbortError, isEqualFormValue, + parseFormResetArgs, submitAction, + type FormResetOptions, type VisitOptions, type VoidRouter, } from 'void/pages-client'; @@ -26,6 +28,7 @@ type UntypedForm = { put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset(options: FormResetOptions, ...fields: ReadonlyArray): void; reset(...fields: ReadonlyArray): void; clearErrors(...fields: ReadonlyArray): void; clearError(): void; @@ -47,6 +50,10 @@ export function useForm( put(opts?: FormSubmitOptions): Promise; patch(opts?: FormSubmitOptions): Promise; delete(opts?: FormSubmitOptions): Promise; + reset( + options: FormResetOptions, + ...fields: ReadonlyArray & string> + ): void; reset(...fields: ReadonlyArray & string>): void; clearErrors(...fields: ReadonlyArray & string>): void; clearError(): void; @@ -55,7 +62,7 @@ export function useForm( export function useForm( url: string, defaults: Record, - options?: { params?: Record }, + options?: { params?: Record; resetOnSuccess?: boolean }, ): UntypedForm { const formDefaults = defaults; const router = inject('__void_router', null); @@ -67,6 +74,7 @@ export function useForm( const recentlySuccessful = ref(false); let defaults_ = { ...formDefaults }; + const initialDefaults = { ...formDefaults }; let successTimeout: ReturnType | null = null; const hasChanges = ref(false); @@ -80,12 +88,14 @@ export function useForm( { deep: true }, ); - function reset(...fields: ReadonlyArray) { + function reset(...args: ReadonlyArray) { + const { initial, fields } = parseFormResetArgs(args); + const source = initial ? initialDefaults : defaults_; if (fields.length === 0) { - Object.assign(data, { ...defaults_ }); + Object.assign(data, { ...source }); } else { for (const field of fields) { - data[field] = defaults_[field]; + data[field] = source[field]; } } } @@ -141,7 +151,12 @@ export function useForm( wasSuccessful.value = true; recentlySuccessful.value = true; - defaults_ = { ...data }; + if (options?.resetOnSuccess) { + Object.assign(data, { ...initialDefaults }); + defaults_ = { ...initialDefaults }; + } else { + defaults_ = { ...data }; + } 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 cd6e2577b..c1c6a7a49 100644 --- a/packages/vue/test/unit/use-form.test.ts +++ b/packages/vue/test/unit/use-form.test.ts @@ -4,12 +4,20 @@ import { createSSRApp, defineComponent, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; import { useForm as typedUseForm } from '../../src/runtime/use-form.ts'; +type TestForm = { + data: Record; + hasChanges: boolean; + pending: boolean; + post(): Promise; + reset: (...args: Array) => void; +}; + // The public overload only accepts generated action URLs; these tests use plain paths. const useForm = typedUseForm as unknown as ( url: string, defaults: Record, - options: { params: Record }, -) => { post(): Promise; readonly pending: boolean }; + options?: { params?: Record; resetOnSuccess?: boolean }, +) => TestForm; async function renderForm(url: string, params: Record) { const visits: Array = []; @@ -34,6 +42,55 @@ async function renderForm(url: string, params: Record) { return { form, visits }; } +async function mountForm( + defaults: Record, + options?: { resetOnSuccess?: boolean }, +): Promise { + const router = { + visit: async () => ({}), + } as unknown as VoidRouter; + let form!: TestForm; + const app = createSSRApp( + defineComponent({ + setup() { + form = useForm('/notes?create', defaults, options); + return () => h('form'); + }, + }), + ); + app.provide('__void_router', router); + await renderToString(app); + return form; +} + +describe('useForm reset', () => { + it('reset() restores submitted values after success', async () => { + const form = await mountForm({ title: '', body: '' }); + form.data.title = 'Hello'; + await form.post(); + form.data.title = 'Changed'; + form.reset(); + expect(form.data.title).toBe('Hello'); + expect(form.hasChanges).toBe(false); + }); + + it('reset({ initial: true }) restores mount-time defaults after success', async () => { + const form = await mountForm({ title: '', body: '' }); + form.data.title = 'Hello'; + await form.post(); + form.reset({ initial: true }); + expect(form.data.title).toBe(''); + }); + + it('resetOnSuccess clears create forms after a successful submit', async () => { + const form = await mountForm({ title: '', body: '' }, { resetOnSuccess: true }); + form.data.title = 'Hello'; + await form.post(); + expect(form.data.title).toBe(''); + expect(form.hasChanges).toBe(false); + }); +}); + describe('useForm params', () => { it('fills in params and keeps the action query', async () => { const { form, visits } = await renderForm('/users/:name/follow?action=follow', {