diff --git a/.changeset/salty-doors-feel.md b/.changeset/salty-doors-feel.md new file mode 100644 index 000000000000..659bf06179b8 --- /dev/null +++ b/.changeset/salty-doors-feel.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add programmatic `submit` method to form instances diff --git a/.changeset/sweet-lamps-invite.md b/.changeset/sweet-lamps-invite.md new file mode 100644 index 000000000000..95efe4f261f3 --- /dev/null +++ b/.changeset/sweet-lamps-invite.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: pass form instance into `enhance` callback diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 7b60d7181e86..dedd67c34abd 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -711,10 +711,10 @@ We can customize what happens when the form is submitted with the `enhance` meth

Create a new post

-
{ + { try { - if (await submit()) { - form.reset(); + if (await form.submit()) { + form.element.reset(); showToast('Successfully published!'); } else { @@ -728,9 +728,9 @@ We can customize what happens when the form is submitted with the `enhance` meth
``` -> When using `enhance`, the `
` is not automatically reset — you must call `form.reset()` if you want to clear the inputs. +> When using `enhance`, the `` is not automatically reset — you must call `form.element.reset()` if you want to clear the inputs. -The callback receives the `form` element, the `data` it contains, and a `submit` function. +The callback receives a copy of the form instance. It has all the same properties and methods except `enhance`, and `form.submit()` performs the submission directly without re-running the enhance callback. Inside the callback, `form.element` is always defined. ### Multiple instances of a form diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 60a50033add7..81906a4658b1 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2077,15 +2077,19 @@ export type RemoteForm = { method: 'POST'; /** The URL to send the form to. */ action: string; + /** The `` element this instance is currently attached to, if any. */ + get element(): HTMLFormElement | null; + /** Submit the currently attached form programmatically. */ + submit(): Promise & { + updates: (...updates: RemoteQueryUpdate[]) => Promise; + }; /** Use the `enhance` method to influence what happens when the form is submitted. */ enhance( - callback: (opts: { - form: HTMLFormElement; - data: Input; - submit: () => Promise & { - updates: (...updates: RemoteQueryUpdate[]) => Promise; - }; - }) => void + callback: ( + form: Omit, 'enhance' | 'element'> & { + readonly element: HTMLFormElement; + } + ) => void ): { method: 'POST'; action: string; @@ -2187,9 +2191,9 @@ export type RemoteQuery = RemoteResource & { * const todos = getTodos(); * * - * { - * await submit().updates( - * todos.withOverride((todos) => [...todos, { text: data.get('text') }]) + * { + * await form.submit().updates( + * todos.withOverride((todos) => [...todos, { text: form.fields.text.value() }]) * ); * })}> * diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 1e9c8f012872..df769eaea115 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -242,6 +242,16 @@ export function form(validate_or_fn, maybe_fn) { } }); + Object.defineProperty(instance, 'submit', { + value: () => { + throw new Error('Cannot call submit() on the server'); + } + }); + + Object.defineProperty(instance, 'element', { + get: () => null + }); + 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 1af3bffdcc6a..ba3c028a4820 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -79,6 +79,15 @@ export function form(id) { /** @type {StandardSchemaV1 | undefined} */ let preflight_schema = undefined; + /** + * @param {Omit, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} instance + */ + let enhance_callback = async (instance) => { + if (await instance.submit()) { + instance.element.reset(); + } + }; + /** @type {HTMLFormElement | null} */ let element = null; @@ -100,82 +109,11 @@ export function form(id) { } /** - * @param {HTMLFormElement} form * @param {FormData} form_data - * @param {Parameters['enhance']>[0]} callback - */ - async function handle_submit(form, form_data, callback) { - const data = convert(form_data); - - submitted = true; - - // Increment pending count immediately so that `pending` reflects - // the in-progress state during async preflight validation - pending_count++; - - const validated = await preflight_schema?.['~standard'].validate(data); - - if (validated?.issues) { - raw_issues = merge_with_server_issues( - form_data, - raw_issues, - validated.issues.map((issue) => normalize_issue(issue, false)) - ); - pending_count--; - return; - } - - // Preflight passed - clear stale client-side preflight issues - if (preflight_schema) { - raw_issues = raw_issues.filter((issue) => issue.server); - } - - // TODO 3.0 remove this warning - if (DEV) { - const error = () => { - throw new Error( - 'Remote form functions no longer get passed a FormData object. The payload is now a POJO. See https://kit.svelte.dev/docs/remote-functions#form for details.' - ); - }; - for (const key of [ - 'append', - 'delete', - 'entries', - 'forEach', - 'get', - 'getAll', - 'has', - 'keys', - 'set', - 'values' - ]) { - if (!(key in data)) { - Object.defineProperty(data, key, { get: error }); - } - } - } - - try { - // eslint-disable-next-line @typescript-eslint/await-thenable -- `callback` is typed as returning `void` to allow returning e.g. `Promise` - 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); - } finally { - pending_count--; - } - } - - /** - * @param {FormData} data + * @param {boolean} should_preflight * @returns {Promise & { updates: (...args: any[]) => Promise }} */ - function submit(data) { + function submit(form_data, should_preflight) { // Store a reference to the current instance and increment the usage count for the duration // of the request. This ensures that the instance is not deleted in case of an optimistic update // (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards. @@ -203,7 +141,12 @@ export function form(id) { throw updates_error; } - const { blob } = serialize_binary_form(convert(data), { + if (should_preflight) { + const valid = await preflight(form_data); + if (!valid) return false; + } + + const { blob } = serialize_binary_form(convert(form_data), { remote_refreshes: Array.from(refreshes ?? []) }); @@ -292,16 +235,94 @@ export function form(id) { return promise; } + /** + * @param {HTMLFormElement} form + * @param {FormData} form_data + * @returns {Omit, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} + */ + function create_enhance_callback_instance(form, form_data) { + const { enhance: _enhance, ...descriptors } = Object.getOwnPropertyDescriptors(instance); + void _enhance; + + return /** @type {Omit, 'enhance' | 'element'> & { readonly element: HTMLFormElement }} */ ( + Object.defineProperties( + {}, + { + ...descriptors, + data: { + get() { + // TODO 3.0 remove + throw new Error( + `The \`data\` property has been removed from the \`enhance\` callback argument. Use \`instance.fields.value()\` instead.` + ); + } + }, + form: { + get() { + // TODO 3.0 remove + throw new Error( + `The \`form\` property has been removed from the \`enhance\` callback argument. To get the current \`\` element, use \`instance.element\` instead.` + ); + } + }, + element: { + value: form + }, + submit: { + value: () => submit(form_data, false) + } + } + ) + ); + } + + /** + * @param {FormData} form_data + */ + async function preflight(form_data) { + const data = convert(form_data); + const validated = await preflight_schema?.['~standard'].validate(data); + + if (validated?.issues) { + raw_issues = merge_with_server_issues( + form_data, + raw_issues, + validated.issues.map((issue) => normalize_issue(issue, false)) + ); + return false; + } + + // Preflight passed - clear stale client-side preflight issues + if (preflight_schema) { + raw_issues = raw_issues.filter((issue) => issue.server); + } + + return true; + } + /** @type {RemoteForm} */ const instance = {}; instance.method = 'POST'; instance.action = action; - /** @param {Parameters['enhance']>[0]} callback */ - const form_onsubmit = (callback) => { + instance[createAttachmentKey()] = (/** @type {HTMLFormElement} */ form) => { + if (element) { + let message = `A form object can only be attached to a single \`\` element`; + if (DEV && !key) { + const name = id.split('/').pop(); + message += `. To create multiple instances, use \`${name}.for(key)\``; + } + + throw new Error(message); + } + + element = form; + + touched = {}; + /** @param {SubmitEvent} event */ - return async (event) => { + const handle_submit = async (event) => { const form = /** @type {HTMLFormElement} */ (event.target); const method = event.submitter?.hasAttribute('formmethod') ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod @@ -336,142 +357,130 @@ export function form(id) { validate_form_data(form_data, clone(form).enctype); } - await handle_submit(form, form_data, callback); - }; - }; - - /** @param {(event: SubmitEvent) => void} onsubmit */ - function create_attachment(onsubmit) { - return (/** @type {HTMLFormElement} */ form) => { - if (element) { - let message = `A form object can only be attached to a single \`\` element`; - if (DEV && !key) { - const name = id.split('/').pop(); - message += `. To create multiple instances, use \`${name}.for(key)\``; - } - - throw new Error(message); - } + submitted = true; - element = form; + try { + // Increment pending count immediately so that `pending` reflects + // the in-progress state during async preflight validation + pending_count++; - touched = {}; + const valid = await preflight(form_data); + if (!valid) return; - form.addEventListener('submit', onsubmit); + // eslint-disable-next-line @typescript-eslint/await-thenable -- `callback` is typed as returning `void` to allow returning e.g. `Promise` + await enhance_callback(create_enhance_callback_instance(form, 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); + } finally { + pending_count--; + } + }; - /** @param {Event} e */ - const handle_input = (e) => { - // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement - // but that makes the types unnecessarily awkward - const element = /** @type {HTMLInputElement} */ (e.target); + /** @param {Event} e */ + const handle_input = (e) => { + // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement + // but that makes the types unnecessarily awkward + const element = /** @type {HTMLInputElement} */ (e.target); - let name = element.name; - if (!name) return; + let name = element.name; + if (!name) return; - const is_array = name.endsWith('[]'); - if (is_array) name = name.slice(0, -2); + const is_array = name.endsWith('[]'); + if (is_array) name = name.slice(0, -2); - const is_file = element.type === 'file'; + const is_file = element.type === 'file'; - touched[name] = true; + touched[name] = true; - if (is_array) { - let value; + if (is_array) { + let value; - if (element.tagName === 'SELECT') { - value = Array.from( - element.querySelectorAll('option:checked'), - (e) => /** @type {HTMLOptionElement} */ (e).value - ); - } else { - const elements = /** @type {HTMLInputElement[]} */ ( - Array.from(form.querySelectorAll(`[name="${name}[]"]`)) - ); + if (element.tagName === 'SELECT') { + value = Array.from( + element.querySelectorAll('option:checked'), + (e) => /** @type {HTMLOptionElement} */ (e).value + ); + } else { + const elements = /** @type {HTMLInputElement[]} */ ( + Array.from(form.querySelectorAll(`[name="${name}[]"]`)) + ); - if (DEV) { - for (const e of elements) { - if ((e.type === 'file') !== is_file) { - throw new Error( - `Cannot mix and match file and non-file inputs under the same name ("${element.name}")` - ); - } + if (DEV) { + for (const e of elements) { + if ((e.type === 'file') !== is_file) { + throw new Error( + `Cannot mix and match file and non-file inputs under the same name ("${element.name}")` + ); } } - - value = is_file - ? elements.map((input) => Array.from(input.files ?? [])).flat() - : elements.map((element) => element.value); - if (element.type === 'checkbox') { - value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked)); - } } - set_nested_value(input, name, value); - } else if (is_file) { - if (DEV && element.multiple) { - throw new Error( - `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` - ); + value = is_file + ? elements.map((input) => Array.from(input.files ?? [])).flat() + : elements.map((element) => element.value); + if (element.type === 'checkbox') { + value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked)); } + } - const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; - - if (file) { - set_nested_value(input, name, file); - } else { - // Remove the property by setting to undefined and clean up - const path_parts = name.split(/\.|\[|\]/).filter(Boolean); - let current = /** @type {any} */ (input); - for (let i = 0; i < path_parts.length - 1; i++) { - if (current[path_parts[i]] == null) return; - current = current[path_parts[i]]; - } - delete current[path_parts[path_parts.length - 1]]; - } - } else { - set_nested_value( - input, - name, - element.type === 'checkbox' && !element.checked ? null : element.value + set_nested_value(input, name, value); + } else if (is_file) { + if (DEV && element.multiple) { + throw new Error( + `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` ); } - name = name.replace(/^[nb]:/, ''); - - touched[name] = true; - }; + const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; - form.addEventListener('input', handle_input); + if (file) { + set_nested_value(input, name, file); + } else { + // Remove the property by setting to undefined and clean up + const path_parts = name.split(/\.|\[|\]/).filter(Boolean); + let current = /** @type {any} */ (input); + for (let i = 0; i < path_parts.length - 1; i++) { + if (current[path_parts[i]] == null) return; + current = current[path_parts[i]]; + } + delete current[path_parts[path_parts.length - 1]]; + } + } else { + set_nested_value( + input, + name, + element.type === 'checkbox' && !element.checked ? null : element.value + ); + } - const handle_reset = async () => { - // need to wait a moment, because the `reset` event occurs before - // the inputs are actually updated (so that it can be cancelled) - await tick(); + name = name.replace(/^[nb]:/, ''); - input = convert_formdata(new FormData(form)); - }; + touched[name] = true; + }; - form.addEventListener('reset', handle_reset); + const handle_reset = async () => { + // need to wait a moment, because the `reset` event occurs before + // the inputs are actually updated (so that it can be cancelled) + await tick(); - return () => { - form.removeEventListener('submit', onsubmit); - form.removeEventListener('input', handle_input); - form.removeEventListener('reset', handle_reset); - element = null; - preflight_schema = undefined; - }; + input = convert_formdata(new FormData(form)); }; - } - instance[createAttachmentKey()] = create_attachment( - form_onsubmit(({ submit, form }) => - submit().then((succeeded) => { - if (succeeded) { - form.reset(); - } - }) - ) - ); + form.addEventListener('submit', handle_submit); + form.addEventListener('input', handle_input); + form.addEventListener('reset', handle_reset); + + return () => { + form.removeEventListener('submit', handle_submit); + form.removeEventListener('input', handle_input); + form.removeEventListener('reset', handle_reset); + element = null; + preflight_schema = undefined; + }; + }; let validate_id = 0; @@ -490,6 +499,37 @@ export function form(id) { } Object.defineProperties(instance, { + element: { + get: () => element + }, + submit: { + value: () => { + if (!element) { + throw new Error('Cannot call submit() before the form is attached'); + } + + const default_submitter = /** @type {HTMLElement | undefined} */ ( + element.querySelector('button:not([type]), [type="submit"]') + ); + + const form_data = new FormData(element, default_submitter); + + if (DEV) { + validate_form_data(form_data, clone(element).enctype); + } + + submitted = true; + pending_count++; + + const submission = submit(form_data, true); + + void submission.finally(() => { + pending_count--; + }); + + return submission; + } + }, fields: { get: () => create_field_proxy( @@ -589,13 +629,12 @@ export function form(id) { } }, enhance: { - /** @type {RemoteForm['enhance']} */ + /** + * @param {(instance: Omit, 'enhance' | 'element'> & { readonly element: HTMLFormElement }) => any} callback + */ value: (callback) => { - return { - method: 'POST', - action, - [createAttachmentKey()]: create_attachment(form_onsubmit(callback)) - }; + enhance_callback = callback; + return instance; } } }); diff --git a/packages/kit/test/apps/async/src/routes/remote/form/[test_name]/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/form/[test_name]/+page.svelte index 7ae51a6566d9..887674e60635 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/[test_name]/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/form/[test_name]/+page.svelte @@ -9,6 +9,9 @@ const enhanced = set_message.for(`enhanced:${params.test_name}`); let submit_result = $state('none'); + let imperative_submit_result = $state('none'); + let callback_element_matches = $state('unknown'); + let callback_has_enhance = $state('unknown');

message.current: {message.current}

@@ -53,9 +56,14 @@ { + {...enhanced.enhance(async (form) => { + const instance = /** @type {any} */ (form); + callback_element_matches = String(instance.element === /** @type {any} */ (enhanced).element); + callback_has_enhance = String('enhance' in instance); submit_result = String( - await submit().updates(message.withOverride(() => data.message + ' (override)')) + await instance + .submit() + .updates(message.withOverride(() => instance.fields.message.value() + ' (override)')) ); })} > @@ -72,6 +80,19 @@

enhanced.pending: {enhanced.pending}

enhanced.result: {enhanced.result}

enhanced.submit_result: {submit_result}

+

enhanced.element: {/** @type {any} */ (enhanced).element ? 'attached' : 'null'}

+

enhanced.callback_element_matches: {callback_element_matches}

+

enhanced.callback_has_enhance: {callback_has_enhance}

+

enhanced.imperative_submit_result: {imperative_submit_result}

+ +
diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 1099624a885b..9faec1bca25f 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -266,6 +266,7 @@ test.describe('remote functions', () => { if (javaScriptEnabled) { await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 1'); + await expect(page.getByText('enhanced.element:')).toHaveText('enhanced.element: attached'); await page.getByText('message.current: hello (override)').waitFor(); @@ -275,6 +276,12 @@ test.describe('remote functions', () => { // enhanced submission should not clear the input; the developer must do that at the appropriate time await expect(page.locator('[data-enhanced] input[name="message"]')).toHaveValue('hello'); + await expect(page.getByText('enhanced.callback_element_matches:')).toHaveText( + 'enhanced.callback_element_matches: true' + ); + await expect(page.getByText('enhanced.callback_has_enhance:')).toHaveText( + 'enhanced.callback_has_enhance: false' + ); } else { await expect(page.locator('[data-enhanced] input[name="message"]')).toHaveValue(''); } @@ -307,6 +314,29 @@ test.describe('remote functions', () => { ); }); + test('form submit() enables programmatic submission', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/enhanced'); + + await expect(page.getByText('enhanced.imperative_submit_result:')).toHaveText( + 'enhanced.imperative_submit_result: none' + ); + + await page.fill('[data-enhanced] input', 'hello'); + await page.getByText('submit enhanced programmatically').click(); + + await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 1'); + + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('enhanced.imperative_submit_result:')).toHaveText( + 'enhanced.imperative_submit_result: true' + ); + await expect(page.getByText('enhanced.result:')).toHaveText( + 'enhanced.result: hello (from: enhanced:enhanced)' + ); + }); + test('form preflight works', async ({ page, javaScriptEnabled }) => { if (!javaScriptEnabled) return; diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 33be713459ed..63e769c1981f 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -246,17 +246,24 @@ function form_tests() { f.result?.success === true; - f.enhance(async ({ submit }) => { - const x: boolean = await submit(); + f.enhance(async (form) => { + const x: boolean = await form.submit(); x; - const y: boolean = await submit().updates( + const y: boolean = await form.submit().updates( q, q(), q().withOverride(() => '') ); y; + const element: HTMLFormElement = form.element; + element; + // @ts-expect-error + form.enhance(() => {}); }); + const element: HTMLFormElement | null = f.element; + element; + const f2 = form( null as any as StandardSchemaV1<{ a: string; nested: { prop: string } }>, (data, issue) => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d23a0a4b7380..a49e8fca1880 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2051,15 +2051,19 @@ declare module '@sveltejs/kit' { method: 'POST'; /** The URL to send the form to. */ action: string; + /** The `` element this instance is currently attached to, if any. */ + get element(): HTMLFormElement | null; + /** Submit the currently attached form programmatically. */ + submit(): Promise & { + updates: (...updates: RemoteQueryUpdate[]) => Promise; + }; /** Use the `enhance` method to influence what happens when the form is submitted. */ enhance( - callback: (opts: { - form: HTMLFormElement; - data: Input; - submit: () => Promise & { - updates: (...updates: RemoteQueryUpdate[]) => Promise; - }; - }) => void + callback: ( + form: Omit, 'enhance' | 'element'> & { + readonly element: HTMLFormElement; + } + ) => void ): { method: 'POST'; action: string; @@ -2161,9 +2165,9 @@ declare module '@sveltejs/kit' { * const todos = getTodos(); * * - * { - * await submit().updates( - * todos.withOverride((todos) => [...todos, { text: data.get('text') }]) + * { + * await form.submit().updates( + * todos.withOverride((todos) => [...todos, { text: form.fields.text.value() }]) * ); * })}> *