diff --git a/.changeset/all-symbols-hammer.md b/.changeset/all-symbols-hammer.md new file mode 100644 index 000000000000..0e9d0d649e89 --- /dev/null +++ b/.changeset/all-symbols-hammer.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: remote form factory diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index bcbb3e9c1570..b0c6f89bf753 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -292,7 +292,7 @@ export const createPost = form(

Create a new post

-
+ @@ -308,15 +308,15 @@ As with `query`, if the callback uses the submitted `data`, it should be [valida A form is composed of a set of _fields_, which are defined by the schema. In the case of `createPost`, we have two fields, `title` and `content`, which are both strings. To get the attributes for a field, call its `.as(...)` method, specifying which [input type](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types) to use: ```svelte - + @@ -353,10 +353,10 @@ export const createProfile = form(datingProfile, (data) => { /* ... */ }); - + @@ -403,12 +403,12 @@ export const survey = form( ``` ```svelte - +

Which operating system do you use?

{#each ['windows', 'mac', 'linux'] as os} {/each} @@ -417,7 +417,7 @@ export const survey = form( {#each ['html', 'css', 'js'] as language} {/each} @@ -429,10 +429,10 @@ export const survey = form( Alternatively, you could use `select` and `select multiple`: ```svelte - +

Which operating system do you use?

- @@ -440,7 +440,7 @@ Alternatively, you could use `select` and `select multiple`:

Which languages do you write code in?

- @@ -494,25 +494,25 @@ The `invalid` function works as both a function and a proxy: If the submitted data doesn't pass the schema, the callback will not run. Instead, each invalid field's `issues()` method will return an array of `{ message: string }` objects, and the `aria-invalid` attribute (returned from `as(...)`) will be set to `true`: ```svelte - + @@ -522,7 +522,7 @@ If the submitted data doesn't pass the schema, the callback will not run. Instea 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()}> + form.validate()}>
``` @@ -540,11 +540,15 @@ For client-side validation, you can specify a _preflight_ schema which will popu title: v.pipe(v.string(), v.nonEmpty()), content: v.pipe(v.string(), v.nonEmpty()) }); + + const form = createPost(+++{ + preflight: schema + }+++)

Create a new post

-
+
``` @@ -554,7 +558,7 @@ For client-side validation, you can specify a _preflight_ schema which will popu To get a list of _all_ issues, rather than just those belonging to a single field, you can use the `fields.allIssues()` method: ```svelte -{#each createPost.fields.allIssues() as issue} +{#each createPost().fields.allIssues() as issue}

{issue.message}

{/each} ``` @@ -564,17 +568,17 @@ To get a list of _all_ issues, rather than just those belonging to a single fiel Each field has a `value()` method that reflects its current value. As the user interacts with the form, it is automatically updated: ```svelte -
+
-

{createPost.fields.title.value()}

-
{@html render(createPost.fields.content.value())}
+

{createPost().fields.title.value()}

+
{@html render(createPost().fields.content.value())}
``` -Alternatively, `createPost.fields.value()` would return a `{ title, content }` object. +Alternatively, `createPost().fields.value()` would return a `{ title, content }` object. You can update a field (or a collection of fields) via the `set(...)` method: @@ -583,14 +587,14 @@ You can update a field (or a collection of fields) via the `set(...)` method: import { createPost } from '../data.remote'; // this... - createPost.fields.set({ + createPost().fields.set({ title: 'My new blog post', content: 'Lorem ipsum dolor sit amet...' }); // ...is equivalent to this: - createPost.fields.title.set('My new blog post'); - createPost.fields.content.set('Lorem ipsum dolor sit amet'); + createPost().fields.title.set('My new blog post'); + createPost().fields.content.set('Lorem ipsum dolor sit amet'); ``` @@ -601,15 +605,15 @@ In the case of a non-progressively-enhanced form submission (i.e. where JavaScri You can prevent sensitive data (such as passwords and credit card numbers) from being sent back to the user by using a name with a leading underscore: ```svelte -
+ @@ -668,7 +672,7 @@ The second is to drive the single-flight mutation from the client, which we'll s ### Returns and redirects -The example above uses [`redirect(...)`](@sveltejs-kit#redirect), which sends the user to the newly created page. Alternatively, the callback could return data, in which case it would be available as `createPost.result`: +The example above uses [`redirect(...)`](@sveltejs-kit#redirect), which sends the user to the newly created page. Alternatively, the callback could return data, in which case it would be available as `createPost().result`: ```ts /// file: src/routes/blog/data.remote.js @@ -717,11 +721,11 @@ export const createPost = form(

Create a new post

- +
-{#if createPost.result?.success} +{#if createPost().result?.success}

Successfully published!

{/if} ``` @@ -745,7 +749,7 @@ We can customize what happens when the form is submitted with the `enhance` meth

Create a new post

-
{ + { try { await submit(); form.reset(); @@ -798,7 +802,7 @@ The override will be applied immediately, and released when the submission compl ### Multiple instances of a form -Some forms may be repeated as part of a list. In this case you can create separate instances of a form function via `for(id)` to achieve isolation. +Some forms may be repeated as part of a list. In this case you can create separate instances of a form function via `form(id)` to achieve isolation. ```svelte @@ -809,14 +813,46 @@ Some forms may be repeated as part of a list. In this case you can create separa

Todos

{#each await getTodos() as todo} - {@const modify = modifyTodo.for(todo.id)} + {@const modify = modifyTodo(todo.id)} - +
{/each} ``` +### Initial form data + +There are times when you want a form to be pre-filled with certain values when it first renders. For example, you might want to populate a form with values fetched from the server or set default values for a new data entry. + +You can do this by passing the `initialData` option when creating a form instance. This will set the initial state of the form fields, both for their values and for client-side validation. + +Here's an example of how to set initial form data using `initialData`: + +```svelte + + + +
+ + +
+``` + +You can also pass a partial object to `initialData` if you only want to set values for some fields. If `initialData` is omitted, the fields will be empty by default. + ### buttonProps By default, submitting a form will send a request to the URL indicated by the `
` element's [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#attributes_for_form_submission) attribute, which in the case of a remote function is a property on the form object generated by SvelteKit. @@ -831,19 +867,19 @@ This attribute exists on the `buttonProps` property of a form object: import { login, register } from '$lib/auth'; - + - +
``` diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 1ef333310326..5edd130e0b80 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -15,7 +15,9 @@ import { PrerenderUnseenRoutesHandlerValue, PrerenderOption, RequestOptions, - RouteSegment + RouteSegment, + DeepPartial, + ExtractId } from '../types/private.js'; import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; import { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; @@ -1969,14 +1971,6 @@ export interface RemoteFormAllIssue extends RemoteFormIssue { path: Array; } -// If the schema specifies `id` as a string or number, ensure that `for(...)` -// only accepts that type. Otherwise, accept `string | number` -type ExtractId = Input extends { id: infer Id } - ? Id extends string | number - ? Id - : string | number - : string | number; - /** * Recursively maps an input type to a structure where each field can create a validation issue. * This mirrors the runtime behavior of the `invalid` proxy passed to form handlers. @@ -2007,9 +2001,27 @@ type InvalidField = export type Invalid = ((...issues: Array) => never) & InvalidField; +export type RemoteFormFactoryOptions = { + /** Optional key to create a scoped instance */ + key?: ExtractId; + /** Client-side preflight schema for validation before submit */ + preflight?: StandardSchemaV1; + /** Initial input values for the form fields */ + initialData?: DeepPartial; + /** Reset the form values after successful submission, for non-enhanced forms (default: true) */ + resetAfterSuccess?: boolean; +}; + /** * The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. */ +export type RemoteFormFactory = ( + keyOrOptions?: ExtractId | RemoteFormFactoryOptions +) => RemoteForm; + +/** + * The remote form instance created by the form factory function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. + */ 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; @@ -2030,23 +2042,6 @@ export type RemoteForm = { action: string; [attachment: symbol]: (node: HTMLFormElement) => void; }; - /** - * Create an instance of the form for the given `id`. - * The `id` is stringified and used for deduplication to potentially reuse existing instances. - * Useful when you have multiple forms that use the same remote form action, for example in a loop. - * ```svelte - * {#each todos as todo} - * {@const todoForm = updateTodo.for(todo.id)} - *
- * {#if todoForm.result?.invalid}

Invalid data

{/if} - * ... - *
- * {/each} - * ``` - */ - for(id: ExtractId): Omit, 'for'>; - /** Preflight checks */ - preflight(schema: StandardSchemaV1): RemoteForm; /** Validate the form contents programmatically */ validate(options?: { /** Set this to `true` to also show validation issues of fields that haven't been touched yet. */ diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index c1fe5a1d2c10..0681271ce858 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 { RemoteFormInput, RemoteForm } from '@sveltejs/kit' */ -/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */ +/** @import { RemoteFormInput, RemoteForm, RemoteFormFactory, RemoteFormFactoryOptions } from '@sveltejs/kit' */ +/** @import { ExtractId, InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { DEV } from 'esm-env'; @@ -15,18 +15,18 @@ import { import { get_cache, run_remote_function } from './shared.js'; /** - * Creates a form object that can be spread onto a `
` element. + * Creates a factory function that returns form instances which can be spread onto a `` element. * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. * * @template Output * @overload * @param {(invalid: import('@sveltejs/kit').Invalid) => MaybePromise} fn - * @returns {RemoteForm} + * @returns {RemoteFormFactory} * @since 2.27 */ /** - * Creates a form object that can be spread onto a `` element. + * Creates a factory function that returns form instances which can be spread onto a `` element. * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. * @@ -35,11 +35,11 @@ import { get_cache, run_remote_function } from './shared.js'; * @overload * @param {'unchecked'} validate * @param {(data: Input, invalid: import('@sveltejs/kit').Invalid) => MaybePromise} fn - * @returns {RemoteForm} + * @returns {RemoteFormFactory} * @since 2.27 */ /** - * Creates a form object that can be spread onto a `` element. + * Creates a factory function that returns form instances which can be spread onto a `` element. * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. * @@ -48,7 +48,7 @@ import { get_cache, run_remote_function } from './shared.js'; * @overload * @param {Schema} validate * @param {(data: StandardSchemaV1.InferOutput, invalid: import('@sveltejs/kit').Invalid>) => MaybePromise} fn - * @returns {RemoteForm, Output>} + * @returns {RemoteFormFactory, Output>} * @since 2.27 */ /** @@ -56,7 +56,7 @@ import { get_cache, run_remote_function } from './shared.js'; * @template Output * @param {any} validate_or_fn * @param {(data_or_invalid: any, invalid?: any) => MaybePromise} [maybe_fn] - * @returns {RemoteForm} + * @returns {RemoteFormFactory} * @since 2.27 */ /*@__NO_SIDE_EFFECTS__*/ @@ -69,10 +69,126 @@ export function form(validate_or_fn, maybe_fn) { const schema = !maybe_fn || validate_or_fn === 'unchecked' ? null : /** @type {any} */ (validate_or_fn); + /** @type {RemoteInfo & { type: 'form' }} */ + const __ = { + type: 'form', + name: '', + id: '', + fn: async (form_data, instance) => { + const validate_only = form_data.get('sveltekit:validate_only') === 'true'; + + let data = maybe_fn ? convert_formdata(form_data) : undefined; + + if (data && data.id === undefined) { + const id = form_data.get('sveltekit:id'); + if (typeof id === 'string') { + data.id = JSON.parse(id); + } + } + + // TODO 3.0 remove this warning + if (DEV && !data) { + const error = () => { + throw new Error( + 'Remote form functions no longer get passed a FormData object. ' + + "`form` now has the same signature as `query` or `command`, i.e. it expects to be invoked like `form(schema, callback)` or `form('unchecked', callback)`. " + + 'The payload of the callback function is now a POJO instead of a FormData object. See https://kit.svelte.dev/docs/remote-functions#form for details.' + ); + }; + data = {}; + for (const key of [ + 'append', + 'delete', + 'entries', + 'forEach', + 'get', + 'getAll', + 'has', + 'keys', + 'set', + 'values' + ]) { + Object.defineProperty(data, key, { get: error }); + } + } + + /** @type {{ submission: true, input?: Record, issues?: InternalRemoteFormIssue[], result: Output }} */ + const output = {}; + + // make it possible to differentiate between user submission and programmatic `field.set(...)` updates + output.submission = true; + + const { event, state } = get_request_store(); + const validated = await schema?.['~standard'].validate(data); + + if (validate_only) { + return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? []; + } + + if (validated?.issues !== undefined) { + handle_issues(output, validated.issues, event.isRemoteRequest, form_data); + } else { + if (validated !== undefined) { + data = validated.value; + } + + state.refreshes ??= {}; + + const invalid = create_invalid(); + + try { + output.result = await run_remote_function( + event, + state, + true, + data, + (d) => d, + (data) => (!maybe_fn ? fn(invalid) : fn(data, invalid)) + ); + } catch (e) { + if (e instanceof ValidationError) { + handle_issues(output, e.issues, event.isRemoteRequest, form_data); + } else { + throw e; + } + } + } + + // We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads + // where only one form submission is active at the same time + if (!event.isRemoteRequest) { + get_cache(/** @type any */ (instance).__, state)[''] ??= output; + } + + return output; + } + }; + /** - * @param {string | number | boolean} [key] + * @param {ExtractId} [key] + * @returns {RemoteForm} */ function create_instance(key) { + const { state } = get_request_store(); + const instance_id = + key !== undefined ? `${__.id}/${encodeURIComponent(JSON.stringify(key))}` : __.id; + + // Create instance-specific info object with the correct id + /** @type {RemoteInfo & { type: 'form' }} */ + const instance_info = { + type: 'form', + name: __.name, + id: instance_id, + fn: __.fn + }; + + // Check cache for keyed instances + const cache_key = instance_info.id + '|' + JSON.stringify(key); + const cached = (state.form_instances ??= new Map()).get(cache_key); + if (cached) { + return cached; + } + /** @type {RemoteForm} */ const instance = {}; @@ -99,117 +215,21 @@ export function form(validate_or_fn, maybe_fn) { value: button_props }); - /** @type {RemoteInfo} */ - const __ = { - type: 'form', - name: '', - id: '', - /** @param {FormData} form_data */ - fn: async (form_data) => { - const validate_only = form_data.get('sveltekit:validate_only') === 'true'; - - let data = maybe_fn ? convert_formdata(form_data) : undefined; - - if (data && data.id === undefined) { - const id = form_data.get('sveltekit:id'); - if (typeof id === 'string') { - data.id = JSON.parse(id); - } - } - - // TODO 3.0 remove this warning - if (DEV && !data) { - const error = () => { - throw new Error( - 'Remote form functions no longer get passed a FormData object. ' + - "`form` now has the same signature as `query` or `command`, i.e. it expects to be invoked like `form(schema, callback)` or `form('unchecked', callback)`. " + - 'The payload of the callback function is now a POJO instead of a FormData object. See https://kit.svelte.dev/docs/remote-functions#form for details.' - ); - }; - data = {}; - for (const key of [ - 'append', - 'delete', - 'entries', - 'forEach', - 'get', - 'getAll', - 'has', - 'keys', - 'set', - 'values' - ]) { - Object.defineProperty(data, key, { get: error }); - } - } - - /** @type {{ submission: true, input?: Record, issues?: InternalRemoteFormIssue[], result: Output }} */ - const output = {}; - - // make it possible to differentiate between user submission and programmatic `field.set(...)` updates - output.submission = true; - - const { event, state } = get_request_store(); - const validated = await schema?.['~standard'].validate(data); - - if (validate_only) { - return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? []; - } - - if (validated?.issues !== undefined) { - handle_issues(output, validated.issues, event.isRemoteRequest, form_data); - } else { - if (validated !== undefined) { - data = validated.value; - } - - state.refreshes ??= {}; - - const invalid = create_invalid(); - - try { - output.result = await run_remote_function( - event, - state, - true, - data, - (d) => d, - (data) => (!maybe_fn ? fn(invalid) : fn(data, invalid)) - ); - } catch (e) { - if (e instanceof ValidationError) { - handle_issues(output, e.issues, event.isRemoteRequest, form_data); - } else { - throw e; - } - } - } - - // We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads - // where only one form submission is active at the same time - if (!event.isRemoteRequest) { - get_cache(__, state)[''] ??= output; - } - - return output; - } - }; - - Object.defineProperty(instance, '__', { value: __ }); + Object.defineProperty(instance, '__', { value: instance_info }); Object.defineProperty(instance, 'action', { - get: () => `?/remote=${__.id}`, + get: () => `?/remote=${instance_info.id}`, enumerable: true }); Object.defineProperty(button_props, 'formaction', { - get: () => `?/remote=${__.id}`, + get: () => `?/remote=${instance_info.id}`, enumerable: true }); Object.defineProperty(instance, 'fields', { get() { - const data = get_cache(__)?.['']; + const data = get_cache(instance_info)?.['']; const issues = flatten_issues(data?.issues ?? []); return create_field_proxy( @@ -224,7 +244,7 @@ export function form(validate_or_fn, maybe_fn) { const input = path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value); - (get_cache(__)[''] ??= {}).input = input; + (get_cache(instance_info)[''] ??= {}).input = input; }, () => issues ); @@ -239,7 +259,7 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, 'result', { get() { try { - return get_cache(__)?.['']?.result; + return get_cache(instance_info)?.['']?.result; } catch { return undefined; } @@ -256,42 +276,40 @@ export function form(validate_or_fn, maybe_fn) { get: () => 0 }); - Object.defineProperty(instance, 'preflight', { - // preflight is a noop on the server - 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']} */ - value: (key) => { - const { state } = get_request_store(); - const cache_key = __.id + '|' + JSON.stringify(key); - let instance = (state.form_instances ??= new Map()).get(cache_key); + // Cache keyed instances + const form_instances = state.form_instances ?? (state.form_instances = new Map()); + form_instances.set(cache_key, instance); - if (!instance) { - instance = create_instance(key); - instance.__.id = `${__.id}/${encodeURIComponent(JSON.stringify(key))}`; - instance.__.name = __.name; + return instance; + } - state.form_instances.set(cache_key, instance); - } + /** @type {RemoteFormFactory} */ + const factory = (arg) => { + /** @type {RemoteFormFactoryOptions | undefined } */ + const options = arg && typeof arg === 'object' ? arg : undefined; + const key = options ? options.key : /** @type {ExtractId | undefined} */ (arg); - return instance; - } - }); + const instance = create_instance(key); + + if (options?.initialData) { + // seed initial input into cache for SSR + const { state } = get_request_store(); + const cache = get_cache(/** @type any */ (instance).__, state); + (cache[''] ??= {}).input = options.initialData; } return instance; - } + }; + + Object.defineProperty(factory, '__', { value: __ }); - return create_instance(); + return factory; } /** 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 02bbf9239720..1be39168c670 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 { RemoteFormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */ -/** @import { InternalRemoteFormIssue, RemoteFunctionResponse } from 'types' */ +/** @import { RemoteFormInput, RemoteForm, RemoteQueryOverride, RemoteFormFactory, RemoteFormFactoryOptions } from '@sveltejs/kit' */ +/** @import { ExtractId, InternalRemoteFormIssue, RemoteFunctionResponse } from 'types' */ /** @import { Query } from './query.svelte.js' */ import { app_dir, base } from '$app/paths/internal/client'; import * as devalue from 'devalue'; @@ -47,21 +47,22 @@ function merge_with_server_issues(form_data, current_issues, client_issues) { * @template {RemoteFormInput} T * @template U * @param {string} id - * @returns {RemoteForm} + * @returns {RemoteFormFactory} */ export function form(id) { /** @type {Map }>} */ const instances = new Map(); - /** @param {string | number | boolean} [key] */ - function create_instance(key) { - const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : ''); + /** @param {RemoteFormFactoryOptions} options */ + function create_instance(options) { + const { key, preflight, initialData, resetAfterSuccess = true } = options; + const action_id = id + (key !== undefined ? `/${JSON.stringify(key)}` : ''); const action = '?/remote=' + encodeURIComponent(action_id); /** * @type {Record} */ - let input = $state({}); + let input = $state(initialData ?? {}); /** @type {InternalRemoteFormIssue[]} */ let raw_issues = $state.raw([]); @@ -75,7 +76,7 @@ export function form(id) { let pending_count = $state(0); /** @type {StandardSchemaV1 | undefined} */ - let preflight_schema = undefined; + let preflight_schema = preflight; /** @type {HTMLFormElement | null} */ let element = null; @@ -411,7 +412,7 @@ export function form(id) { instance[createAttachmentKey()] = create_attachment( form_onsubmit(({ submit, form }) => submit().then(() => { - if (!issues.$) { + if (!issues.$ && resetAfterSuccess) { form.reset(); } }) @@ -452,7 +453,7 @@ export function form(id) { formaction: action, onclick: form_action_onclick(({ submit, form }) => submit().then(() => { - if (!issues.$) { + if (!issues.$ && resetAfterSuccess) { form.reset(); } }) @@ -510,13 +511,6 @@ export function form(id) { pending: { get: () => pending_count }, - preflight: { - /** @type {RemoteForm['preflight']} */ - value: (schema) => { - preflight_schema = schema; - return instance; - } - }, validate: { /** @type {RemoteForm['validate']} */ value: async ({ includeUntouched = false, preflightOnly = false, submitter } = {}) => { @@ -587,37 +581,36 @@ export function form(id) { return instance; } - const instance = create_instance(); - - Object.defineProperty(instance, 'for', { - /** @type {RemoteForm['for']} */ - value: (key) => { - const entry = instances.get(key) ?? { count: 0, instance: create_instance(key) }; - - try { - $effect.pre(() => { - return () => { - entry.count--; + /** @type {RemoteFormFactory} */ + const factory = (arg) => { + /** @type {RemoteFormFactoryOptions | undefined } */ + const options = arg && typeof arg === 'object' ? arg : undefined; + const key = options ? options.key : /** @type {ExtractId | undefined} */ (arg); + const entry = instances.get(key) ?? { count: 0, instance: create_instance(options ?? { key }) }; - void tick().then(() => { - if (entry.count === 0) { - instances.delete(key); - } - }); - }; - }); + try { + $effect.pre(() => { + return () => { + entry.count--; - entry.count += 1; - instances.set(key, entry); - } catch { - // not in an effect context - } + void tick().then(() => { + if (entry.count === 0) { + instances.delete(key); + } + }); + }; + }); - return entry.instance; + entry.count += 1; + instances.set(key, entry); + } catch { + // not in an effect context } - }); - return instance; + return entry.instance; + }; + + return factory; } /** diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 88ae0d41387c..5df7dc2c33d0 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -1,4 +1,4 @@ -/** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */ +/** @import { ActionResult, RequestEvent, SSRManifest } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */ import { json, error } from '@sveltejs/kit'; @@ -116,6 +116,9 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } + const action_id = decodeURIComponent(additional_args ?? ''); + const instance = fn(action_id ? JSON.parse(action_id) : undefined); + const form_data = await event.request.formData(); form_client_refreshes = /** @type {string[]} */ ( JSON.parse(/** @type {string} */ (form_data.get('sveltekit:remote_refreshes')) ?? '[]') @@ -123,12 +126,14 @@ async function handle_remote_call_internal(event, state, options, manifest, id) form_data.delete('sveltekit:remote_refreshes'); // If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set) - if (additional_args) { - form_data.set('sveltekit:id', decodeURIComponent(additional_args)); + if (action_id) { + form_data.set('sveltekit:id', action_id); } - const fn = info.fn; - const data = await with_request_store({ event, state }, () => fn(form_data)); + const form_fn = /** @type {RemoteInfo & { type: 'form' }} */ ( + /** @type {any} */ (instance).__ + ).fn; + const data = await with_request_store({ event, state }, () => form_fn(form_data, instance)); return json( /** @type {RemoteFunctionResponse} */ ({ @@ -267,7 +272,9 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { const remotes = manifest._.remotes; const module = await remotes[hash]?.(); - let form = /** @type {RemoteForm} */ (module?.default[name]); + const form = /** @type {import("@sveltejs/kit").RemoteFormFactory} */ ( + module?.default[name] + ); if (!form) { event.setHeaders({ @@ -285,14 +292,11 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { }; } - if (action_id) { - // @ts-expect-error - form = with_request_store({ event, state }, () => form.for(JSON.parse(action_id))); - } + const instance = form(action_id ? JSON.parse(action_id) : undefined); try { const form_data = await event.request.formData(); - const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn; + const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (instance).__).fn; // If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set) if (action_id && !form_data.has('id')) { @@ -300,7 +304,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { form_data.set('sveltekit:id', decodeURIComponent(action_id)); } - await with_request_store({ event, state }, () => fn(form_data)); + await with_request_store({ event, state }, () => fn(form_data, instance)); // We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it. // It is instead available on `myForm.result`, setting of which happens within the remote `form` function. diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 84242df90417..2d359cca5498 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -22,7 +22,8 @@ import { ClientInit, Transport, HandleValidationError, - RemoteFormIssue + RemoteFormIssue, + RemoteForm } from '@sveltejs/kit'; import { HttpMethod, @@ -572,7 +573,7 @@ export type RemoteInfo = type: 'form'; id: string; name: string; - fn: (data: FormData) => Promise; + fn: (data: FormData, instance: RemoteForm) => Promise; } | { type: 'prerender'; diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index da512ed777c5..78f6e27addfd 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -241,3 +241,19 @@ export interface RouteSegment { } export type TrailingSlash = 'never' | 'always' | 'ignore'; + +export type DeepPartial = T extends Record | unknown[] + ? { + [K in keyof T]?: T[K] extends Record | unknown[] + ? DeepPartial + : T[K]; + } + : T | undefined; + +// If the schema specifies `id` as a string or number, ensure that `for(...)` +// only accepts that type. Otherwise, accept `string | number` +export type ExtractId = Input extends { id: infer Id } + ? Id extends string | number + ? Id + : string | number + : string | number; 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 33f931628aa1..326239ff9fec 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 @@ -8,8 +8,14 @@ const message = get_message(); - const scoped = set_message.for('scoped'); - const enhanced = set_message.for('enhanced'); + const scoped = set_message({ + key: 'scoped', + initialData: { + message: 'hello' + }, + resetAfterSuccess: false + }); + const enhanced = set_message('enhanced');

message.current: {message.current}

@@ -21,20 +27,20 @@
- - {#if set_message.fields.message.issues()} -

{set_message.fields.message.issues()[0].message}

+ + {#if set_message().fields.message.issues()} +

{set_message().fields.message.issues()[0].message}

{/if} - + - + -

set_message.input.message: {set_message.fields.message.value()}

-

set_message.pending: {set_message.pending}

-

set_message.result: {set_message.result}

-

set_reverse_message.result: {set_reverse_message.result}

+

set_message.input.message: {set_message().fields.message.value()}

+

set_message.pending: {set_message().pending}

+

set_message.result: {set_message().result}

+

set_reverse_message.result: {set_reverse_message().result}


@@ -73,6 +79,6 @@
-
+
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/imperative/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/imperative/+page.svelte index c21e4d146af3..a5d051230428 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/imperative/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/imperative/+page.svelte @@ -8,25 +8,28 @@ 'message is invalid' ) }); + const message_form = set_message({ + preflight: schema + }); -
+

- {set_message.fields.message.issues()?.[0]?.message ?? 'ok'} + {message_form.fields.message.issues()?.[0]?.message ?? 'ok'}

-

{set_message.fields.message.value()}

+

{message_form.fields.message.value()}

- {#each set.fields.allIssues() as issue} + {#each set_form.fields.allIssues() as issue}

{issue.message}

{/each}
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 index 38d1248d4515..1987a92827c0 100644 --- 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 @@ -4,11 +4,16 @@ const number = get_number(); - const enhanced = set_number.for('enhanced'); - const schema = v.object({ number: v.pipe(v.number(), v.maxValue(20, 'too big')) }); + const number_form = set_number({ + preflight: schema + }); + const enhanced = set_number({ + key: 'enhanced', + preflight: schema + });

number.current: {number.current}

@@ -20,24 +25,24 @@
-
- {#each set_number.fields.number.issues() as issue} + + {#each number_form.fields.number.issues() as issue}

{issue.message}

{/each} - +
-

set_number.input.number: {set_number.fields.number.value()}

-

set_number.pending: {set_number.pending}

-

set_number.result: {set_number.result}

+

set_number.input.number: {number_form.fields.number.value()}

+

set_number.pending: {number_form.pending}

+

set_number.result: {number_form.result}


{ + {...enhanced.enhance(async ({ submit }) => { await submit(); })} > diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/select-untouched/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/select-untouched/+page.svelte index 5caecdde39f2..a546968d76c0 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/select-untouched/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/select-untouched/+page.svelte @@ -1,11 +1,13 @@ - - + + - diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte index d4648f9d462e..dff36b92ace8 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte @@ -1,9 +1,11 @@ - - + +
-

{my_form.result}

+

{my_form_form.result}

diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte index 6c8a0aff7e4f..abb644635ad0 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte @@ -1,15 +1,17 @@ -
- - + + +
-
{JSON.stringify(register.fields.issues(), null, '  ')}
+
{JSON.stringify(register_form.fields.issues(), null, '  ')}