Skip to content

Commit 98aae1c

Browse files
feat: allow to run preflight validation only (#14744)
* feat: allow to run preflight validation only That way you can e.g. run preflight on each keystroke and full validation only on blur * clientOnly -> preflightOnly * Preserve server issues when doing preflight-only validation (#14759) * preserve server issues on preflight validation, unless there are newer preflight-only issues * sort correctly * no longer necessary * lint * Update packages/kit/src/runtime/client/remote-functions/form.svelte.js Co-authored-by: Simon H <[email protected]> --------- Co-authored-by: Simon H <[email protected]> --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent fc6017c commit 98aae1c

File tree

9 files changed

+177
-76
lines changed

9 files changed

+177
-76
lines changed

.changeset/polite-planes-turn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
chore: allow to run preflight validation only

packages/kit/src/exports/public.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,7 +2021,10 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
20212021
preflight(schema: StandardSchemaV1<Input, any>): RemoteForm<Input, Output>;
20222022
/** Validate the form contents programmatically */
20232023
validate(options?: {
2024+
/** Set this to `true` to also show validation issues of fields that haven't been touched yet. */
20242025
includeUntouched?: boolean;
2026+
/** Set this to `true` to only run the `preflight` validation. */
2027+
preflightOnly?: boolean;
20252028
/** Perform validation as if the form was submitted by the given button. */
20262029
submitter?: HTMLButtonElement | HTMLInputElement;
20272030
}): Promise<void>;

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { get_request_store } from '@sveltejs/kit/internal/server';
55
import { DEV } from 'esm-env';
66
import {
77
convert_formdata,
8-
flatten_issues,
98
create_field_proxy,
109
set_nested_value,
1110
throw_on_old_property_access,
12-
deep_set
11+
deep_set,
12+
normalize_issue,
13+
flatten_issues
1314
} from '../../../form-utils.svelte.js';
1415
import { get_cache, run_remote_function } from './shared.js';
1516

@@ -142,7 +143,7 @@ export function form(validate_or_fn, maybe_fn) {
142143
}
143144
}
144145

145-
/** @type {{ submission: true, input?: Record<string, any>, issues?: Record<string, InternalRemoteFormIssue[]>, result: Output }} */
146+
/** @type {{ submission: true, input?: Record<string, any>, issues?: InternalRemoteFormIssue[], result: Output }} */
146147
const output = {};
147148

148149
// make it possible to differentiate between user submission and programmatic `field.set(...)` updates
@@ -209,6 +210,8 @@ export function form(validate_or_fn, maybe_fn) {
209210
Object.defineProperty(instance, 'fields', {
210211
get() {
211212
const data = get_cache(__)?.[''];
213+
const issues = flatten_issues(data?.issues ?? []);
214+
212215
return create_field_proxy(
213216
{},
214217
() => data?.input ?? {},
@@ -224,7 +227,7 @@ export function form(validate_or_fn, maybe_fn) {
224227

225228
(get_cache(__)[''] ??= {}).input = input;
226229
},
227-
() => data?.issues ?? {}
230+
() => issues
228231
);
229232
}
230233
});
@@ -293,13 +296,13 @@ export function form(validate_or_fn, maybe_fn) {
293296
}
294297

295298
/**
296-
* @param {{ issues?: Record<string, any>, input?: Record<string, any>, result: any }} output
299+
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
297300
* @param {readonly StandardSchemaV1.Issue[]} issues
298301
* @param {boolean} is_remote_request
299302
* @param {FormData} form_data
300303
*/
301304
function handle_issues(output, issues, is_remote_request, form_data) {
302-
output.issues = flatten_issues(issues);
305+
output.issues = issues.map((issue) => normalize_issue(issue, true));
303306

304307
// if it was a progressively-enhanced submission, we don't need
305308
// to return the input — it's already there

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 38 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,29 @@ import {
1818
set_nested_value,
1919
throw_on_old_property_access,
2020
split_path,
21-
build_path_string
21+
build_path_string,
22+
normalize_issue
2223
} from '../../form-utils.svelte.js';
2324

2425
/**
25-
* Merge client issues into server issues
26-
* @param {Record<string, InternalRemoteFormIssue[]>} current_issues
27-
* @param {Record<string, InternalRemoteFormIssue[]>} client_issues
28-
* @returns {Record<string, InternalRemoteFormIssue[]>}
26+
* Merge client issues into server issues. Server issues are persisted unless
27+
* a client-issue exists for the same path, in which case the client-issue overrides it.
28+
* @param {FormData} form_data
29+
* @param {InternalRemoteFormIssue[]} current_issues
30+
* @param {InternalRemoteFormIssue[]} client_issues
31+
* @returns {InternalRemoteFormIssue[]}
2932
*/
30-
function merge_with_server_issues(current_issues, client_issues) {
31-
const merged_issues = Object.fromEntries(
32-
Object.entries(current_issues)
33-
.map(([key, issue_list]) => [key, issue_list.filter((issue) => issue.server)])
34-
.filter(([, issue_list]) => issue_list.length > 0)
35-
);
36-
37-
for (const [key, new_issue_list] of Object.entries(client_issues)) {
38-
merged_issues[key] = [...(merged_issues[key] || []), ...new_issue_list];
39-
}
33+
function merge_with_server_issues(form_data, current_issues, client_issues) {
34+
const merged = [
35+
...current_issues.filter(
36+
(issue) => issue.server && !client_issues.some((i) => i.name === issue.name)
37+
),
38+
...client_issues
39+
];
4040

41-
return merged_issues;
41+
const keys = Array.from(form_data.keys());
42+
43+
return merged.sort((a, b) => keys.indexOf(a.name) - keys.indexOf(b.name));
4244
}
4345

4446
/**
@@ -77,8 +79,10 @@ export function form(id) {
7779
*/
7880
const version_reads = new Set();
7981

80-
/** @type {Record<string, InternalRemoteFormIssue[]>} */
81-
let issues = $state.raw({});
82+
/** @type {InternalRemoteFormIssue[]} */
83+
let raw_issues = $state.raw([]);
84+
85+
const issues = $derived(flatten_issues(raw_issues));
8286

8387
/** @type {any} */
8488
let result = $state.raw(remote_responses[action_id]);
@@ -132,8 +136,11 @@ export function form(id) {
132136
const validated = await preflight_schema?.['~standard'].validate(data);
133137

134138
if (validated?.issues) {
135-
const client_issues = flatten_issues(validated.issues, false);
136-
issues = merge_with_server_issues(issues, client_issues);
139+
raw_issues = merge_with_server_issues(
140+
form_data,
141+
raw_issues,
142+
validated.issues.map((issue) => normalize_issue(issue, false))
143+
);
137144
return;
138145
}
139146

@@ -223,14 +230,7 @@ export function form(id) {
223230
const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
224231

225232
if (form_result.type === 'result') {
226-
({ issues = {}, result } = devalue.parse(form_result.result, app.decoders));
227-
228-
// Mark server issues with server: true
229-
for (const issue_list of Object.values(issues)) {
230-
for (const issue of issue_list) {
231-
issue.server = true;
232-
}
233-
}
233+
({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders));
234234

235235
if (issues.$) {
236236
release_overrides(updates);
@@ -572,7 +572,7 @@ export function form(id) {
572572
},
573573
validate: {
574574
/** @type {RemoteForm<any, any>['validate']} */
575-
value: async ({ includeUntouched = false, submitter } = {}) => {
575+
value: async ({ includeUntouched = false, preflightOnly = false, submitter } = {}) => {
576576
if (!element) return;
577577

578578
const id = ++validate_id;
@@ -582,7 +582,7 @@ export function form(id) {
582582

583583
const form_data = new FormData(element, submitter);
584584

585-
/** @type {readonly StandardSchemaV1.Issue[]} */
585+
/** @type {InternalRemoteFormIssue[]} */
586586
let array = [];
587587

588588
const validated = await preflight_schema?.['~standard'].validate(convert(form_data));
@@ -592,8 +592,8 @@ export function form(id) {
592592
}
593593

594594
if (validated?.issues) {
595-
array = validated.issues;
596-
} else {
595+
array = validated.issues.map((issue) => normalize_issue(issue, false));
596+
} else if (!preflightOnly) {
597597
form_data.set('sveltekit:validate_only', 'true');
598598

599599
const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
@@ -608,36 +608,21 @@ export function form(id) {
608608
}
609609

610610
if (result.type === 'result') {
611-
array = /** @type {StandardSchemaV1.Issue[]} */ (
611+
array = /** @type {InternalRemoteFormIssue[]} */ (
612612
devalue.parse(result.result, app.decoders)
613613
);
614614
}
615615
}
616616

617617
if (!includeUntouched && !submitted) {
618-
array = array.filter((issue) => {
619-
if (issue.path !== undefined) {
620-
let path = '';
621-
622-
for (const segment of issue.path) {
623-
const key = typeof segment === 'object' ? segment.key : segment;
624-
625-
if (typeof key === 'number') {
626-
path += `[${key}]`;
627-
} else if (typeof key === 'string') {
628-
path += path === '' ? key : '.' + key;
629-
}
630-
}
631-
632-
return touched[path];
633-
}
634-
});
618+
array = array.filter((issue) => touched[issue.name]);
635619
}
636620

637-
const is_server_validation = !validated?.issues;
638-
const new_issues = flatten_issues(array, is_server_validation);
621+
const is_server_validation = !validated?.issues && !preflightOnly;
639622

640-
issues = is_server_validation ? new_issues : merge_with_server_issues(issues, new_issues);
623+
raw_issues = is_server_validation
624+
? array
625+
: merge_with_server_issues(form_data, raw_issues, array);
641626
}
642627
},
643628
enhance: {

packages/kit/src/runtime/form-utils.svelte.js

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -116,39 +116,58 @@ export function deep_set(object, keys, value) {
116116
}
117117

118118
/**
119-
* @param {readonly StandardSchemaV1.Issue[]} issues
120-
* @param {boolean} [server=false] - Whether these issues come from server validation
119+
* @param {StandardSchemaV1.Issue} issue
120+
* @param {boolean} server Whether this issue came from server validation
121121
*/
122-
export function flatten_issues(issues, server = false) {
122+
export function normalize_issue(issue, server = false) {
123+
/** @type {InternalRemoteFormIssue} */
124+
const normalized = { name: '', path: [], message: issue.message, server };
125+
126+
if (issue.path !== undefined) {
127+
let name = '';
128+
129+
for (const segment of issue.path) {
130+
const key = /** @type {string | number} */ (
131+
typeof segment === 'object' ? segment.key : segment
132+
);
133+
134+
normalized.path.push(key);
135+
136+
if (typeof key === 'number') {
137+
name += `[${key}]`;
138+
} else if (typeof key === 'string') {
139+
name += name === '' ? key : '.' + key;
140+
}
141+
}
142+
143+
normalized.name = name;
144+
}
145+
146+
return normalized;
147+
}
148+
149+
/**
150+
* @param {InternalRemoteFormIssue[]} issues
151+
*/
152+
export function flatten_issues(issues) {
123153
/** @type {Record<string, InternalRemoteFormIssue[]>} */
124154
const result = {};
125155

126156
for (const issue of issues) {
127-
/** @type {InternalRemoteFormIssue} */
128-
const normalized = { name: '', path: [], message: issue.message, server };
129-
130-
(result.$ ??= []).push(normalized);
157+
(result.$ ??= []).push(issue);
131158

132159
let name = '';
133160

134161
if (issue.path !== undefined) {
135-
for (const segment of issue.path) {
136-
const key = /** @type {string | number} */ (
137-
typeof segment === 'object' ? segment.key : segment
138-
);
139-
140-
normalized.path.push(key);
141-
162+
for (const key of issue.path) {
142163
if (typeof key === 'number') {
143164
name += `[${key}]`;
144165
} else if (typeof key === 'string') {
145166
name += name === '' ? key : '.' + key;
146167
}
147168

148-
(result[name] ??= []).push(normalized);
169+
(result[name] ??= []).push(issue);
149170
}
150-
151-
normalized.name = name;
152171
}
153172
}
154173

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script>
2+
import { get, set } from './form.remote.js';
3+
import * as v from 'valibot';
4+
5+
const data = get();
6+
7+
const schema = v.object({
8+
a: v.pipe(v.string(), v.maxLength(7, 'a is too long')),
9+
b: v.string(),
10+
c: v.string()
11+
});
12+
</script>
13+
14+
<!-- TODO use await here once async lands -->
15+
{#await data then { a, b, c }}
16+
<p>a: {a}</p>
17+
<p>b: {b}</p>
18+
<p>c: {c}</p>
19+
{/await}
20+
21+
<hr />
22+
23+
<form
24+
{...set.preflight(schema)}
25+
oninput={() => set.validate({ preflightOnly: true })}
26+
onchange={() => set.validate()}
27+
>
28+
<input {...set.fields.a.as('text')} />
29+
<input {...set.fields.b.as('text')} />
30+
<input {...set.fields.c.as('text')} />
31+
32+
<button>submit</button>
33+
</form>
34+
35+
<div class="issues">
36+
{#each set.fields.allIssues() as issue}
37+
<p>{issue.message}</p>
38+
{/each}
39+
</div>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { form, query } from '$app/server';
2+
import * as v from 'valibot';
3+
4+
let data = { a: '', b: '', c: '' };
5+
6+
export const get = query(() => {
7+
return data;
8+
});
9+
10+
export const set = form(
11+
v.object({
12+
a: v.pipe(v.string(), v.minLength(3, 'a is too short')),
13+
b: v.pipe(v.string(), v.minLength(3, 'b is too short')),
14+
c: v.pipe(v.string(), v.minLength(3, 'c is too short'))
15+
}),
16+
async (d) => {
17+
data = d;
18+
}
19+
);

0 commit comments

Comments
 (0)