From f55b58c08c4544d40a47b3b5e847a4b6cdfae5c9 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Wed, 8 Oct 2025 23:58:27 -0500 Subject: [PATCH 1/8] feat: add array mutation methods to field arrays with documentation and tests --- .changeset/blue-news-dream.md | 5 ++ .../20-core-concepts/60-remote-functions.md | 32 ++++++++ packages/kit/src/exports/public.d.ts | 52 ++++++++----- packages/kit/src/runtime/form-utils.svelte.js | 76 +++++++++++++++++++ .../routes/remote/form/arrays/+page.svelte | 47 ++++++++++++ .../routes/remote/form/arrays/form.remote.ts | 15 ++++ packages/kit/types/index.d.ts | 18 ++++- 7 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 .changeset/blue-news-dream.md create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts diff --git a/.changeset/blue-news-dream.md b/.changeset/blue-news-dream.md new file mode 100644 index 000000000000..63889527d58f --- /dev/null +++ b/.changeset/blue-news-dream.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +add array mutation methods to field arrays, includes test and docs diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 5a8a472bb3a7..4d853c9c3889 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -592,6 +592,38 @@ You can update a field (or a collection of fields) via the `set(...)` method: ``` +### Mutating field arrays + +Field arrays have the following extra methods to mutate the array: `.pop()`, `.push()`, `.shift()`, `.unshift()`, `.splice()`, and `.sort()`. + +```svelte + +``` + +Other array methods can still be accessed through the `.value()` method. + +```svelte + +``` + ### Handling sensitive data In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `value()` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 01e4f8cf9eb9..7f08632f3451 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1895,29 +1895,45 @@ type AsArgs = Type extends 'checkbox' /** * Form field accessor type that provides name(), value(), and issues() methods */ -export type RemoteFormField = RemoteFormFieldMethods & { - /** - * Returns an object that can be spread onto an input element with the correct type attribute, - * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. - * @example - * ```svelte - * - * - * - * ``` - */ - as>(...args: AsArgs): InputElementProps; -}; +export type RemoteFormField = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** + * Returns an object that can be spread onto an input element with the correct type attribute, + * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. + * @example + * ```svelte + * + * + * + * ``` + */ + as>(...args: AsArgs): InputElementProps; + }; -type RemoteFormFieldContainer = RemoteFormFieldMethods & { - /** Validation issues belonging to this or any of the fields that belong to it, if any */ - allIssues(): RemoteFormIssue[] | undefined; -}; +type RemoteFormArrayFieldMethods = + T extends Array + ? { + push(item: U): void; + pop(): U; + unshift(item: U): void; + shift(): U; + splice(start: number, deleteCount: number, ...insert: U[]): U[]; + sort(fn: (a: U, b: U) => number): U[]; + // you could add more, but the mutate fns are all we really need. + // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` + } + : {}; + +type RemoteFormFieldContainer = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** Validation issues belonging to this or any of the fields that belong to it, if any */ + allIssues(): RemoteFormIssue[] | undefined; + }; /** * Recursive type to build form fields structure with proxy access */ -type RemoteFormFields = +export type RemoteFormFields = WillRecurseIndefinitely extends true ? RecursiveFormFields : NonNullable extends string | number | boolean | File diff --git a/packages/kit/src/runtime/form-utils.svelte.js b/packages/kit/src/runtime/form-utils.svelte.js index 4d0ff75d7318..7cbe614e6026 100644 --- a/packages/kit/src/runtime/form-utils.svelte.js +++ b/packages/kit/src/runtime/form-utils.svelte.js @@ -216,6 +216,82 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss ]); } + if (prop === 'push') { + const push_func = function (/** @type {any} */ newValue) { + set_input(path, [...get_value(), newValue]); + }; + return create_field_proxy(push_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'unshift') { + const unshift_func = function (/** @type {any} */ newValue) { + set_input(path, [newValue, ...get_value()]); + }; + return create_field_proxy(unshift_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'pop') { + const pop_func = function () { + const value = get_value(); + const output = value.pop(); + set_input(path, value); + return output; + }; + return create_field_proxy(pop_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'shift') { + const shift_func = function () { + const value = get_value(); + const output = value.shift(); + set_input(path, value); + return output; + }; + return create_field_proxy(shift_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'splice') { + const splice_func = function ( + /** @type {number} */ start, + /** @type {number} */ deleteCount, + /** @type {any[]} */ ...insert + ) { + const value = get_value(); + const output = value.splice(start, deleteCount, ...insert); + set_input(path, value); + return output; + }; + return create_field_proxy(splice_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'sort') { + const sort_func = function (/** @type {function} */ fn) { + const value = get_value(); + const output = value.sort(fn); + set_input(path, value); + return output; + }; + return create_field_proxy(sort_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + if (prop === 'value') { return create_field_proxy(get_value, get_input, depend, set_input, get_issues, [ ...path, diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte new file mode 100644 index 000000000000..4a06c5ebcc04 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte @@ -0,0 +1,47 @@ + + +
+

+ +

+ {#each my_form.fields.strings.value() as _, i (i)} + + {/each} + +

+ +

+ {#each my_form.fields.numbers.value() as _, i (i)} + + {/each} + +

+ +

+ {#each my_form.fields.objects.value() as _, i (i)} + + + {/each} + + +
\ No newline at end of file diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts new file mode 100644 index 000000000000..0e6d2345acc1 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts @@ -0,0 +1,15 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const my_form = form( + v.object({ + strings: v.array(v.string()), + numbers: v.array(v.number()), + objects: v.array(v.object({ + name: v.string(), + age: v.number() + })) + }), (input) => { + return { success: true, data: input }; + } +) \ No newline at end of file diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index b503853794e8..f13e38356168 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1871,7 +1871,8 @@ declare module '@sveltejs/kit' { /** * Form field accessor type that provides name(), value(), and issues() methods */ - export type RemoteFormField = RemoteFormFieldMethods & { + export type RemoteFormField = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { /** * Returns an object that can be spread onto an input element with the correct type attribute, * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. @@ -1885,7 +1886,18 @@ declare module '@sveltejs/kit' { as>(...args: AsArgs): InputElementProps; }; - type RemoteFormFieldContainer = RemoteFormFieldMethods & { + type RemoteFormArrayFieldMethods = T extends Array ? { + push(item: U): void; + pop(): U; + unshift(item: U): void; + shift(): U; + splice(start: number, deleteCount: number, ...insert: U[]): U[]; + sort(fn: (a: U, b: U) => number): U[]; + // you could add more, but the mutate fns are all we really need. + // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` + } : {}; + + type RemoteFormFieldContainer = RemoteFormFieldMethods & RemoteFormArrayFieldMethods & { /** Validation issues belonging to this or any of the fields that belong to it, if any */ allIssues(): RemoteFormIssue[] | undefined; }; @@ -1893,7 +1905,7 @@ declare module '@sveltejs/kit' { /** * Recursive type to build form fields structure with proxy access */ - type RemoteFormFields = + export type RemoteFormFields = WillRecurseIndefinitely extends true ? RecursiveFormFields : NonNullable extends string | number | boolean | File From 506ce1ce9c16f18ee9cf837f8563bae96016c2ea Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Thu, 9 Oct 2025 00:03:03 -0500 Subject: [PATCH 2/8] lint --- packages/kit/src/exports/public.d.ts | 2 +- .../routes/remote/form/arrays/+page.svelte | 76 +++++++++---------- .../routes/remote/form/arrays/form.remote.ts | 25 +++--- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 7f08632f3451..e07506be2216 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1895,7 +1895,7 @@ type AsArgs = Type extends 'checkbox' /** * Form field accessor type that provides name(), value(), and issues() methods */ -export type RemoteFormField = RemoteFormFieldMethods & +export type RemoteFormField = RemoteFormFieldMethods & RemoteFormArrayFieldMethods & { /** * Returns an object that can be spread onto an input element with the correct type attribute, diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte index 4a06c5ebcc04..0f2e226278a7 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte @@ -1,47 +1,47 @@
-

- -

- {#each my_form.fields.strings.value() as _, i (i)} - - {/each} +

+ +

+ {#each my_form.fields.strings.value() as _, i (i)} + + {/each} -

- -

- {#each my_form.fields.numbers.value() as _, i (i)} - - {/each} +

+ +

+ {#each my_form.fields.numbers.value() as _, i (i)} + + {/each} -

- -

- {#each my_form.fields.objects.value() as _, i (i)} - - - {/each} +

+ +

+ {#each my_form.fields.objects.value() as _, i (i)} + + + {/each} - -
\ No newline at end of file + + diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts index 0e6d2345acc1..a9e40538529c 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts @@ -2,14 +2,17 @@ import { form } from '$app/server'; import * as v from 'valibot'; export const my_form = form( - v.object({ - strings: v.array(v.string()), - numbers: v.array(v.number()), - objects: v.array(v.object({ - name: v.string(), - age: v.number() - })) - }), (input) => { - return { success: true, data: input }; - } -) \ No newline at end of file + v.object({ + strings: v.array(v.string()), + numbers: v.array(v.number()), + objects: v.array( + v.object({ + name: v.string(), + age: v.number() + }) + ) + }), + (input) => { + return { success: true, data: input }; + } +); From 563b1f4223e00a983106aeacf8d6373c5db59c13 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Thu, 9 Oct 2025 00:12:05 -0500 Subject: [PATCH 3/8] lint --- packages/kit/types/index.d.ts | 60 +++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f13e38356168..ca1fda04d1c5 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1871,36 +1871,40 @@ declare module '@sveltejs/kit' { /** * Form field accessor type that provides name(), value(), and issues() methods */ - export type RemoteFormField = RemoteFormFieldMethods & + export type RemoteFormField = RemoteFormFieldMethods & RemoteFormArrayFieldMethods & { - /** - * Returns an object that can be spread onto an input element with the correct type attribute, - * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. - * @example - * ```svelte - * - * - * - * ``` - */ - as>(...args: AsArgs): InputElementProps; - }; + /** + * Returns an object that can be spread onto an input element with the correct type attribute, + * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. + * @example + * ```svelte + * + * + * + * ``` + */ + as>(...args: AsArgs): InputElementProps; + }; - type RemoteFormArrayFieldMethods = T extends Array ? { - push(item: U): void; - pop(): U; - unshift(item: U): void; - shift(): U; - splice(start: number, deleteCount: number, ...insert: U[]): U[]; - sort(fn: (a: U, b: U) => number): U[]; - // you could add more, but the mutate fns are all we really need. - // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` - } : {}; - - type RemoteFormFieldContainer = RemoteFormFieldMethods & RemoteFormArrayFieldMethods & { - /** Validation issues belonging to this or any of the fields that belong to it, if any */ - allIssues(): RemoteFormIssue[] | undefined; - }; + type RemoteFormArrayFieldMethods = + T extends Array + ? { + push(item: U): void; + pop(): U; + unshift(item: U): void; + shift(): U; + splice(start: number, deleteCount: number, ...insert: U[]): U[]; + sort(fn: (a: U, b: U) => number): U[]; + // you could add more, but the mutate fns are all we really need. + // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` + } + : {}; + + type RemoteFormFieldContainer = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** Validation issues belonging to this or any of the fields that belong to it, if any */ + allIssues(): RemoteFormIssue[] | undefined; + }; /** * Recursive type to build form fields structure with proxy access From 822452a2ed4a9b8e69e467e3f3bb78f8b3092546 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Thu, 9 Oct 2025 08:50:08 -0500 Subject: [PATCH 4/8] Add `feat:` label to changeset --- .changeset/blue-news-dream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/blue-news-dream.md b/.changeset/blue-news-dream.md index 63889527d58f..9fb520e25077 100644 --- a/.changeset/blue-news-dream.md +++ b/.changeset/blue-news-dream.md @@ -2,4 +2,4 @@ '@sveltejs/kit': minor --- -add array mutation methods to field arrays, includes test and docs +feat: add array mutation methods to field arrays From 762cfef3ab00191f67f7bdbef0de29403c30a348 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Thu, 9 Oct 2025 16:08:42 -0500 Subject: [PATCH 5/8] simplify docs --- .../docs/20-core-concepts/60-remote-functions.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 4d853c9c3889..03fc4036b74f 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -594,7 +594,7 @@ You can update a field (or a collection of fields) via the `set(...)` method: ### Mutating field arrays -Field arrays have the following extra methods to mutate the array: `.pop()`, `.push()`, `.shift()`, `.unshift()`, `.splice()`, and `.sort()`. +Field arrays have the following extra methods to mutate the array: `.pop()`, `.push()`, `.shift()`, `.unshift()`, `.splice()`, and `.sort()`. Other array methods and properties can still be accessed through the `.value()` method. ```svelte -``` - -Other array methods can still be accessed through the `.value()` method. - -```svelte - ``` From ee8c54a55b8fb90c5d63026df7c2d86d14fd3a25 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Fri, 10 Oct 2025 10:25:21 -0500 Subject: [PATCH 6/8] better splice example - The previous example could have been achieved simply by `cart.fields.items.set([])` --- documentation/docs/20-core-concepts/60-remote-functions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 03fc4036b74f..7a65ed7b156a 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -608,8 +608,8 @@ Field arrays have the following extra methods to mutate the array: `.pop()`, `.p quantity: 1 }); - // clear cart - cart.fields.items.splice(0, cart.fields.items.value().length); + // remove cart item at index `i` + cart.fields.items.splice(i, 1); // cart total const total = cart.fields.items.value().reduce((acc, item) => acc + item.price * item.quantity, 0); From b7e322d7d4d157a9313a8ccc97cc6ef1f50b5e6e Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Sat, 11 Oct 2025 15:45:33 -0500 Subject: [PATCH 7/8] test --- .../routes/remote/form/arrays/+page.svelte | 60 ++++++++++--------- packages/kit/test/apps/basics/test/test.js | 15 ++++- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte index 0f2e226278a7..3b0f134e6969 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte @@ -2,46 +2,50 @@ import { my_form } from './form.remote.js'; my_form.fields.set({ - strings: [''], - numbers: [0], - objects: [{ name: '', age: 0 }] + strings: [], + numbers: [], + objects: [] }); + + my_form.fields.strings.push(''); + my_form.fields.numbers.push(0); + my_form.fields.objects.push({ name: '', age: 0 });
-

- -

{#each my_form.fields.strings.value() as _, i (i)} - +
+ +
{/each} -

- -

{#each my_form.fields.numbers.value() as _, i (i)} - +
+ +
{/each} -

- -

{#each my_form.fields.objects.value() as _, i (i)} - - +
+ + +
{/each}
+ +

Full Form Value

+
{JSON.stringify(my_form.fields.value(), null, 2)}
\ No newline at end of file diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 1e64ded04982..c924c648a0d2 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1,5 +1,5 @@ -import process from 'node:process'; import { expect } from '@playwright/test'; +import process from 'node:process'; import { test } from '../../../utils.js'; /** @typedef {import('@playwright/test').Response} Response */ @@ -1886,6 +1886,19 @@ test.describe('remote functions', () => { await expect(page.locator('input[name="_password"]')).toHaveValue(''); }); + test('form field arrays', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/arrays'); + + const value = await page.locator('#full-value').textContent(); + expect(JSON.parse(value)).toEqual({ + strings: [''], + numbers: [0], + objects: [{ name: '', age: 0 }] + }); + }); + test('prerendered entries not called in prod', async ({ page, clicknav }) => { await page.goto('/remote/prerender'); await clicknav('[href="/remote/prerender/whole-page"]'); From 26e0e0cdb7bb108eb8186f5d79f0a8925e05d4b2 Mon Sep 17 00:00:00 2001 From: Matt DeKok Date: Sat, 11 Oct 2025 15:54:36 -0500 Subject: [PATCH 8/8] format --- .../routes/remote/form/arrays/+page.svelte | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte index 3b0f134e6969..fff27c5db7c7 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte @@ -15,37 +15,37 @@
{#each my_form.fields.strings.value() as _, i (i)}
- -
+ + {/each} {#each my_form.fields.numbers.value() as _, i (i)} -
- -
+
+ +
{/each} {#each my_form.fields.objects.value() as _, i (i)} -
- - -
+
+ + +
{/each}

Full Form Value

-
{JSON.stringify(my_form.fields.value(), null, 2)}
\ No newline at end of file +
{JSON.stringify(my_form.fields.value(), null, 2)}