Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-planes-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

chore: allow to run preflight validation only
3 changes: 3 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2021,7 +2021,10 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
preflight(schema: StandardSchemaV1<Input, any>): RemoteForm<Input, Output>;
/** Validate the form contents programmatically */
validate(options?: {
/** Set this to `true` to also show validation issues of fields that haven't been touched yet. */
includeUntouched?: boolean;
/** Set this to `true` to only run the `preflight` validation. */
preflightOnly?: boolean;
/** Perform validation as if the form was submitted by the given button. */
submitter?: HTMLButtonElement | HTMLInputElement;
}): Promise<void>;
Expand Down
15 changes: 9 additions & 6 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { get_request_store } from '@sveltejs/kit/internal/server';
import { DEV } from 'esm-env';
import {
convert_formdata,
flatten_issues,
create_field_proxy,
set_nested_value,
throw_on_old_property_access,
deep_set
deep_set,
normalize_issue,
flatten_issues
} from '../../../form-utils.svelte.js';
import { get_cache, run_remote_function } from './shared.js';

Expand Down Expand Up @@ -142,7 +143,7 @@ export function form(validate_or_fn, maybe_fn) {
}
}

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

// make it possible to differentiate between user submission and programmatic `field.set(...)` updates
Expand Down Expand Up @@ -209,6 +210,8 @@ export function form(validate_or_fn, maybe_fn) {
Object.defineProperty(instance, 'fields', {
get() {
const data = get_cache(__)?.[''];
const issues = flatten_issues(data?.issues ?? []);

return create_field_proxy(
{},
() => data?.input ?? {},
Expand All @@ -224,7 +227,7 @@ export function form(validate_or_fn, maybe_fn) {

(get_cache(__)[''] ??= {}).input = input;
},
() => data?.issues ?? {}
() => issues
);
}
});
Expand Down Expand Up @@ -293,13 +296,13 @@ export function form(validate_or_fn, maybe_fn) {
}

/**
* @param {{ issues?: Record<string, any>, input?: Record<string, any>, result: any }} output
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
* @param {readonly StandardSchemaV1.Issue[]} issues
* @param {boolean} is_remote_request
* @param {FormData} form_data
*/
function handle_issues(output, issues, is_remote_request, form_data) {
output.issues = flatten_issues(issues);
output.issues = issues.map((issue) => normalize_issue(issue, true));

// if it was a progressively-enhanced submission, we don't need
// to return the input — it's already there
Expand Down
91 changes: 38 additions & 53 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,29 @@ import {
set_nested_value,
throw_on_old_property_access,
split_path,
build_path_string
build_path_string,
normalize_issue
} from '../../form-utils.svelte.js';

/**
* Merge client issues into server issues
* @param {Record<string, InternalRemoteFormIssue[]>} current_issues
* @param {Record<string, InternalRemoteFormIssue[]>} client_issues
* @returns {Record<string, InternalRemoteFormIssue[]>}
* Merge client issues into server issues. Server issues are persisted unless
* a client-issue exists for the same path, in which case the client-issue overrides it.
* @param {FormData} form_data
* @param {InternalRemoteFormIssue[]} current_issues
* @param {InternalRemoteFormIssue[]} client_issues
* @returns {InternalRemoteFormIssue[]}
*/
function merge_with_server_issues(current_issues, client_issues) {
const merged_issues = Object.fromEntries(
Object.entries(current_issues)
.map(([key, issue_list]) => [key, issue_list.filter((issue) => issue.server)])
.filter(([, issue_list]) => issue_list.length > 0)
);

for (const [key, new_issue_list] of Object.entries(client_issues)) {
merged_issues[key] = [...(merged_issues[key] || []), ...new_issue_list];
}
function merge_with_server_issues(form_data, current_issues, client_issues) {
const merged = [
...current_issues.filter(
(issue) => issue.server && !client_issues.some((i) => i.name === issue.name)
),
...client_issues
];

return merged_issues;
const keys = Array.from(form_data.keys());

return merged.sort((a, b) => keys.indexOf(a.name) - keys.indexOf(b.name));
}

/**
Expand Down Expand Up @@ -77,8 +79,10 @@ export function form(id) {
*/
const version_reads = new Set();

/** @type {Record<string, InternalRemoteFormIssue[]>} */
let issues = $state.raw({});
/** @type {InternalRemoteFormIssue[]} */
let raw_issues = $state.raw([]);

const issues = $derived(flatten_issues(raw_issues));

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

if (validated?.issues) {
const client_issues = flatten_issues(validated.issues, false);
issues = merge_with_server_issues(issues, client_issues);
raw_issues = merge_with_server_issues(
form_data,
raw_issues,
validated.issues.map((issue) => normalize_issue(issue, false))
);
return;
}

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

if (form_result.type === 'result') {
({ issues = {}, result } = devalue.parse(form_result.result, app.decoders));

// Mark server issues with server: true
for (const issue_list of Object.values(issues)) {
for (const issue of issue_list) {
issue.server = true;
}
}
({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders));

if (issues.$) {
release_overrides(updates);
Expand Down Expand Up @@ -572,7 +572,7 @@ export function form(id) {
},
validate: {
/** @type {RemoteForm<any, any>['validate']} */
value: async ({ includeUntouched = false, submitter } = {}) => {
value: async ({ includeUntouched = false, preflightOnly = false, submitter } = {}) => {
if (!element) return;

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

const form_data = new FormData(element, submitter);

/** @type {readonly StandardSchemaV1.Issue[]} */
/** @type {InternalRemoteFormIssue[]} */
let array = [];

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

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

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

if (result.type === 'result') {
array = /** @type {StandardSchemaV1.Issue[]} */ (
array = /** @type {InternalRemoteFormIssue[]} */ (
devalue.parse(result.result, app.decoders)
);
}
}

if (!includeUntouched && !submitted) {
array = array.filter((issue) => {
if (issue.path !== undefined) {
let path = '';

for (const segment of issue.path) {
const key = typeof segment === 'object' ? segment.key : segment;

if (typeof key === 'number') {
path += `[${key}]`;
} else if (typeof key === 'string') {
path += path === '' ? key : '.' + key;
}
}

return touched[path];
}
});
array = array.filter((issue) => touched[issue.name]);
}

const is_server_validation = !validated?.issues;
const new_issues = flatten_issues(array, is_server_validation);
const is_server_validation = !validated?.issues && !preflightOnly;

issues = is_server_validation ? new_issues : merge_with_server_issues(issues, new_issues);
raw_issues = is_server_validation
? array
: merge_with_server_issues(form_data, raw_issues, array);
}
},
enhance: {
Expand Down
53 changes: 36 additions & 17 deletions packages/kit/src/runtime/form-utils.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,39 +116,58 @@ export function deep_set(object, keys, value) {
}

/**
* @param {readonly StandardSchemaV1.Issue[]} issues
* @param {boolean} [server=false] - Whether these issues come from server validation
* @param {StandardSchemaV1.Issue} issue
* @param {boolean} server Whether this issue came from server validation
*/
export function flatten_issues(issues, server = false) {
export function normalize_issue(issue, server = false) {
/** @type {InternalRemoteFormIssue} */
const normalized = { name: '', path: [], message: issue.message, server };

if (issue.path !== undefined) {
let name = '';

for (const segment of issue.path) {
const key = /** @type {string | number} */ (
typeof segment === 'object' ? segment.key : segment
);

normalized.path.push(key);

if (typeof key === 'number') {
name += `[${key}]`;
} else if (typeof key === 'string') {
name += name === '' ? key : '.' + key;
}
}

normalized.name = name;
}

return normalized;
}

/**
* @param {InternalRemoteFormIssue[]} issues
*/
export function flatten_issues(issues) {
/** @type {Record<string, InternalRemoteFormIssue[]>} */
const result = {};

for (const issue of issues) {
/** @type {InternalRemoteFormIssue} */
const normalized = { name: '', path: [], message: issue.message, server };

(result.$ ??= []).push(normalized);
(result.$ ??= []).push(issue);

let name = '';

if (issue.path !== undefined) {
for (const segment of issue.path) {
const key = /** @type {string | number} */ (
typeof segment === 'object' ? segment.key : segment
);

normalized.path.push(key);

for (const key of issue.path) {
if (typeof key === 'number') {
name += `[${key}]`;
} else if (typeof key === 'string') {
name += name === '' ? key : '.' + key;
}

(result[name] ??= []).push(normalized);
(result[name] ??= []).push(issue);
}

normalized.name = name;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script>
import { get, set } from './form.remote.js';
import * as v from 'valibot';
const data = get();
const schema = v.object({
a: v.pipe(v.string(), v.maxLength(7, 'a is too long')),
b: v.string(),
c: v.string()
});
</script>

<!-- TODO use await here once async lands -->
{#await data then { a, b, c }}
<p>a: {a}</p>
<p>b: {b}</p>
<p>c: {c}</p>
{/await}

<hr />

<form
{...set.preflight(schema)}
oninput={() => set.validate({ preflightOnly: true })}
onchange={() => set.validate()}
>
<input {...set.fields.a.as('text')} />
<input {...set.fields.b.as('text')} />
<input {...set.fields.c.as('text')} />

<button>submit</button>
</form>

<div class="issues">
{#each set.fields.allIssues() as issue}
<p>{issue.message}</p>
{/each}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { form, query } from '$app/server';
import * as v from 'valibot';

let data = { a: '', b: '', c: '' };

export const get = query(() => {
return data;
});

export const set = form(
v.object({
a: v.pipe(v.string(), v.minLength(3, 'a is too short')),
b: v.pipe(v.string(), v.minLength(3, 'b is too short')),
c: v.pipe(v.string(), v.minLength(3, 'c is too short'))
}),
async (d) => {
data = d;
}
);
Loading
Loading