Skip to content

Commit b6d6b8a

Browse files
feat: form.for(id) now implicitly sets id on form object (#14623)
* feat: `form.for(id)` now implicitly sets id on form object * fix * client * docs * enforce id type * preserve type of id --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 097d718 commit b6d6b8a

File tree

10 files changed

+92
-16
lines changed

10 files changed

+92
-16
lines changed

.changeset/dry-garlics-yawn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: `form.for(id)` now implicitly sets id on form object

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,27 @@ await submit().updates(
755755

756756
The override will be applied immediately, and released when the submission completes (or fails).
757757

758+
### Multiple instances of a form
759+
760+
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.
761+
762+
```svelte
763+
<!--- file: src/routes/todos/+page.svelte --->
764+
<script>
765+
import { getTodos, modifyTodo } from '../data.remote';
766+
</script>
767+
768+
<h1>Todos</h1>
769+
770+
{#each await getTodos() as todo}
771+
{@const modify = modifyTodo.for(todo.id)}
772+
<form {...modify}>
773+
<!-- -->
774+
<button disabled={!!modify.pending}>save changes</button>
775+
</form>
776+
{/each}
777+
```
778+
758779
### buttonProps
759780
760781
By default, submitting a form will send a request to the URL indicated by the `<form>` 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.

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,14 @@ export interface RemoteFormIssue {
19371937
message: string;
19381938
}
19391939

1940+
// If the schema specifies `id` as a string or number, ensure that `for(...)`
1941+
// only accepts that type. Otherwise, accept `string | number`
1942+
type ExtractId<Input> = Input extends { id: infer Id }
1943+
? Id extends string | number
1944+
? Id
1945+
: string | number
1946+
: string | number;
1947+
19401948
/**
19411949
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
19421950
*/
@@ -1961,8 +1969,8 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
19611969
[attachment: symbol]: (node: HTMLFormElement) => void;
19621970
};
19631971
/**
1964-
* Create an instance of the form for the given key.
1965-
* The key is stringified and used for deduplication to potentially reuse existing instances.
1972+
* Create an instance of the form for the given `id`.
1973+
* The `id` is stringified and used for deduplication to potentially reuse existing instances.
19661974
* Useful when you have multiple forms that use the same remote form action, for example in a loop.
19671975
* ```svelte
19681976
* {#each todos as todo}
@@ -1974,7 +1982,7 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
19741982
* {/each}
19751983
* ```
19761984
*/
1977-
for(key: string | number | boolean): Omit<RemoteForm<Input, Output>, 'for'>;
1985+
for(id: ExtractId<Input>): Omit<RemoteForm<Input, Output>, 'for'>;
19781986
/** Preflight checks */
19791987
preflight(schema: StandardSchemaV1<Input, any>): RemoteForm<Input, Output>;
19801988
/** Validate the form contents programmatically */

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,16 @@ export function form(validate_or_fn, maybe_fn) {
105105
/** @param {FormData} form_data */
106106
fn: async (form_data) => {
107107
const validate_only = form_data.get('sveltekit:validate_only') === 'true';
108-
form_data.delete('sveltekit:validate_only');
109108

110109
let data = maybe_fn ? convert_formdata(form_data) : undefined;
111110

111+
if (data && data.id === undefined) {
112+
const id = form_data.get('sveltekit:id');
113+
if (typeof id === 'string') {
114+
data.id = JSON.parse(id);
115+
}
116+
}
117+
112118
// TODO 3.0 remove this warning
113119
if (DEV && !data) {
114120
const error = () => {

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,25 @@ export function form(id) {
8282

8383
let submitted = false;
8484

85+
/**
86+
* @param {FormData} form_data
87+
* @returns {Record<string, any>}
88+
*/
89+
function convert(form_data) {
90+
const data = convert_formdata(form_data);
91+
if (key !== undefined && !form_data.has('id')) {
92+
data.id = key;
93+
}
94+
return data;
95+
}
96+
8597
/**
8698
* @param {HTMLFormElement} form
8799
* @param {FormData} form_data
88100
* @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback
89101
*/
90102
async function handle_submit(form, form_data, callback) {
91-
const data = convert_formdata(form_data);
103+
const data = convert(form_data);
92104

93105
submitted = true;
94106

@@ -505,9 +517,7 @@ export function form(id) {
505517
/** @type {readonly StandardSchemaV1.Issue[]} */
506518
let array = [];
507519

508-
const validated = await preflight_schema?.['~standard'].validate(
509-
convert_formdata(form_data)
510-
);
520+
const validated = await preflight_schema?.['~standard'].validate(convert(form_data));
511521

512522
if (validated?.issues) {
513523
array = validated.issues;

packages/kit/src/runtime/form-utils.svelte.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export function convert_formdata(data) {
3131
let result = Object.create(null); // guard against prototype pollution
3232

3333
for (let key of data.keys()) {
34+
if (key.startsWith('sveltekit:')) {
35+
continue;
36+
}
37+
3438
const is_array = key.endsWith('[]');
3539
/** @type {any[]} */
3640
let values = data.getAll(key);

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function handle_remote_call(event, state, options, manifest, id) {
3737
* @param {string} id
3838
*/
3939
async function handle_remote_call_internal(event, state, options, manifest, id) {
40-
const [hash, name, prerender_args] = id.split('/');
40+
const [hash, name, additional_args] = id.split('/');
4141
const remotes = manifest._.remotes;
4242

4343
if (!remotes[hash]) error(404);
@@ -122,6 +122,11 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
122122
);
123123
form_data.delete('sveltekit:remote_refreshes');
124124

125+
// If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set)
126+
if (additional_args) {
127+
form_data.set('sveltekit:id', decodeURIComponent(additional_args));
128+
}
129+
125130
const fn = info.fn;
126131
const data = await with_request_store({ event, state }, () => fn(form_data));
127132

@@ -151,7 +156,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
151156

152157
const payload =
153158
info.type === 'prerender'
154-
? prerender_args
159+
? additional_args
155160
: /** @type {string} */ (
156161
// new URL(...) necessary because we're hiding the URL from the user in the event object
157162
new URL(event.request.url).searchParams.get('payload')
@@ -289,6 +294,12 @@ async function handle_remote_form_post_internal(event, state, manifest, id) {
289294
const form_data = await event.request.formData();
290295
const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn;
291296

297+
// If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set)
298+
if (action_id && !form_data.has('id')) {
299+
// The action_id is URL-encoded JSON, decode and parse it
300+
form_data.set('sveltekit:id', decodeURIComponent(action_id));
301+
}
302+
292303
await with_request_store({ event, state }, () => fn(form_data));
293304

294305
// We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it.

packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const get_message = query(() => {
1111

1212
export const set_message = form(
1313
v.object({
14+
id: v.optional(v.string()),
1415
message: v.picklist(
1516
['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect'],
1617
'message is invalid'
@@ -38,7 +39,7 @@ export const set_message = form(
3839
await deferred.promise;
3940
}
4041

41-
return message;
42+
return message + (data.id ? ` (from: ${data.id})` : '');
4243
}
4344
);
4445

packages/kit/test/apps/basics/test/test.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,7 +1786,7 @@ test.describe('remote functions', () => {
17861786
await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello');
17871787
}
17881788

1789-
await expect(page.getByText('scoped.result')).toHaveText('scoped.result: hello');
1789+
await expect(page.getByText('scoped.result')).toHaveText('scoped.result: hello (from: scoped)');
17901790
await expect(page.locator('[data-scoped] input')).toHaveValue('');
17911791
});
17921792

@@ -1808,7 +1808,9 @@ test.describe('remote functions', () => {
18081808
await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello');
18091809
}
18101810

1811-
await expect(page.getByText('enhanced.result')).toHaveText('enhanced.result: hello');
1811+
await expect(page.getByText('enhanced.result')).toHaveText(
1812+
'enhanced.result: hello (from: enhanced)'
1813+
);
18121814
await expect(page.locator('[data-enhanced] input')).toHaveValue('');
18131815
});
18141816

packages/kit/types/index.d.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,14 @@ declare module '@sveltejs/kit' {
19131913
message: string;
19141914
}
19151915

1916+
// If the schema specifies `id` as a string or number, ensure that `for(...)`
1917+
// only accepts that type. Otherwise, accept `string | number`
1918+
type ExtractId<Input> = Input extends { id: infer Id }
1919+
? Id extends string | number
1920+
? Id
1921+
: string | number
1922+
: string | number;
1923+
19161924
/**
19171925
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
19181926
*/
@@ -1937,8 +1945,8 @@ declare module '@sveltejs/kit' {
19371945
[attachment: symbol]: (node: HTMLFormElement) => void;
19381946
};
19391947
/**
1940-
* Create an instance of the form for the given key.
1941-
* The key is stringified and used for deduplication to potentially reuse existing instances.
1948+
* Create an instance of the form for the given `id`.
1949+
* The `id` is stringified and used for deduplication to potentially reuse existing instances.
19421950
* Useful when you have multiple forms that use the same remote form action, for example in a loop.
19431951
* ```svelte
19441952
* {#each todos as todo}
@@ -1950,7 +1958,7 @@ declare module '@sveltejs/kit' {
19501958
* {/each}
19511959
* ```
19521960
*/
1953-
for(key: string | number | boolean): Omit<RemoteForm<Input, Output>, 'for'>;
1961+
for(id: ExtractId<Input>): Omit<RemoteForm<Input, Output>, 'for'>;
19541962
/** Preflight checks */
19551963
preflight(schema: StandardSchemaV1<Input, any>): RemoteForm<Input, Output>;
19561964
/** Validate the form contents programmatically */

0 commit comments

Comments
 (0)