diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts index e832455ea..4b74ac90c 100644 --- a/packages/svelte/src/runtime/use-form.svelte.ts +++ b/packages/svelte/src/runtime/use-form.svelte.ts @@ -95,6 +95,26 @@ export function useForm( }); }); + // An input handler may validate its new value before the change effect runs. + function snapshotField(field: string) { + untrack(() => { + dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) }; + }); + } + + // Handlers may also write `form.errors.field` directly, which needs the same snapshot. + // The view is derived because setError and clearErrors replace `errors`. + const errorsView = $derived( + new Proxy(errors, { + set(target, field, message) { + if (typeof field === 'string') { + snapshotField(field); + } + return Reflect.set(target, field, message); + }, + }), + ); + const hasChanges = $derived( Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])), ); @@ -123,8 +143,7 @@ export function useForm( } function setError(field: string, message: string) { - // An input handler may validate its new value before the change effect runs. - dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) }; + snapshotField(field); errors = { ...errors, [field]: message }; } @@ -189,7 +208,7 @@ export function useForm( return data; }, get errors() { - return errors; + return errorsView; }, get error() { return error; diff --git a/packages/svelte/test/unit/use-form.test.ts b/packages/svelte/test/unit/use-form.test.ts new file mode 100644 index 000000000..1b565611b --- /dev/null +++ b/packages/svelte/test/unit/use-form.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +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 type { VoidRouter } from 'void/pages-client'; + +type TestForm = { + readonly data: Record; + readonly errors: Record; + setError(field: string, message: string): void; +}; + +// The unit project has no Svelte plugin and resolves `svelte` to its server entry, so compile +// the rune module and a host component here and point them at Svelte's browser entries. +const TMP = join(import.meta.dirname, '.tmp-use-form'); +const require = createRequire(import.meta.url); +const svelteDir = dirname(require.resolve('svelte/package.json')); +const svelteExports: Record = + require('svelte/package.json').exports; + +function svelteBrowserUrl(specifier: string): string { + const entry = svelteExports['.' + specifier.slice('svelte'.length)]; + const file = typeof entry === 'string' ? entry : (entry.browser ?? entry.default); + return pathToFileURL(join(svelteDir, file)).href; +} + +function writeCompiled(file: string, code: string): void { + writeFileSync( + join(TMP, file), + code.replace( + /(from |import )['"](svelte(?:\/[^'"]*)?)['"]/g, + (_, keyword, specifier) => `${keyword}'${svelteBrowserUrl(specifier)}'`, + ), + ); +} + +let svelte: typeof import('svelte'); +let Host: Component<{ defaults: Record; onform: (form: TestForm) => void }>; +const mounted: Array> = []; + +beforeAll(async () => { + svelte = await import(/* @vite-ignore */ svelteBrowserUrl('svelte')); + const source = join(import.meta.dirname, '../../src/runtime/use-form.svelte.ts'); + const { code } = await transformWithOxc(readFileSync(source, 'utf8'), source); + mkdirSync(TMP, { recursive: true }); + writeCompiled( + 'use-form.svelte.js', + compileModule(code, { filename: source, generate: 'client' }).js.code, + ); + const host = ``; + writeCompiled('Host.js', compile(host, { filename: 'Host.svelte', generate: 'client' }).js.code); + Host = (await import(pathToFileURL(join(TMP, 'Host.js')).href)).default; +}); + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +afterEach(() => { + for (const component of mounted.splice(0)) { + void svelte.unmount(component); + } +}); + +function mountForm(defaults: Record): TestForm { + const router = { visit: async () => ({}) } as unknown as VoidRouter; + let form!: TestForm; + mounted.push( + svelte.mount(Host, { + target: document.createElement('div'), + props: { defaults, onform: (value) => (form = value) }, + context: new Map([['__void_router', router]]), + }), + ); + svelte.flushSync(); + return form; +} + +describe('useForm field errors', () => { + it('keeps an error written directly in the handler that edits the field', () => { + const form = mountForm({ email: '', name: '' }); + + form.data.email = 'i'; + form.errors.email = 'Client email invalid'; + svelte.flushSync(); + expect({ ...form.errors }).toEqual({ email: 'Client email invalid' }); + + // The next keystroke writes the same message again. + form.data.email = 'in'; + form.errors.email = 'Client email invalid'; + svelte.flushSync(); + expect({ ...form.errors }).toEqual({ email: 'Client email invalid' }); + }); + + it('keeps an error set with setError in the handler that edits the field', () => { + const form = mountForm({ email: '' }); + + form.data.email = 'invalid'; + form.setError('email', 'Client email invalid'); + svelte.flushSync(); + expect({ ...form.errors }).toEqual({ email: 'Client email invalid' }); + }); + + it('clears only the stale error of an edited field', () => { + const form = mountForm({ email: '', name: '', profile: { name: '' } }); + form.errors.email = 'Email invalid'; + form.errors.name = 'Name invalid'; + form.errors.profile = 'Profile invalid'; + svelte.flushSync(); + + form.data.email = 'alice@example.com'; + (form.data.profile as { name: string }).name = 'Alice'; + svelte.flushSync(); + expect({ ...form.errors }).toEqual({ name: 'Name invalid' }); + }); + + it('clears an error written before the field edit in the same handler', () => { + const form = mountForm({ email: '' }); + + form.errors.email = 'Email invalid'; + form.data.email = 'alice@example.com'; + svelte.flushSync(); + expect({ ...form.errors }).toEqual({}); + }); +}); diff --git a/playground/svelte-pages/pages/form-errors.svelte b/playground/svelte-pages/pages/form-errors.svelte index f43766466..2d5dd60ca 100644 --- a/playground/svelte-pages/pages/form-errors.svelte +++ b/playground/svelte-pages/pages/form-errors.svelte @@ -16,7 +16,7 @@ function editEmail(event: Event) { form.data.email = (event.target as HTMLInputElement).value; if (!form.data.email.includes('@')) { - form.setError('email', 'Client email invalid'); + form.errors.email = 'Client email invalid'; } }