Skip to content

Commit e019196

Browse files
committed
Fix form error handling across adapters.
1 parent b018a22 commit e019196

19 files changed

Lines changed: 555 additions & 42 deletions

File tree

‎docs/guide/pages-routing/actions-and-forms.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ The types are inferred from your action's `withValidator()` schema in the compan
163163
| `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. |
164164
| `clearError()` | Clear `error`. |
165165

166-
Changing a field clears that field's validation error automatically (`setData` in React and Solid, or assigning to `data` in Vue and Svelte).
166+
Use `setError()` to display errors from your own client-side validation. Setting an error preserves errors on other fields. Changing a field clears that field's validation error automatically (`setData` in React and Solid, or assigning to `data` in Vue and Svelte).
167167

168168
For a native React form, use `form.post`, `form.put`, `form.patch`, or `form.delete` as a native form action. For an awaitable mutation, use `action()`. In Vue, Svelte, and Solid, form submissions return `Promise<void>`; catch thrown errors or use your framework's error handling.
169169

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useForm } from '../../src/runtime/use-form.ts';
2+
3+
declare module 'void/routes' {
4+
interface PageActionMap {
5+
'/__form-errors/react': { body: { email: string } };
6+
}
7+
}
8+
9+
export function checkFieldErrors(): void {
10+
const form = useForm('/__form-errors/react', { email: '' });
11+
form.setError('email', 'Invalid email');
12+
// @ts-expect-error Error fields must exist in the action body.
13+
form.setError('missing', 'Invalid field');
14+
// @ts-expect-error Error messages must be strings.
15+
form.setError('email', 123);
16+
}

‎packages/solid/src/runtime/use-form.ts‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,7 @@ export function useForm(
8484
const [data, setDataStore] = createStore<Record<string, unknown>>(
8585
cloneFormValue(initialDefaults),
8686
);
87-
const [localErrors, setLocalErrors] = createSignal<Record<string, string>>({});
88-
const errors = createMemo(() => {
89-
const local = localErrors();
90-
if (Object.keys(local).length > 0) {
91-
return local;
92-
}
87+
const serverErrors = createMemo(() => {
9388
// globalErrors is a signal accessor (function) on the client, or a plain
9489
// object on the server. Handle both cases.
9590
if (!globalErrors) {
@@ -100,6 +95,20 @@ export function useForm(
10095
}
10196
return globalErrors;
10297
});
98+
const [localErrors, setLocalErrors] = createSignal<{
99+
source: Record<string, string>;
100+
values: Record<string, string>;
101+
} | null>(null);
102+
const errors = createMemo(() => {
103+
const server = serverErrors();
104+
const local = localErrors();
105+
// A new router result replaces earlier edits, including explicit clears.
106+
return local?.source === server ? local.values : server;
107+
});
108+
109+
function replaceErrors(values: Record<string, string>) {
110+
setLocalErrors({ source: serverErrors(), values });
111+
}
103112
const [pending, setPending] = createSignal(false);
104113
const [error, setActionError] = createSignal<VoidActionError | null>(null);
105114
const [wasSuccessful, setWasSuccessful] = createSignal(false);
@@ -115,14 +124,7 @@ export function useForm(
115124

116125
function setData(field: string, value: unknown) {
117126
setDataStore(field, value);
118-
setLocalErrors((prev) => {
119-
if (!(field in prev)) {
120-
return prev;
121-
}
122-
const next = { ...prev };
123-
delete next[field];
124-
return next;
125-
});
127+
clearErrors(field);
126128
}
127129

128130
function reset(...args: ReadonlyArray<string | FormResetOptions>) {
@@ -139,20 +141,18 @@ export function useForm(
139141

140142
function clearErrors(...fields: ReadonlyArray<string>) {
141143
if (fields.length === 0) {
142-
setLocalErrors({});
144+
replaceErrors({});
143145
} else {
144-
setLocalErrors((prev) => {
145-
const next = { ...prev };
146-
for (const field of fields) {
147-
delete next[field];
148-
}
149-
return next;
150-
});
146+
const next = { ...errors() };
147+
for (const field of fields) {
148+
delete next[field];
149+
}
150+
replaceErrors(next);
151151
}
152152
}
153153

154154
function setFieldError(field: string, message: string) {
155-
setLocalErrors((prev) => ({ ...prev, [field]: message }));
155+
replaceErrors({ ...errors(), [field]: message });
156156
}
157157

158158
function clearError() {
@@ -166,7 +166,7 @@ export function useForm(
166166
if (successTimeout) {
167167
clearTimeout(successTimeout);
168168
}
169-
setLocalErrors({});
169+
clearErrors();
170170
clearError();
171171

172172
try {
@@ -185,7 +185,7 @@ export function useForm(
185185
if (!result.ok) {
186186
const validationErrors = getValidationErrors(result.error.body);
187187
if (validationErrors) {
188-
setLocalErrors(validationErrors);
188+
replaceErrors(validationErrors);
189189
} else {
190190
setActionError(result.error);
191191
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createComponent, createRoot, createSignal } from 'solid-js';
2+
import { describe, expect, it, vi } from 'vite-plus/test';
3+
import { ErrorsContext } from '../../src/runtime/context.ts';
4+
import { useForm as typedUseForm } from '../../src/runtime/use-form.ts';
5+
6+
// Client memos must react to edits; Solid's server runtime evaluates them only once.
7+
vi.mock('solid-js', () => vi.importActual<typeof import('solid-js')>('solid-js/dist/solid.js'));
8+
vi.mock('solid-js/store', () =>
9+
vi.importActual<typeof import('solid-js/store')>('solid-js/store/dist/store.js'),
10+
);
11+
12+
type TestForm = {
13+
errors: Record<string, string>;
14+
setData(field: string, value: unknown): void;
15+
setError(field: string, message: string): void;
16+
clearErrors(...fields: Array<string>): void;
17+
};
18+
const useForm = typedUseForm as unknown as (
19+
url: string,
20+
defaults: Record<string, unknown>,
21+
) => TestForm;
22+
23+
function createForm() {
24+
const [serverErrors, setServerErrors] = createSignal({
25+
email: 'Invalid email',
26+
name: 'Required',
27+
});
28+
let form!: TestForm;
29+
const dispose = createRoot((dispose) => {
30+
createComponent(ErrorsContext.Provider, {
31+
value: serverErrors,
32+
get children() {
33+
form = useForm('/test', { email: '', name: '' });
34+
return null;
35+
},
36+
});
37+
return dispose;
38+
});
39+
return { form, setServerErrors, dispose };
40+
}
41+
42+
describe('useForm router errors', () => {
43+
it('keeps unrelated server errors when adding client errors', () => {
44+
const { form, dispose } = createForm();
45+
try {
46+
form.setError('name', 'Client error');
47+
expect(form.errors).toEqual({ email: 'Invalid email', name: 'Client error' });
48+
} finally {
49+
dispose();
50+
}
51+
});
52+
53+
it('clears server errors without reviving them after local edits', () => {
54+
const { form, dispose } = createForm();
55+
try {
56+
form.setData('email', 'valid@example.com');
57+
expect(form.errors).toEqual({ name: 'Required' });
58+
form.setError('email', 'Client error');
59+
form.clearErrors('name');
60+
form.setData('email', 'other@example.com');
61+
expect(form.errors).toEqual({});
62+
form.clearErrors();
63+
expect(form.errors).toEqual({});
64+
} finally {
65+
dispose();
66+
}
67+
});
68+
69+
it('shows new server results after earlier errors were cleared', () => {
70+
const { form, setServerErrors, dispose } = createForm();
71+
try {
72+
form.clearErrors();
73+
expect(form.errors).toEqual({});
74+
setServerErrors({ email: 'New email error', name: 'New name error' });
75+
expect(form.errors).toEqual({ email: 'New email error', name: 'New name error' });
76+
} finally {
77+
dispose();
78+
}
79+
});
80+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useForm } from '../../src/runtime/use-form.ts';
2+
3+
declare module 'void/routes' {
4+
interface PageActionMap {
5+
'/__form-errors/solid': { body: { email: string } };
6+
}
7+
}
8+
9+
export function checkFieldErrors(): void {
10+
const form = useForm('/__form-errors/solid', { email: '' });
11+
form.setError('email', 'Invalid email');
12+
// @ts-expect-error Error fields must exist in the action body.
13+
form.setError('missing', 'Invalid field');
14+
// @ts-expect-error Error messages must be strings.
15+
form.setError('email', 123);
16+
}

‎packages/svelte/src/runtime/use-form.svelte.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getContext } from 'svelte';
1+
import { getContext, untrack } from 'svelte';
22
import {
33
VoidActionError,
44
cloneFormValue,
@@ -80,15 +80,19 @@ export function useForm(
8080

8181
let defaults_ = $state.raw(initialDefaults);
8282
let successTimeout: ReturnType<typeof setTimeout> | null = null;
83-
let dataSnapshot = { ...defaults };
83+
let dataSnapshot = cloneFormValue(initialDefaults);
8484

8585
$effect(() => {
86-
for (const key of Object.keys(data)) {
87-
if (!isEqualFormValue(data[key], dataSnapshot[key])) {
88-
delete errors[key];
86+
// Read nested values even after an earlier edit, without subscribing to errors.
87+
const current = cloneFormValue(data);
88+
untrack(() => {
89+
for (const key of new Set([...Object.keys(dataSnapshot), ...Object.keys(current)])) {
90+
if (!isEqualFormValue(current[key], dataSnapshot[key])) {
91+
delete errors[key];
92+
}
8993
}
90-
}
91-
dataSnapshot = { ...data };
94+
dataSnapshot = current;
95+
});
9296
});
9397

9498
const hasChanges = $derived(
@@ -105,7 +109,7 @@ export function useForm(
105109
data[field] = cloneFormValue(source[field]);
106110
}
107111
}
108-
dataSnapshot = { ...data };
112+
dataSnapshot = cloneFormValue(data);
109113
}
110114

111115
function clearErrors(...fields: ReadonlyArray<string>) {
@@ -119,6 +123,8 @@ export function useForm(
119123
}
120124

121125
function setError(field: string, message: string) {
126+
// An input handler may validate its new value before the change effect runs.
127+
dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
122128
errors = { ...errors, [field]: message };
123129
}
124130

@@ -148,7 +154,9 @@ export function useForm(
148154
if (!result.ok) {
149155
const validationErrors = getValidationErrors(result.error.body);
150156
if (validationErrors) {
151-
Object.assign(errors, validationErrors);
157+
for (const [field, message] of Object.entries(validationErrors)) {
158+
setError(field, message);
159+
}
152160
} else {
153161
error = result.error;
154162
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useForm } from '../../src/runtime/use-form.svelte.ts';
2+
3+
declare module 'void/routes' {
4+
interface PageActionMap {
5+
'/__form-errors/svelte': { body: { email: string } };
6+
}
7+
}
8+
9+
export function checkFieldErrors(): void {
10+
const form = useForm('/__form-errors/svelte', { email: '' });
11+
form.setError('email', 'Invalid email');
12+
// @ts-expect-error Error fields must exist in the action body.
13+
form.setError('missing', 'Invalid field');
14+
// @ts-expect-error Error messages must be strings.
15+
form.setError('email', 123);
16+
}

‎packages/vue/src/runtime/use-form.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,18 @@ export function useForm(
7979
const defaults_ = shallowRef(initialDefaults);
8080
let successTimeout: ReturnType<typeof setTimeout> | null = null;
8181

82-
let dataSnapshot = { ...initialDefaults };
82+
let dataSnapshot = cloneFormValue(initialDefaults);
8383
watch(
84-
data,
84+
() => cloneFormValue(data),
8585
(val) => {
86-
for (const key of Object.keys(val)) {
86+
for (const key of new Set([...Object.keys(dataSnapshot), ...Object.keys(val)])) {
8787
if (!isEqualFormValue(val[key], dataSnapshot[key])) {
8888
delete errors[key];
8989
}
9090
}
91-
dataSnapshot = { ...val };
91+
dataSnapshot = val;
9292
},
93-
{ deep: true, flush: 'sync' },
93+
{ flush: 'sync' },
9494
);
9595

9696
const hasChanges = computed(() =>
@@ -107,7 +107,7 @@ export function useForm(
107107
data[field] = cloneFormValue(source[field]);
108108
}
109109
}
110-
dataSnapshot = { ...data };
110+
dataSnapshot = cloneFormValue(data);
111111
}
112112

113113
function clearErrors(...fields: ReadonlyArray<string>) {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useForm } from '../../src/runtime/use-form.ts';
2+
3+
declare module 'void/routes' {
4+
interface PageActionMap {
5+
'/__form-errors/vue': { body: { email: string } };
6+
}
7+
}
8+
9+
export function checkFieldErrors(): void {
10+
const form = useForm('/__form-errors/vue', { email: '' });
11+
form.setError('email', 'Invalid email');
12+
// @ts-expect-error Error fields must exist in the action body.
13+
form.setError('missing', 'Invalid field');
14+
// @ts-expect-error Error messages must be strings.
15+
form.setError('email', 123);
16+
}

‎packages/vue/test/unit/use-form.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,28 @@ describe('useForm reset', () => {
119119
});
120120

121121
describe('useForm setError', () => {
122+
it('clears errors after repeated nested object and array edits', async () => {
123+
const app = createSSRApp(
124+
defineComponent({
125+
setup() {
126+
const form = useForm('/test', { profile: { name: '' }, tags: [] });
127+
for (let edit = 0; edit < 3; edit++) {
128+
form.setError('profile', 'Invalid profile');
129+
form.setError('tags', 'Invalid tags');
130+
(form.data.profile as { name: string }).name = 'Name ' + edit;
131+
expect(form.errors.profile).toBeUndefined();
132+
expect(form.errors.tags).toBe('Invalid tags');
133+
(form.data.tags as Array<string>).push('tag');
134+
expect(form.errors).toEqual({});
135+
}
136+
return () => h('form');
137+
},
138+
}),
139+
);
140+
app.provide('__void_router', { visit: async () => ({}) });
141+
await renderToString(app);
142+
});
143+
122144
it('sets a field error and clears it when data changes', async () => {
123145
const router = {
124146
visit: async () => ({}),

0 commit comments

Comments
 (0)