From 85d627d5223e70af29e14d3bedf3f5bfe5906078 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 6 Sep 2025 12:27:22 +0100 Subject: [PATCH 01/64] add convert_formdata function --- packages/kit/src/runtime/utils.js | 51 ++++++++++++++++++++ packages/kit/src/runtime/utils.spec.js | 64 +++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index b6e3103ff1ee..56c4bd3dbdd2 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -63,3 +63,54 @@ export function base64_decode(encoded) { return bytes; } + +/** + * Convert `FormData` into a POJO + * @param {FormData} data + */ +export function convert_formdata(data) { + /** @type {Record} */ + const result = {}; + + for (let key of data.keys()) { + const is_array = key.endsWith('[]'); + const values = data.getAll(key); + + if (is_array) key = key.slice(0, -2); + + if (values.length > 1 && !is_array) { + throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`); + } + + deep_set(result, split_path(key), is_array ? values : values[0]); + } + + return result; +} + +const path_regex = /^[a-zA-Z_$]\w*(\.[a-zA-Z_$]\w*|\[\d+\])*$/; + +/** + * @param {string} path + */ +export function split_path(path) { + if (!path_regex.test(path)) { + throw new Error(`Invalid path ${path}`); + } + + return path.split(/\.|\[|\]/).filter(Boolean); +} + +/** + * @param {Record} object + * @param {string[]} keys + * @param {any} value + */ +export function deep_set(object, keys, value) { + for (let i = 0; i < keys.length - 1; i += 1) { + const key = keys[i]; + object = object[key] ??= /^\d+$/.test(keys[i + 1]) ? [] : {}; + } + + object[keys[keys.length - 1]] = value; +} diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js index 673ba0b0bb33..6438178b5048 100644 --- a/packages/kit/src/runtime/utils.spec.js +++ b/packages/kit/src/runtime/utils.spec.js @@ -1,5 +1,11 @@ import { afterEach, assert, beforeEach, describe, expect, test } from 'vitest'; -import { base64_decode, base64_encode, text_encoder } from './utils.js'; +import { + base64_decode, + base64_encode, + convert_formdata, + split_path, + text_encoder +} from './utils.js'; const inputs = [ 'hello world', @@ -35,3 +41,59 @@ describe('base64_decode', () => { expect(actual).toEqual(text_encoder.encode(input)); }); }); + +describe('split_path', () => { + const good = [ + { + input: 'foo', + output: ['foo'] + }, + { + input: 'foo.bar.baz', + output: ['foo', 'bar', 'baz'] + }, + { + input: 'foo[0][1][2]', + output: ['foo', '0', '1', '2'] + } + ]; + + const bad = ['[0]', 'foo.0', 'foo[bar]']; + + for (const { input, output } of good) { + test(input, () => { + expect(split_path(input)).toEqual(output); + }); + } + + for (const input of bad) { + test(input, () => { + expect(() => split_path(input)).toThrowError(`Invalid path ${input}`); + }); + } +}); + +describe('convert_formdata', () => { + test('converts a FormData object', () => { + const data = new FormData(); + + data.append('foo', 'foo'); + + data.append('object.nested.property', 'property'); + data.append('array[]', 'a'); + data.append('array[]', 'b'); + data.append('array[]', 'c'); + + const converted = convert_formdata(data); + + expect(converted).toEqual({ + foo: 'foo', + object: { + nested: { + property: 'property' + } + }, + array: ['a', 'b', 'c'] + }); + }); +}); From 8d9953395171f35c662c74d0727b99bd3f560dfc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 6 Sep 2025 13:02:37 +0100 Subject: [PATCH 02/64] WIP --- packages/kit/src/exports/public.d.ts | 36 ++++++++++- .../kit/src/runtime/app/server/remote/form.js | 60 ++++++++++++++++--- .../src/routes/remote/form/form.remote.js | 8 +-- packages/kit/types/index.d.ts | 54 +++++++++++++++-- 4 files changed, 137 insertions(+), 21 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 9334ee453db6..1604d4fe5cef 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1706,10 +1706,36 @@ export interface Snapshot { restore: (snapshot: T) => void; } +// Helper type to convert union to intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void + ? I + : never; + +// Main flattening type that handles objects, arrays, and primitives +type FlattenObject = T extends readonly unknown[] + ? T extends ReadonlyArray + ? Prefix extends '' + ? { [K in `[${number}]`]: U } + : { [K in `${Prefix}[${number}]`]: U } + : never + : T extends object + ? { + [K in keyof T]: FlattenObject< + T[K], + Prefix extends '' ? K & string : `${Prefix}.${K & string}` + >; + }[keyof T] + : Prefix extends '' + ? never + : { [P in Prefix]: T }; + +// Convert the union of objects to a single intersected object +type Flatten = UnionToIntersection>; + /** * The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. */ -export type RemoteForm = { +export type RemoteForm = { method: 'POST'; /** The URL to send the form to. */ action: string; @@ -1743,11 +1769,15 @@ export type RemoteForm = { * {/each} * ``` */ - for(key: string | number | boolean): Omit, 'for'>; + for(key: string | number | boolean): Omit, 'for'>; /** The result of the form submission */ - get result(): Result | undefined; + get result(): Output | undefined; /** The number of pending submissions */ get pending(): number; + /** The submitted values (TODO values should be string | File | null) */ + input?: Flatten; + /** Validation issues (TODO values should be Issue[]) */ + issues?: Flatten; /** Spread this onto a ` @@ -37,8 +35,7 @@ { - const task = data.get('task'); - await submit().updates(current_task.withOverride(() => task + ' (overridden)')); + await submit().updates(current_task.withOverride(() => data.task + ' (overridden)')); })} > @@ -46,8 +43,7 @@ @@ -55,7 +51,7 @@
{ - const task = data.get('task'); + data.task; await submit(); })} > @@ -63,7 +59,7 @@ - -
+
+ {#if set_message.issues.message} +

{set_message.issues.message[0].message}

+ {/if} - { - if (data.task === 'abort') return; - await submit(); - })} -> - - - + + +
-
{ - await submit().updates(current_task.withOverride(() => data.task + ' (overridden)')); - })} -> - - - +

set_message.input.message: {set_message.input.message}

+

set_message.pending: {set_message.pending}

+

set_message.result: {set_message.result}

+

set_reverse_message.result: {set_reverse_message.result}

+ +
+ + + {#if scoped.issues.message} +

{scoped.issues.message[0].message}

+ {/if} + + +
- +

scoped.input.message: {scoped.input.message}

+

scoped.pending: {scoped.pending}

+

scoped.result: {scoped.result}

+ +
+
{ - data.task; - await submit(); + data-enhanced + {...enhanced.enhance(async ({ data, submit }) => { + console.group('enhanced'); + console.log(data); + await submit().updates(get_message().withOverride((message) => message + ' (override)')); + console.groupEnd(); })} > - - + {#if enhanced.issues.message} +

{enhanced.issues.message[0].message}

+ {/if} + + +
-

{task_one.result}

-

{task_two.result}

+

enhanced.input.message: {enhanced.input.message}

+

enhanced.pending: {enhanced.pending}

+

enhanced.result: {enhanced.result}

-
- -
+
-{#each ['foo', 'bar'] as item} -
- {task_one.for(item).result} - - -
-{/each} - -
- {task_two.for('foo').result} - - + +
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js deleted file mode 100644 index ce7f7989a697..000000000000 --- a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js +++ /dev/null @@ -1,55 +0,0 @@ -import { form, query } from '$app/server'; -import { error, redirect } from '@sveltejs/kit'; -import * as v from 'valibot'; - -let task; -const deferreds = []; - -export const get_task = query(() => { - return task; -}); - -export const resolve_deferreds = form(async () => { - for (const deferred of deferreds) { - deferred.resolve(); - } - deferreds.length = 0; - return 'resolved'; -}); - -export const task_one = form(v.object({ task: v.string() }), async (data) => { - task = data.task; - - if (task === 'error') { - error(400, { message: 'Expected error' }); - } - if (task === 'redirect') { - redirect(303, '/remote'); - } - if (task === 'deferred') { - const deferred = Promise.withResolvers(); - deferreds.push(deferred); - await deferred.promise; - } else if (task === 'override') { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return task; -}); - -export const task_two = form(v.object({ task: v.string() }), async (data) => { - task = data.task; - - if (task === 'error') { - throw new Error('Unexpected error'); - } - if (task === 'deferred') { - const deferred = Promise.withResolvers(); - deferreds.push(deferred); - await deferred.promise; - } else if (task === 'override') { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return task; -}); diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts new file mode 100644 index 000000000000..fa1a82e1e76b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts @@ -0,0 +1,54 @@ +import { form, getRequestEvent, query } from '$app/server'; +import { error, redirect } from '@sveltejs/kit'; +import * as v from 'valibot'; + +let message = 'initial'; +const deferreds = []; + +export const get_message = query(() => { + return message; +}); + +export const set_message = form( + v.object({ + message: v.picklist( + ['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect'], + 'message is invalid' + ) + }), + async (data) => { + if (data.message === 'unexpected error') { + throw new Error('oops'); + } + + if (data.message === 'expected error') { + error(500, 'oops'); + } + + if (data.message === 'redirect') { + redirect(303, '/remote'); + } + + message = data.message; + + if (getRequestEvent().isRemoteRequest) { + const deferred = Promise.withResolvers(); + deferreds.push(deferred); + await deferred.promise; + } + + return message; + } +); + +export const set_reverse_message = form(v.object({ message: v.string() }), (data) => { + message = data.message.split('').reverse().join(''); + return message; +}); + +export const resolve_deferreds = form(async () => { + for (const deferred of deferreds) { + deferred.resolve(); + } + deferreds.length = 0; +}); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 44d6a794f171..cfac8d3e5305 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1754,69 +1754,6 @@ test.describe('remote functions', () => { expect(request_count).toBe(1); // no query refreshes, since that happens as part of the command response }); - test('form.enhance works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-enhance', 'abort'); - await page.click('#submit-btn-enhance-one'); - await page.waitForTimeout(100); // allow Svelte to update in case this does submit after (which it shouldn't) - await expect(page.locator('#form-result-1')).toHaveText(''); - - await page.fill('#input-task-enhance', 'hi'); - await page.click('#submit-btn-enhance-one'); - await expect(page.locator('#form-result-1')).toHaveText('hi'); - - await page.fill('#input-task-enhance', 'error'); - await page.click('#submit-btn-enhance-one'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Expected error"' - ); - }); - - test('form.buttonProps.enhance works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-enhance', 'abort'); - await page.click('#submit-btn-enhance-two'); - await page.waitForTimeout(100); // allow Svelte to update in case this does submit after (which it shouldn't) - await expect(page.locator('#form-result-2')).toHaveText(''); - - await page.fill('#input-task-enhance', 'hi'); - await page.click('#submit-btn-enhance-two'); - await expect(page.locator('#form-result-2')).toHaveText('hi'); - - await page.fill('#input-task-enhance', 'error'); - await page.click('#submit-btn-enhance-two'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Unexpected error (500 Internal Error)"' - ); - }); - - test('form.enhance with override works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-override', 'override'); - page.click('#submit-btn-override-one'); - await expect(page.locator('#get-task')).toHaveText('override (overridden)'); - await expect(page.locator('#form-result-1')).toHaveText('override'); - await expect(page.locator('#get-task')).toHaveText('override'); - }); - - test('form.buttonProps.enhance with override works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-override', 'override'); - page.click('#submit-btn-override-one'); - await expect(page.locator('#get-task')).toHaveText('override (overridden)'); - await expect(page.locator('#form-result-1')).toHaveText('override'); - await expect(page.locator('#get-task')).toHaveText('override'); - }); - - test('form.buttonProps.enhance works with nested elements (issue #14159)', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-nested', 'nested-test'); - - // Click on the span inside the button to test the event.target vs event.currentTarget issue - await page.click('#submit-btn-nested-span span'); - await expect(page.locator('#form-result-2')).toHaveText('nested-test'); - }); - test('prerendered entries not called in prod', async ({ page }) => { let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); @@ -1915,31 +1852,4 @@ test.describe('remote functions', () => { // Verify pending count returns to 0 await expect(page.locator('#command-pending')).toHaveText('Command pending: 0'); }); - - test('form pending state is tracked correctly', async ({ page }) => { - await page.goto('/remote/form'); - - // Initially no pending forms - await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); - await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); - - // Fill form with slow operation - await page.fill('#input-task', 'deferred'); - - // Submit form - this will hang until we resolve it - await page.click('#submit-btn-one'); - - // Check that pending has incremented to 1 - await expect(page.locator('#form-pending')).toHaveText('Form pending: 1'); - - // Resolve the deferred form submission - await page.click('#resolve-deferreds'); - - // Wait for form submission to complete and verify results - await expect(page.locator('#get-task')).toHaveText('deferred'); - - // Verify pending count returns to 0 - await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); - await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); - }); }); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 36c6ed0ad9d8..0a078cb64abd 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1600,59 +1600,152 @@ test.describe('remote functions', () => { } }); - test('form works', async ({ page }) => { + test('form works', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'hi'); - await page.click('#submit-btn-one'); - await expect(page.locator('#form-result-1')).toHaveText('hi'); - await expect(page.locator('#input-task')).toHaveValue(''); + + if (javaScriptEnabled) { + // TODO remove the `if` — once async SSR lands these assertions should always succeed + await expect(page.getByText('message.current:')).toHaveText('message.current: initial'); + await expect(page.getByText('await get_message():')).toHaveText( + 'await get_message(): initial' + ); + } + + await page.fill('[data-unscoped] input', 'hello'); + await page.getByText('set message').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('set_message.pending:')).toHaveText('set_message.pending: 1'); + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('set_message.pending:')).toHaveText('set_message.pending: 0'); + + await expect(page.getByText('message.current:')).toHaveText('message.current: hello'); + await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); + } + + await expect(page.getByText('set_message.result')).toHaveText('set_message.result: hello'); + await expect(page.locator('[data-unscoped] input')).toHaveValue(''); }); - test('form error works', async ({ page }) => { + test('form updates inputs live', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'error'); - await page.click('#submit-btn-one'); - expect(await page.textContent('h1')).toBe('400'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Expected error"' + + await page.fill('input', 'hello'); + + if (javaScriptEnabled) { + await expect(page.getByText('set_message.input.message:')).toHaveText( + 'set_message.input.message: hello' + ); + } + + await page.getByText('set message').click(); + + if (javaScriptEnabled) { + await page.getByText('resolve deferreds').click(); + } + + await expect(page.getByText('set_message.input.message:')).toHaveText( + 'set_message.input.message:' ); }); - test('form redirect works', async ({ page }) => { + test('form reports validation issues', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'redirect'); - await page.click('#submit-btn-one'); - expect(await page.textContent('#echo-result')).toBe('Hello world'); + + await page.fill('input', 'invalid'); + await page.getByText('set message').click(); + + await page.getByText('message is invalid').waitFor(); }); - test('form.buttonProps works', async ({ page }) => { + test('form handles unexpected error', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'hi'); - await page.click('#submit-btn-two'); - await expect(page.locator('#form-result-2')).toHaveText('hi'); + + await page.fill('input', 'unexpected error'); + await page.getByText('set message').click(); + + await page + .getByText('This is your custom error page saying: "oops (500 Internal Error)"') + .waitFor(); }); - test('form.buttonProps error works', async ({ page }) => { + test('form handles expected error', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'error'); - await page.click('#submit-btn-two'); - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Unexpected error (500 Internal Error)"' + + await page.fill('input', 'expected error'); + await page.getByText('set message').click(); + + await page.getByText('This is your custom error page saying: "oops"').waitFor(); + }); + + test('form redirects', async ({ page }) => { + await page.goto('/remote/form'); + + await page.fill('input', 'redirect'); + await page.getByText('set message').click(); + + await page.waitForURL('/remote'); + }); + + test('form.buttonProps works', async ({ page, javaScriptEnabled }) => { + await page.goto('/remote/form'); + + await page.fill('[data-unscoped] input', 'backwards'); + await page.getByText('set reverse message').click(); + + if (javaScriptEnabled) { + await page.getByText('message.current: sdrawkcab').waitFor(); + await expect(page.getByText('await get_message():')).toHaveText( + 'await get_message(): sdrawkcab' + ); + } + + await expect(page.getByText('set_reverse_message.result')).toHaveText( + 'set_reverse_message.result: sdrawkcab' ); }); - test('form.for(...) scopes form submission', async ({ page, javaScriptEnabled }) => { + test('form scoping with for(...) works', async ({ page, javaScriptEnabled }) => { + await page.goto('/remote/form'); + + await page.fill('[data-scoped] input', 'hello'); + await page.getByText('set scoped message').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('scoped.pending:')).toHaveText('scoped.pending: 1'); + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('scoped.pending:')).toHaveText('scoped.pending: 0'); + + await page.getByText('message.current: hello').waitFor(); + await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); + } + + await expect(page.getByText('scoped.result')).toHaveText('scoped.result: hello'); + await expect(page.locator('[data-scoped] input')).toHaveValue(''); + }); + + test('form enhance(...) works', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.click('#submit-btn-item-foo'); - await expect(page.locator('#form-result-foo')).toHaveText('foo'); - await expect(page.locator('#form-result-bar')).toHaveText(''); - await expect(page.locator('#form-result-1')).toHaveText(''); - - await page.click('#submit-btn-item-2-foo'); - await expect(page.locator('#form-result-2-foo')).toHaveText('foo2'); - await expect(page.locator('#form-result-foo')).toHaveText(javaScriptEnabled ? 'foo' : ''); - await expect(page.locator('#form-result-2')).toHaveText(''); + + await page.fill('[data-enhanced] input', 'hello'); + + // Click on the span inside the button to test the event.target vs event.currentTarget issue (#14159) + await page.locator('[data-enhanced] span').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 1'); + + await page.getByText('message.current: hello (override)').waitFor(); + await expect(page.getByText('await get_message():')).toHaveText( + 'await get_message(): hello (override)' + ); + + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 0'); + } + + await expect(page.getByText('enhanced.result')).toHaveText('enhanced.result: hello'); + await expect(page.locator('[data-enhanced] input')).toHaveValue(''); }); test('prerendered entries not called in prod', async ({ page, clicknav }) => { From 74d19095347b3fdc8d2a8476471ac724f31ad920 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 8 Sep 2025 22:28:21 -0400 Subject: [PATCH 25/64] tidy up --- .../kit/test/apps/basics/src/routes/remote/form/+page.svelte | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/+page.svelte index 378439fc6408..f3ba02f0be3a 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/+page.svelte @@ -56,10 +56,7 @@
{ - console.group('enhanced'); - console.log(data); await submit().updates(get_message().withOverride((message) => message + ' (override)')); - console.groupEnd(); })} > {#if enhanced.issues.message} From eec424931a4d889b740a53d744749803f6274af1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 08:43:32 -0400 Subject: [PATCH 26/64] lint --- .../kit/src/runtime/client/remote-functions/form.svelte.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 0b2652c5f97c..6c713b5358dd 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -1,6 +1,6 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -/** @import { FormInput, RemoteForm, RemoteQuery, RemoteQueryOverride } from '@sveltejs/kit' */ -/** @import { MaybePromise, RemoteFunctionResponse } from 'types' */ +/** @import { FormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */ +/** @import { RemoteFunctionResponse } from 'types' */ /** @import { Query } from './query.svelte.js' */ import { app_dir, base } from '__sveltekit/paths'; import * as devalue from 'devalue'; From 1c820a78c9b05736c1a0c0ec6fca172ca905aeca Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 08:53:36 -0400 Subject: [PATCH 27/64] lint --- packages/kit/src/exports/public.d.ts | 2 +- packages/kit/types/index.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 6fd70ffac659..e78151cd9bb0 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1757,7 +1757,7 @@ export interface FormInput { /** * The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. */ -export type RemoteForm = { +export type RemoteForm = { /** Attachment that sets up an event handler that intercepts the form submission on the client to prevent a full page reload */ [attachment: symbol]: (node: HTMLFormElement) => void; method: 'POST'; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 9583d74f1ecd..d62c851f7a24 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1733,7 +1733,7 @@ declare module '@sveltejs/kit' { /** * The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. */ - export type RemoteForm = { + export type RemoteForm = { /** Attachment that sets up an event handler that intercepts the form submission on the client to prevent a full page reload */ [attachment: symbol]: (node: HTMLFormElement) => void; method: 'POST'; From 037251c11c973b15c140c6ed3cf0e7cb39d24f53 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 13:52:27 -0400 Subject: [PATCH 28/64] fix --- packages/kit/src/exports/public.d.ts | 2 +- packages/kit/src/runtime/app/server/remote/form.js | 8 ++++---- packages/kit/types/index.d.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index e78151cd9bb0..99cc7f666f2d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1794,7 +1794,7 @@ export type RemoteForm = { */ for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ - preflight(schema: StandardSchemaV1): RemoteForm; + preflight(schema: StandardSchemaV1): RemoteForm; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 96511f057d60..1b340d1d0bed 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -1,5 +1,5 @@ /** @import { FormInput, RemoteForm } from '@sveltejs/kit' */ -/** @import { RemoteInfo } from 'types' */ +/** @import { MaybePromise, RemoteInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { run_remote_function } from './shared.js'; @@ -25,7 +25,7 @@ import { convert_formdata } from '../../../utils.js'; * @template Output * @overload * @param {'unchecked'} validate - * @param {(data: Input) => Output} fn + * @param {(data: Input) => MaybePromise} fn * @returns {RemoteForm} * @since 2.27 */ @@ -38,7 +38,7 @@ import { convert_formdata } from '../../../utils.js'; * @template Output * @overload * @param {Schema} validate - * @param {(data: StandardSchemaV1.InferOutput) => Output} fn + * @param {(data: StandardSchemaV1.InferOutput) => MaybePromise} fn * @returns {RemoteForm, Output>} * @since 2.27 */ @@ -46,7 +46,7 @@ import { convert_formdata } from '../../../utils.js'; * @template {FormInput} Input * @template Output * @param {any} validate_or_fn - * @param {(data?: Input) => Output} [maybe_fn] + * @param {(data?: Input) => MaybePromise} [maybe_fn] * @returns {RemoteForm} * @since 2.27 */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 896f8eb2cdc6..b05eec494d98 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1770,7 +1770,7 @@ declare module '@sveltejs/kit' { */ for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ - preflight(schema: StandardSchemaV1): RemoteForm; + preflight(schema: StandardSchemaV1): RemoteForm; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ @@ -2923,7 +2923,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function form(validate: "unchecked", fn: (data: Input) => Output): RemoteForm; + export function form(validate: "unchecked", fn: (data: Input) => MaybePromise): RemoteForm; /** * Creates a form object that can be spread onto a `` element. * @@ -2931,7 +2931,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function form>, Output>(validate: Schema, fn: (data: StandardSchemaV1.InferOutput) => Output): RemoteForm, Output>; + export function form>, Output>(validate: Schema, fn: (data: StandardSchemaV1.InferOutput) => MaybePromise): RemoteForm, Output>; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * From cc5fdb76520fad4ebfa443ac68ae1b99dd43890c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 14:42:21 -0400 Subject: [PATCH 29/64] only enforce input parity --- packages/kit/src/exports/public.d.ts | 2 +- packages/kit/types/index.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 99cc7f666f2d..5f2b300f1332 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1794,7 +1794,7 @@ export type RemoteForm = { */ for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ - preflight(schema: StandardSchemaV1): RemoteForm; + preflight(schema: StandardSchemaV1): RemoteForm; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index b05eec494d98..ce17d5d5521d 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1770,7 +1770,7 @@ declare module '@sveltejs/kit' { */ for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ - preflight(schema: StandardSchemaV1): RemoteForm; + preflight(schema: StandardSchemaV1): RemoteForm; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ From 6119e20fa39be6ab8ef3664905e46a11a90cc1a6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 14:43:12 -0400 Subject: [PATCH 30/64] use validation output, not input --- packages/kit/src/runtime/app/server/remote/form.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 1b340d1d0bed..39f98a2b28ac 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -96,15 +96,15 @@ export function form(validate_or_fn, maybe_fn) { id: '', /** @param {FormData} form_data */ fn: async (form_data) => { - const object = maybe_fn ? convert_formdata(form_data) : undefined; + let data = maybe_fn ? convert_formdata(form_data) : undefined; /** @type {{ input?: Record, issues?: Record, result: Output }} */ const output = {}; const { event, state } = get_request_store(); - const issues = (await schema?.['~standard'].validate(object))?.issues; + const validated = await schema?.['~standard'].validate(data); - if (issues !== undefined) { + if (validated?.issues !== undefined) { output.input = {}; output.issues = { $: [] }; @@ -117,7 +117,7 @@ export function form(validate_or_fn, maybe_fn) { output.input[key] = is_array ? values : values[0]; } - for (const issue of issues) { + for (const issue of validated.issues) { output.issues.$.push(issue); let path = ''; @@ -137,9 +137,13 @@ export function form(validate_or_fn, maybe_fn) { } } } else { + if (validated !== undefined) { + data = validated.value; + } + state.refreshes ??= {}; - output.result = await run_remote_function(event, state, true, object, (d) => d, fn); + output.result = await run_remote_function(event, state, true, data, (d) => d, fn); } // We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads From 249dec1ba2af578a089ce7b4811af861873a5ec9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 14:43:31 -0400 Subject: [PATCH 31/64] preflight should return instance --- packages/kit/src/runtime/app/server/remote/form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 39f98a2b28ac..6d1f7bfc38fe 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -204,7 +204,7 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, 'preflight', { // preflight is a noop on the server - value: () => {} + value: () => instance }); if (key == undefined) { From 3578408c54502ec501ca7627258e1f0eae63c649 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 15:05:52 -0400 Subject: [PATCH 32/64] DRY out --- .../client/remote-functions/form.svelte.js | 119 +++++++++--------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 6c713b5358dd..967754415b39 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -50,6 +50,33 @@ export function form(id) { /** @type {StandardSchemaV1 | undefined} */ let preflight_schema = undefined; + /** + * @param {HTMLFormElement} form + * @param {FormData} form_data + * @param {Parameters['enhance']>[0]} callback + */ + async function handle_submit(form, form_data, callback) { + const data = convert_formdata(form_data); + + const validated = await preflight_schema?.['~standard'].validate(data); + + if (validated?.issues) { + // TODO populate `issues` + } + + try { + await callback({ + form, + data, + submit: () => submit(form_data) + }); + } catch (e) { + const error = e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message }; + const status = e instanceof HttpError ? e.status : 500; + void set_nearest_error_page(error, status); + } + } + /** * @param {FormData} data * @returns {Promise & { updates: (...args: any[]) => any }} @@ -155,36 +182,6 @@ export function form(id) { instance.method = 'POST'; instance.action = action; - /** - * @param {HTMLFormElement} form_element - * @param {HTMLElement | null} submitter - */ - function create_form_data(form_element, submitter) { - const form_data = new FormData(form_element); - - if (DEV) { - const enctype = submitter?.hasAttribute('formenctype') - ? /** @type {HTMLButtonElement | HTMLInputElement} */ (submitter).formEnctype - : clone(form_element).enctype; - if (enctype !== 'multipart/form-data') { - for (const value of form_data.values()) { - if (value instanceof File) { - throw new Error( - 'Your form contains fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.' - ); - } - } - } - } - - const submitter_name = submitter?.getAttribute('name'); - if (submitter_name) { - form_data.append(submitter_name, submitter?.getAttribute('value') ?? ''); - } - - return form_data; - } - /** @param {Parameters['enhance']>[0]} callback */ const form_onsubmit = (callback) => { /** @param {SubmitEvent} event */ @@ -209,26 +206,13 @@ export function form(id) { event.preventDefault(); - const form_data = create_form_data(form, event.submitter); - const data = convert_formdata(form_data); + const form_data = new FormData(form); - if (preflight_schema) { - // TODO populate `issues` - // const validation = preflight_schema['~standard'].validate(data); + if (DEV) { + validate_form_data(form_data, clone(form).enctype); } - try { - await callback({ - form, - data, - submit: () => submit(form_data) - }); - } catch (e) { - const error = - e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message }; - const status = e instanceof HttpError ? e.status : 500; - void set_nearest_error_page(error, status); - } + await handle_submit(form, form_data, callback); }; }; @@ -294,20 +278,21 @@ export function form(id) { event.stopPropagation(); event.preventDefault(); - const data = create_form_data(form, target); + const form_data = new FormData(form); - try { - await callback({ - form, - data, - submit: () => submit(data) - }); - } catch (e) { - const error = - e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message }; - const status = e instanceof HttpError ? e.status : 500; - void set_nearest_error_page(error, status); + if (DEV) { + const enctype = target.hasAttribute('formenctype') + ? target.formEnctype + : clone(form).enctype; + + validate_form_data(form_data, enctype); + } + + if (target.name) { + form_data.append(target.name, target?.getAttribute('value') ?? ''); } + + await handle_submit(form, form_data, callback); }; }; @@ -420,3 +405,19 @@ export function form(id) { function clone(element) { return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element)); } + +/** + * @param {FormData} form_data + * @param {string} enctype + */ +function validate_form_data(form_data, enctype) { + if (enctype !== 'multipart/form-data') { + for (const value of form_data.values()) { + if (value instanceof File) { + throw new Error( + 'Your form contains fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.' + ); + } + } + } +} From 48700d61d308fe8053f090080707a57d1294dbec Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 17:50:24 -0400 Subject: [PATCH 33/64] implement preflight --- .../kit/src/runtime/app/server/remote/form.js | 24 +------- .../client/remote-functions/form.svelte.js | 5 +- packages/kit/src/runtime/utils.js | 31 ++++++++++ .../routes/remote/form/preflight/+page.svelte | 59 +++++++++++++++++++ .../remote/form/preflight/form.remote.ts | 23 ++++++++ packages/kit/test/apps/basics/test/test.js | 23 ++++++++ 6 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 6d1f7bfc38fe..45b3c97f6e75 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -3,7 +3,7 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { run_remote_function } from './shared.js'; -import { convert_formdata } from '../../../utils.js'; +import { convert_formdata, flatten_issues } from '../../../utils.js'; /** * Creates a form object that can be spread onto a `` element. @@ -105,8 +105,8 @@ export function form(validate_or_fn, maybe_fn) { const validated = await schema?.['~standard'].validate(data); if (validated?.issues !== undefined) { + output.issues = flatten_issues(validated.issues); output.input = {}; - output.issues = { $: [] }; for (let key of form_data.keys()) { const is_array = key.endsWith('[]'); @@ -116,26 +116,6 @@ export function form(validate_or_fn, maybe_fn) { output.input[key] = is_array ? values : values[0]; } - - for (const issue of validated.issues) { - output.issues.$.push(issue); - - let path = ''; - - if (issue.path !== undefined) { - 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; - } - - (output.issues[path] ??= []).push(issue); - } - } - } } else { if (validated !== undefined) { data = validated.value; diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 967754415b39..eee821032387 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -17,7 +17,7 @@ import { import { tick } from 'svelte'; import { refresh_queries, release_overrides } from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; -import { convert_formdata } from '../../utils.js'; +import { convert_formdata, flatten_issues } from '../../utils.js'; /** * Client-version of the `form` function from `$app/server`. @@ -61,7 +61,8 @@ export function form(id) { const validated = await preflight_schema?.['~standard'].validate(data); if (validated?.issues) { - // TODO populate `issues` + issues = flatten_issues(validated.issues); + return; } try { diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index 56c4bd3dbdd2..e8d7befe276c 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -1,3 +1,4 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { BROWSER } from 'esm-env'; export const text_encoder = new TextEncoder(); @@ -114,3 +115,33 @@ export function deep_set(object, keys, value) { object[keys[keys.length - 1]] = value; } + +/** + * @param {readonly StandardSchemaV1.Issue[]} issues + */ +export function flatten_issues(issues) { + /** @type {Record} */ + const result = { $: [] }; + + for (const issue of issues) { + result.$.push(issue); + + let path = ''; + + if (issue.path !== undefined) { + 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; + } + + (result[path] ??= []).push(issue); + } + } + } + + return result; +} diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte new file mode 100644 index 000000000000..db83799d2f2b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte @@ -0,0 +1,59 @@ + + +

number.current: {number.current}

+ + +{#await number then n} +

await get_number(): {n}

+{/await} + +
+ + + {#if set_number.issues.number} +

{set_number.issues.number[0].message}

+ {/if} + + + + + +

set_number.input.number: {set_number.input.number}

+

set_number.pending: {set_number.pending}

+

set_number.result: {set_number.result}

+ +
+ +
{ + await submit(); + })} +> + {#if enhanced.issues.number} +

{enhanced.issues.number[0].message}

+ {/if} + + + +
+ +

enhanced.input.number: {enhanced.input.number}

+

enhanced.pending: {enhanced.pending}

+

enhanced.result: {enhanced.result}

diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts new file mode 100644 index 000000000000..e4a1f2378587 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts @@ -0,0 +1,23 @@ +import { form, query } from '$app/server'; +import * as v from 'valibot'; + +let number = 0; + +export const get_number = query(() => { + return number; +}); + +export const set_number = form( + v.object({ + number: v.pipe( + v.string(), + v.regex(/^\d+$/), + v.transform((n) => +n), + v.minValue(10, 'too small') + ) + }), + async (data) => { + number = data.number; + get_number().refresh(); + } +); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index c2c9faae82fd..cb9ac233a97e 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1777,6 +1777,29 @@ test.describe('remote functions', () => { await expect(page.locator('[data-enhanced] input')).toHaveValue(''); }); + test('form preflight works', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/preflight'); + + for (const enhanced of [true, false]) { + const input = page.locator(enhanced ? '[data-enhanced] input' : '[data-default] input'); + const button = page.getByText(enhanced ? 'set enhanced number' : 'set number'); + + await input.fill('21'); + await button.click(); + await page.getByText('too big').waitFor(); + + await input.fill('9'); + await button.click(); + await page.getByText('too small').waitFor(); + + await input.fill('15'); + await button.click(); + await expect(page.getByText('number.current')).toHaveText('number.current: 15'); + } + }); + test('prerendered entries not called in prod', async ({ page, clicknav }) => { await page.goto('/remote/prerender'); await clicknav('[href="/remote/prerender/whole-page"]'); From 5d3bed14d279e6b7fd789004130412275789d110 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Sep 2025 19:49:07 -0400 Subject: [PATCH 34/64] WIP validate method --- packages/kit/src/exports/public.d.ts | 2 ++ .../kit/src/runtime/app/server/remote/form.js | 6 +++++ .../client/remote-functions/form.svelte.js | 22 ++++++++++++++----- .../routes/remote/form/validate/+page.svelte | 19 ++++++++++++++++ .../remote/form/validate/form.remote.ts | 12 ++++++++++ packages/kit/types/index.d.ts | 2 ++ 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 5f2b300f1332..838e14f338e4 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1795,6 +1795,8 @@ export type RemoteForm = { for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ preflight(schema: StandardSchemaV1): RemoteForm; + /** Validate the form contents programmatically */ + validate(options?: { includeUntouched?: boolean }): Promise; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 45b3c97f6e75..e0d4ccb4c44f 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -187,6 +187,12 @@ export function form(validate_or_fn, maybe_fn) { value: () => instance }); + Object.defineProperty(instance, 'validate', { + value: () => { + throw new Error('Cannot call validate() on the server'); + } + }); + if (key == undefined) { Object.defineProperty(instance, 'for', { /** @type {RemoteForm['for']} */ diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index eee821032387..336aed4bf7e5 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -50,6 +50,9 @@ export function form(id) { /** @type {StandardSchemaV1 | undefined} */ let preflight_schema = undefined; + /** @type {HTMLFormElement | null} */ + let element = null; + /** * @param {HTMLFormElement} form * @param {FormData} form_data @@ -217,12 +220,10 @@ export function form(id) { }; }; - let attached = false; - /** @param {(event: SubmitEvent) => void} onsubmit */ function create_attachment(onsubmit) { return (/** @type {HTMLFormElement} */ form) => { - if (attached) { + if (element) { let message = `A form object can only be attached to a single \`
\` element`; if (!key) { const name = id.split('/').pop(); @@ -232,7 +233,7 @@ export function form(id) { throw new Error(message); } - attached = true; + element = form; form.addEventListener('submit', onsubmit); @@ -258,7 +259,7 @@ export function form(id) { }); return () => { - attached = false; + element = null; }; }; } @@ -348,6 +349,17 @@ export function form(id) { return instance; } }, + validate: { + /** @type {RemoteForm['validate']} */ + value: async ({ includeUntouched = false } = {}) => { + if (!element) return; + + const form_data = new FormData(element); + const data = convert_formdata(form_data); + + // TODO make validation request + } + }, enhance: { /** @type {RemoteForm['enhance']} */ value: (callback) => { diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte new file mode 100644 index 000000000000..2668017e45a2 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte @@ -0,0 +1,19 @@ + + + my_form.validate()}> + {#if my_form.issues.foo} +

{my_form.issues.foo[0].message}

+ {/if} + + + + {#if my_form.issues.bar} +

{my_form.issues.bar[0].message}

+ {/if} + + + + + diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts new file mode 100644 index 000000000000..c24ed501afc9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts @@ -0,0 +1,12 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const my_form = form( + v.object({ + foo: v.picklist(['a', 'b', 'c']), + bar: v.picklist(['d', 'e', 'f']) + }), + async (data) => { + console.log(data); + } +); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index ce17d5d5521d..a7dfa3c418f6 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1771,6 +1771,8 @@ declare module '@sveltejs/kit' { for(key: string | number | boolean): Omit, 'for'>; /** Preflight checks */ preflight(schema: StandardSchemaV1): RemoteForm; + /** Validate the form contents programmatically */ + validate(options?: { includeUntouched?: boolean }): Promise; /** The result of the form submission */ get result(): Output | undefined; /** The number of pending submissions */ From 07ae0152473d9da4b3ae53369e686fc02d22f4db Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 12 Sep 2025 13:25:47 -0400 Subject: [PATCH 35/64] programmatic validation --- .../kit/src/runtime/app/server/remote/form.js | 7 ++ .../client/remote-functions/form.svelte.js | 79 ++++++++++++++++--- .../routes/remote/form/validate/+page.svelte | 8 +- packages/kit/test/apps/basics/test/test.js | 20 +++++ 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index e0d4ccb4c44f..4116f28482c7 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -96,6 +96,9 @@ export function form(validate_or_fn, maybe_fn) { id: '', /** @param {FormData} form_data */ fn: async (form_data) => { + const validate_only = form_data.get('sveltekit:validate_only') === 'true'; + form_data.delete('sveltekit:validate_only'); + let data = maybe_fn ? convert_formdata(form_data) : undefined; /** @type {{ input?: Record, issues?: Record, result: Output }} */ @@ -104,6 +107,10 @@ export function form(validate_or_fn, maybe_fn) { const { event, state } = get_request_store(); const validated = await schema?.['~standard'].validate(data); + if (validate_only) { + return validated?.issues ?? []; + } + if (validated?.issues !== undefined) { output.issues = flatten_issues(validated.issues); output.input = {}; diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 336aed4bf7e5..a269c05a7681 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -53,6 +53,9 @@ export function form(id) { /** @type {HTMLFormElement | null} */ let element = null; + /** @type {Record} */ + let touched = {}; + /** * @param {HTMLFormElement} form * @param {FormData} form_data @@ -108,13 +111,6 @@ export function form(id) { await Promise.resolve(); if (updates.length > 0) { - if (DEV) { - if (data.get('sveltekit:remote_refreshes')) { - throw new Error( - 'The FormData key `sveltekit:remote_refreshes` is reserved for internal use and should not be set manually' - ); - } - } data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key))); } @@ -235,6 +231,8 @@ export function form(id) { element = form; + touched = {}; + form.addEventListener('submit', onsubmit); form.addEventListener('input', (e) => { @@ -247,6 +245,8 @@ export function form(id) { const is_array = name.endsWith('[]'); if (is_array) name = name.slice(0, -2); + touched[name] = true; + (input ??= {})[name] = is_array ? Array.from( document.querySelectorAll(`[name="${name}[]"]`), @@ -323,6 +323,8 @@ export function form(id) { get: () => pending_count }); + let validate_id = 0; + Object.defineProperties(instance, { buttonProps: { value: button_props @@ -354,10 +356,61 @@ export function form(id) { value: async ({ includeUntouched = false } = {}) => { if (!element) return; + const id = ++validate_id; + const form_data = new FormData(element); - const data = convert_formdata(form_data); - // TODO make validation request + /** @type {readonly StandardSchemaV1.Issue[]} */ + let array = []; + + const validated = await preflight_schema?.['~standard'].validate( + convert_formdata(form_data) + ); + + if (validated?.issues) { + array = validated.issues; + } else { + form_data.set('sveltekit:validate_only', 'true'); + + const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, { + method: 'POST', + body: form_data + }); + + const result = await response.json(); + + if (result.type === 'result') { + let array = /** @type {StandardSchemaV1.Issue[]} */ ( + devalue.parse(result.result, app.decoders) + ); + } + } + + if (!includeUntouched) { + 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]; + } + }); + } + + issues = flatten_issues(array); + + if (validate_id !== id) { + return; + } } }, enhance: { @@ -424,6 +477,14 @@ function clone(element) { * @param {string} enctype */ function validate_form_data(form_data, enctype) { + for (const key of form_data.keys()) { + if (key.startsWith('sveltekit:')) { + throw new Error( + 'FormData keys starting with `sveltekit:` are reserved for internal use and should not be set manually' + ); + } + } + if (enctype !== 'multipart/form-data') { for (const value of form_data.values()) { if (value instanceof File) { diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte index 2668017e45a2..39cc50b2dcfa 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte @@ -1,8 +1,14 @@ -
my_form.validate()}> + my_form.validate()}> {#if my_form.issues.foo}

{my_form.issues.foo[0].message}

{/if} diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index cb9ac233a97e..8345672ebb14 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1800,6 +1800,26 @@ test.describe('remote functions', () => { } }); + test('form validate works', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/validate'); + + const foo = page.locator('input[name="foo"]'); + const bar = page.locator('input[name="bar"]'); + + await foo.fill('a'); + await expect(page.locator('form')).not.toContainText('Invalid type: Expected'); + + await bar.fill('g'); + await expect(page.locator('form')).toContainText( + 'Invalid type: Expected ("d" | "e") but received "g"' + ); + + await bar.fill('d'); + await expect(page.locator('form')).not.toContainText('Invalid type: Expected'); + }); + test('prerendered entries not called in prod', async ({ page, clicknav }) => { await page.goto('/remote/prerender'); await clicknav('[href="/remote/prerender/whole-page"]'); From 24c099bc2be4af611d1f3a8d351dbeafcbbf31e0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 12 Sep 2025 13:45:39 -0400 Subject: [PATCH 36/64] fix/tidy --- packages/kit/src/exports/public.d.ts | 1 - packages/kit/src/runtime/app/server/remote/form.js | 2 +- packages/kit/src/runtime/client/remote-functions/form.svelte.js | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 838e14f338e4..7bd4d10dd6dc 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1763,7 +1763,6 @@ export type RemoteForm = { method: 'POST'; /** The URL to send the form to. */ action: string; - onsubmit: (event: SubmitEvent) => void; /** Use the `enhance` method to influence what happens when the form is submitted. */ enhance( callback: (opts: { diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 4116f28482c7..7e62ed11061f 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -70,7 +70,7 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, 'enhance', { value: () => { - return { action: instance.action, method: instance.method, onsubmit: instance.onsubmit }; + return { action: instance.action, method: instance.method }; } }); diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index a269c05a7681..d6b375a0a975 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -380,7 +380,7 @@ export function form(id) { const result = await response.json(); if (result.type === 'result') { - let array = /** @type {StandardSchemaV1.Issue[]} */ ( + array = /** @type {StandardSchemaV1.Issue[]} */ ( devalue.parse(result.result, app.decoders) ); } From aa6b952a1de35875a8d78d5da7f8228b77156b4a Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:25:29 -0700 Subject: [PATCH 37/64] generate types --- packages/kit/types/index.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index a7dfa3c418f6..0a43cc801da3 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1739,7 +1739,6 @@ declare module '@sveltejs/kit' { method: 'POST'; /** The URL to send the form to. */ action: string; - onsubmit: (event: SubmitEvent) => void; /** Use the `enhance` method to influence what happens when the form is submitted. */ enhance( callback: (opts: { From 4242c1d9497082d3d8f97d1baa897a5c281a69a4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 13 Sep 2025 14:42:38 -0400 Subject: [PATCH 38/64] docs --- .../20-core-concepts/60-remote-functions.md | 173 +++++++++++++++--- 1 file changed, 143 insertions(+), 30 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 4af669e42ed1..50b952e6a1e7 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -227,7 +227,7 @@ export const getWeather = query.batch(v.string(), async (cities) => { ## form -The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... +The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... ```ts @@ -259,30 +259,28 @@ export const getPosts = query(async () => { /* ... */ }); export const getPost = query(v.string(), async (slug) => { /* ... */ }); -export const createPost = form(async (data) => { - // Check the user is logged in - const user = await auth.getUser(); - if (!user) error(401, 'Unauthorized'); - - const title = data.get('title'); - const content = data.get('content'); - - // Check the data is valid - if (typeof title !== 'string' || typeof content !== 'string') { - error(400, 'Title and content are required'); +export const createPost = form( + v.object({ + title: v.pipe(v.string(), v.nonEmpty()), + content:v.pipe(v.string(), v.nonEmpty()) + }), + async ({ title, content }) => { + // Check the user is logged in + const user = await auth.getUser(); + if (!user) error(401, 'Unauthorized'); + + const slug = title.toLowerCase().replace(/ /g, '-'); + + // Insert into the database + await db.sql` + INSERT INTO post (slug, title, content) + VALUES (${slug}, ${title}, ${content}) + `; + + // Redirect to the newly created page + redirect(303, `/blog/${slug}`); } - - const slug = title.toLowerCase().replace(/ /g, '-'); - - // Insert into the database - await db.sql` - INSERT INTO post (slug, title, content) - VALUES (${slug}, ${title}, ${content}) - `; - - // Redirect to the newly created page - redirect(303, `/blog/${slug}`); -}); +); ``` ...and returns an object that can be spread onto a `` element. The callback is called whenever the form is submitted. @@ -310,7 +308,122 @@ export const createPost = form(async (data) => { ``` -The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an `onsubmit` handler that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page. +As with `query`, if the callback uses the submitted `data`, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `form`. + +The `name` attributes on the form controls must correspond to the properties of the schema — `title` and `content` in this case. If you schema contains objects, you can use object notation... + +```svelte + + + +``` + +...and if you can indicate a repeated field with a `[]` suffix: + +```svelte + + + +``` + +If you'd like type safety and autocomplete when setting `name` attributes, you can use the form object's `field` method: + +```diff + +``` + +This will error during typechecking if `title` does not exist on your schema. + +The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an [attachment](/docs/svelte/@attach) that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page. + +### Validation + +If the submitted data doesn't pass the schema, the callback will not run. Instead, the form object's `issues` object will be populated: + +```diff +
+ + + + + +
+``` + +You don't need to wait until the form is submitted to validate the data — you can call `validate()` programmatically, for example in an `oninput` callback (which will validate the data on every keystroke) or an `onchange` callback: + +```svelte +
createPost.validate()}> + +
+``` + +By default, issues will be ignored if they belong to form controls that haven't yet been interacted with. To validate _all_ inputs, call `validate({ includeUntouched: true })`. + +For client-side validation, you can specify a _preflight_ schema which will populate `issues` and prevent data being sent to the server if the data doesn't validate: + +```svelte + + +

Create a new post

+ +
+ +
+``` + +> [!NOTE] The preflight schema can be the same object as your server-side schema, if appropriate, though it won't be able to do server-side checks like 'this value already exists in the database'. Note that you cannot export a schema from a `.remote.ts` or `.remote.js` file, so the schema must either be exported from a shared module, or from a ` + +
+ + + + + +
+ +
{JSON.stringify(register.issues, null, '  ')}
+ + diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts new file mode 100644 index 000000000000..ed6f2bff3e8a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts @@ -0,0 +1,10 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const register = form( + v.object({ + username: v.pipe(v.string(), v.minLength(8)), + _password: v.pipe(v.string(), v.minLength(8)) + }), + async (data) => {} +); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 5d6261a07991..da7aec216f71 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1818,6 +1818,19 @@ test.describe('remote functions', () => { await expect(page.locator('form')).not.toContainText('Invalid type: Expected'); }); + test('form inputs excludes underscore-prefixed fields', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) return; + + await page.goto('/remote/form/underscore'); + + await page.fill('input[name="username"]', 'abcdefg'); + await page.fill('input[name="_password"]', 'pqrstuv'); + await page.locator('button').click(); + + await expect(page.locator('input[name="username"]')).toHaveValue('abcdefg'); + await expect(page.locator('input[name="_password"]')).toHaveValue(''); + }); + test('prerendered entries not called in prod', async ({ page, clicknav }) => { await page.goto('/remote/prerender'); await clicknav('[href="/remote/prerender/whole-page"]'); From 70106599d2b51588238a18c15db182cdba8c7b9f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 16 Sep 2025 15:04:02 -0400 Subject: [PATCH 60/64] document underscore --- .../20-core-concepts/60-remote-functions.md | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 143f20db93b1..479e789089fc 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -331,7 +331,7 @@ export const setCount = form( The `name` attributes on the form controls must correspond to the properties of the schema — `title` and `content` in this case. If you schema contains objects, use object notation: ```svelte -