Skip to content

Commit c464cfb

Browse files
committed
Merge branch 'main' into pr/14775
2 parents ea07931 + b9635ab commit c464cfb

12 files changed

Lines changed: 274 additions & 181 deletions

File tree

.changeset/icy-glasses-agree.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+
breaking: `invalid` now must be imported from `@sveltejs/kit`

.changeset/tangy-aliens-end.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+
breaking: remove `submitter` option from experimental form `validate()` method, always provide default submitter

documentation/docs/20-core-concepts/60-remote-functions.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,12 @@ Alternatively, you could use `select` and `select multiple`:
454454
455455
### Programmatic validation
456456
457-
In addition to declarative schema validation, you can programmatically mark fields as invalid inside the form handler using the `invalid` function. This is useful for cases where you can't know if something is valid until you try to perform some action:
457+
In addition to declarative schema validation, you can programmatically mark fields as invalid inside the form handler using the `invalid` function. This is useful for cases where you can't know if something is valid until you try to perform some action. Just like `redirect` or `error`, `invalid` throws. It expects a list of strings (for issues relating to the form as a whole) or standard-schema-compliant issues (for those relating to a specific field). Use the `issue` parameter for type-safe creation of such issues:
458458
459459
```js
460460
/// file: src/routes/shop/data.remote.js
461461
import * as v from 'valibot';
462+
import { invalid } from '@sveltejs/kit';
462463
import { form } from '$app/server';
463464
import * as db from '$lib/server/database';
464465

@@ -469,13 +470,13 @@ export const buyHotcakes = form(
469470
v.minValue(1, 'you must buy at least one hotcake')
470471
)
471472
}),
472-
async (data, invalid) => {
473+
async (data, issue) => {
473474
try {
474475
await db.buy(data.qty);
475476
} catch (e) {
476477
if (e.code === 'OUT_OF_STOCK') {
477478
invalid(
478-
invalid.qty(`we don't have enough hotcakes`)
479+
issue.qty(`we don't have enough hotcakes`)
479480
);
480481
}
481482
}

packages/kit/src/exports/index.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { HttpError, Redirect, ActionFailure } from './internal/index.js';
1+
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2+
3+
import { HttpError, Redirect, ActionFailure, ValidationError } from './internal/index.js';
24
import { BROWSER, DEV } from 'esm-env';
35
import {
46
add_data_suffix,
@@ -215,6 +217,49 @@ export function isActionFailure(e) {
215217
return e instanceof ActionFailure;
216218
}
217219

220+
/**
221+
* Use this to throw a validation error to imperatively fail form validation.
222+
* Can be used in combination with `issue` passed to form actions to create field-specific issues.
223+
*
224+
* @example
225+
* ```ts
226+
* import { invalid } from '@sveltejs/kit';
227+
* import { form } from '$app/server';
228+
* import { tryLogin } from '$lib/server/auth';
229+
* import * as v from 'valibot';
230+
*
231+
* export const login = form(
232+
* v.object({ name: v.string(), _password: v.string() }),
233+
* async ({ name, _password }) => {
234+
* const success = tryLogin(name, _password);
235+
* if (!success) {
236+
* invalid('Incorrect username or password');
237+
* }
238+
*
239+
* // ...
240+
* }
241+
* );
242+
* ```
243+
* @param {...(StandardSchemaV1.Issue | string)} issues
244+
* @returns {never}
245+
* @since 2.47.3
246+
*/
247+
export function invalid(...issues) {
248+
throw new ValidationError(
249+
issues.map((issue) => (typeof issue === 'string' ? { message: issue } : issue))
250+
);
251+
}
252+
253+
/**
254+
* Checks whether this is an validation error thrown by {@link invalid}.
255+
* @param {unknown} e The object to check.
256+
* @return {e is import('./public.js').ActionFailure}
257+
* @since 2.47.3
258+
*/
259+
export function isValidationError(e) {
260+
return e instanceof ValidationError;
261+
}
262+
218263
/**
219264
* Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname.
220265
* Returns the normalized URL as well as a method for adding the potential suffix back

packages/kit/src/exports/internal/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
2+
13
export class HttpError {
24
/**
35
* @param {number} status
@@ -62,4 +64,18 @@ export class ActionFailure {
6264
}
6365
}
6466

67+
/**
68+
* Error thrown when form validation fails imperatively
69+
*/
70+
export class ValidationError extends Error {
71+
/**
72+
* @param {StandardSchemaV1.Issue[]} issues
73+
*/
74+
constructor(issues) {
75+
super('Validation failed');
76+
this.name = 'ValidationError';
77+
this.issues = issues;
78+
}
79+
}
80+
6581
export { init_remote_functions } from './remote-functions.js';

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,10 +1992,13 @@ type ExtractId<Input> = Input extends { id: infer Id }
19921992
: string | number;
19931993

19941994
/**
1995-
* Recursively maps an input type to a structure where each field can create a validation issue.
1996-
* This mirrors the runtime behavior of the `invalid` proxy passed to form handlers.
1995+
* A function and proxy object used to imperatively create validation errors in form handlers.
1996+
*
1997+
* Access properties to create field-specific issues: `issue.fieldName('message')`.
1998+
* The type structure mirrors the input data structure for type-safe field access.
1999+
* Call `invalid(issue.foo(...), issue.nested.bar(...))` to throw a validation error.
19972000
*/
1998-
type InvalidField<T> =
2001+
export type InvalidField<T> =
19992002
WillRecurseIndefinitely<T> extends true
20002003
? Record<string | number, any>
20012004
: NonNullable<T> extends string | number | boolean | File
@@ -2011,15 +2014,12 @@ type InvalidField<T> =
20112014
: Record<string, never>;
20122015

20132016
/**
2014-
* A function and proxy object used to imperatively create validation errors in form handlers.
2015-
*
2016-
* Call `invalid(issue1, issue2, ...issueN)` to throw a validation error.
2017-
* If an issue is a `string`, it applies to the form as a whole (and will show up in `fields.allIssues()`)
2018-
* Access properties to create field-specific issues: `invalid.fieldName('message')`.
2019-
* The type structure mirrors the input data structure for type-safe field access.
2017+
* A validation error thrown by `invalid`.
20202018
*/
2021-
export type Invalid<Input = any> = ((...issues: Array<string | StandardSchemaV1.Issue>) => never) &
2022-
InvalidField<Input>;
2019+
export interface ValidationError {
2020+
/** The validation issues */
2021+
issues: StandardSchemaV1.Issue[];
2022+
}
20232023

20242024
/**
20252025
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
@@ -2067,8 +2067,6 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
20672067
includeUntouched?: boolean;
20682068
/** Set this to `true` to only run the `preflight` validation. */
20692069
preflightOnly?: boolean;
2070-
/** Perform validation as if the form was submitted by the given button. */
2071-
submitter?: HTMLButtonElement | HTMLInputElement;
20722070
}): Promise<void>;
20732071
/** The result of the form submission */
20742072
get result(): Output | undefined;

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

Lines changed: 60 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { RemoteFormInput, RemoteForm } from '@sveltejs/kit' */
1+
/** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */
22
/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */
33
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
44
import { get_request_store } from '@sveltejs/kit/internal/server';
@@ -12,6 +12,7 @@ import {
1212
flatten_issues
1313
} from '../../../form-utils.js';
1414
import { get_cache, run_remote_function } from './shared.js';
15+
import { ValidationError } from '@sveltejs/kit/internal';
1516

1617
/**
1718
* Creates a form object that can be spread onto a `<form>` element.
@@ -20,7 +21,7 @@ import { get_cache, run_remote_function } from './shared.js';
2021
*
2122
* @template Output
2223
* @overload
23-
* @param {(invalid: import('@sveltejs/kit').Invalid<void>) => MaybePromise<Output>} fn
24+
* @param {() => MaybePromise<Output>} fn
2425
* @returns {RemoteForm<void, Output>}
2526
* @since 2.27
2627
*/
@@ -33,7 +34,7 @@ import { get_cache, run_remote_function } from './shared.js';
3334
* @template Output
3435
* @overload
3536
* @param {'unchecked'} validate
36-
* @param {(data: Input, invalid: import('@sveltejs/kit').Invalid<Input>) => MaybePromise<Output>} fn
37+
* @param {(data: Input, issue: InvalidField<Input>) => MaybePromise<Output>} fn
3738
* @returns {RemoteForm<Input, Output>}
3839
* @since 2.27
3940
*/
@@ -46,15 +47,15 @@ import { get_cache, run_remote_function } from './shared.js';
4647
* @template Output
4748
* @overload
4849
* @param {Schema} validate
49-
* @param {(data: StandardSchemaV1.InferOutput<Schema>, invalid: import('@sveltejs/kit').Invalid<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
50+
* @param {(data: StandardSchemaV1.InferOutput<Schema>, issue: InvalidField<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
5051
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
5152
* @since 2.27
5253
*/
5354
/**
5455
* @template {RemoteFormInput} Input
5556
* @template Output
5657
* @param {any} validate_or_fn
57-
* @param {(data_or_invalid: any, invalid?: any) => MaybePromise<Output>} [maybe_fn]
58+
* @param {(data_or_issue: any, issue?: any) => MaybePromise<Output>} [maybe_fn]
5859
* @returns {RemoteForm<Input, Output>}
5960
* @since 2.27
6061
*/
@@ -152,7 +153,7 @@ export function form(validate_or_fn, maybe_fn) {
152153

153154
state.refreshes ??= {};
154155

155-
const invalid = create_invalid();
156+
const issue = create_issues();
156157

157158
try {
158159
output.result = await run_remote_function(
@@ -161,7 +162,7 @@ export function form(validate_or_fn, maybe_fn) {
161162
true,
162163
data,
163164
(d) => d,
164-
(data) => (!maybe_fn ? fn(invalid) : fn(data, invalid))
165+
(data) => (!maybe_fn ? fn() : fn(data, issue))
165166
);
166167
} catch (e) {
167168
if (e instanceof ValidationError) {
@@ -314,89 +315,72 @@ function handle_issues(output, issues, form_data) {
314315

315316
/**
316317
* Creates an invalid function that can be used to imperatively mark form fields as invalid
317-
* @returns {import('@sveltejs/kit').Invalid}
318+
* @returns {InvalidField<any>}
318319
*/
319-
function create_invalid() {
320-
/**
321-
* @param {...(string | StandardSchemaV1.Issue)} issues
322-
* @returns {never}
323-
*/
324-
function invalid(...issues) {
325-
throw new ValidationError(
326-
issues.map((issue) => {
327-
if (typeof issue === 'string') {
328-
return {
329-
path: [],
330-
message: issue
331-
};
320+
function create_issues() {
321+
return /** @type {InvalidField<any>} */ (
322+
new Proxy(
323+
/** @param {string} message */
324+
(message) => {
325+
// TODO 3.0 remove
326+
if (typeof message !== 'string') {
327+
throw new Error(
328+
'`invalid` should now be imported from `@sveltejs/kit` to throw validation issues. ' +
329+
"The second parameter provided to the form function (renamed to `issue`) is still used to construct issues, e.g. `invalid(issue.field('message'))`. " +
330+
'For more info see https://github.com/sveltejs/kit/pulls/14768'
331+
);
332332
}
333333

334-
return issue;
335-
})
336-
);
337-
}
338-
339-
return /** @type {import('@sveltejs/kit').Invalid} */ (
340-
new Proxy(invalid, {
341-
get(target, prop) {
342-
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
334+
return create_issue(message);
335+
},
336+
{
337+
get(target, prop) {
338+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
343339

344-
/**
345-
* @param {string} message
346-
* @param {(string | number)[]} path
347-
* @returns {StandardSchemaV1.Issue}
348-
*/
349-
const create_issue = (message, path = []) => ({
350-
message,
351-
path
352-
});
353-
354-
return create_issue_proxy(prop, create_issue, []);
340+
return create_issue_proxy(prop, []);
341+
}
355342
}
356-
})
343+
)
357344
);
358-
}
359345

360-
/**
361-
* Error thrown when form validation fails imperatively
362-
*/
363-
class ValidationError extends Error {
364346
/**
365-
* @param {StandardSchemaV1.Issue[]} issues
347+
* @param {string} message
348+
* @param {(string | number)[]} path
349+
* @returns {StandardSchemaV1.Issue}
366350
*/
367-
constructor(issues) {
368-
super('Validation failed');
369-
this.name = 'ValidationError';
370-
this.issues = issues;
351+
function create_issue(message, path = []) {
352+
return {
353+
message,
354+
path
355+
};
371356
}
372-
}
373-
374-
/**
375-
* Creates a proxy that builds up a path and returns a function to create an issue
376-
* @param {string | number} key
377-
* @param {(message: string, path: (string | number)[]) => StandardSchemaV1.Issue} create_issue
378-
* @param {(string | number)[]} path
379-
*/
380-
function create_issue_proxy(key, create_issue, path) {
381-
const new_path = [...path, key];
382357

383358
/**
384-
* @param {string} message
385-
* @returns {StandardSchemaV1.Issue}
359+
* Creates a proxy that builds up a path and returns a function to create an issue
360+
* @param {string | number} key
361+
* @param {(string | number)[]} path
386362
*/
387-
const issue_func = (message) => create_issue(message, new_path);
363+
function create_issue_proxy(key, path) {
364+
const new_path = [...path, key];
388365

389-
return new Proxy(issue_func, {
390-
get(target, prop) {
391-
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
366+
/**
367+
* @param {string} message
368+
* @returns {StandardSchemaV1.Issue}
369+
*/
370+
const issue_func = (message) => create_issue(message, new_path);
392371

393-
// Handle array access like invalid.items[0]
394-
if (/^\d+$/.test(prop)) {
395-
return create_issue_proxy(parseInt(prop, 10), create_issue, new_path);
396-
}
372+
return new Proxy(issue_func, {
373+
get(target, prop) {
374+
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
397375

398-
// Handle property access like invalid.field.nested
399-
return create_issue_proxy(prop, create_issue, new_path);
400-
}
401-
});
376+
// Handle array access like invalid.items[0]
377+
if (/^\d+$/.test(prop)) {
378+
return create_issue_proxy(parseInt(prop, 10), new_path);
379+
}
380+
381+
// Handle property access like invalid.field.nested
382+
return create_issue_proxy(prop, new_path);
383+
}
384+
});
385+
}
402386
}

0 commit comments

Comments
 (0)