From 20784c452bfe97ba899926715e8d32299951a73f Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 18:50:26 -0700 Subject: [PATCH 01/14] use accessors functions for create_field_proxy --- .../kit/src/runtime/app/server/remote/form.js | 43 +++++++++--------- .../client/remote-functions/form.svelte.js | 44 ++++++++++--------- packages/kit/src/runtime/form-utils.js | 35 +++++++-------- 3 files changed, 62 insertions(+), 60 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 3fd23c793513..5f42cf3df2b5 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -183,26 +183,29 @@ export function form(validate_or_fn, maybe_fn) { // so the current request's state has to be resolved at access time return create_field_proxy( {}, - () => get_cache(__, get_request_store().state)?.['']?.input ?? {}, - (path, value) => { - const cache = get_cache(__, get_request_store().state); - const entry = cache['']; - - if (entry?.submission) { - // don't override a submission - return; - } - - if (path.length === 0) { - (cache[''] ??= {}).input = value; - return; - } - - const input = entry?.input ?? {}; - deep_set(input, path.map(String), value); - (cache[''] ??= {}).input = input; - }, - () => flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []) + { + get_input: () => get_cache(__, get_request_store().state)?.['']?.input ?? {}, + set_input: (path, value) => { + const cache = get_cache(__, get_request_store().state); + const entry = cache['']; + + if (entry?.submission) { + // don't override a submission + return; + } + + if (path.length === 0) { + (cache[''] ??= {}).input = value; + return; + } + + const input = entry?.input ?? {}; + deep_set(input, path.map(String), value); + (cache[''] ??= {}).input = input; + }, + get_issues: () => + flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []) + } ); } }); 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 e2b7fb0944fe..08bee764bd5b 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -604,28 +604,30 @@ export function form(id) { get: () => create_field_proxy( {}, - () => input, - (path, value) => { - if (path.length === 0) { - input = value; - } else { - deep_set(input, path.map(String), value); - - const key = build_path_string(path); - touched[key] = true; - } - }, - (path, all) => { - if (DEV && unread_issues !== null && path !== undefined) { - unread_issues = unread_issues.filter((issue) => { - return ( - (all ? issue.path.slice(0, path.length) : issue.path).join('.') !== - path.join('.') - ); - }); - } + { + get_input: () => input, + set_input: (path, value) => { + if (path.length === 0) { + input = value; + } else { + deep_set(input, path.map(String), value); + + const key = build_path_string(path); + touched[key] = true; + } + }, + get_issues: (path, all) => { + if (DEV && unread_issues !== null && path !== undefined) { + unread_issues = unread_issues.filter((issue) => { + return ( + (all ? issue.path.slice(0, path.length) : issue.path).join('.') !== + path.join('.') + ); + }); + } - return issues; + return issues; + } } ) }, diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 6efc6f6d56ad..1597f13053b7 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -651,15 +651,13 @@ function deep_clone(value) { /** * Creates a proxy-based field accessor for form data * @param {any} target - Function or empty POJO - * @param {() => Record} get_input - Function to get current input data - * @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data - * @param {(path?: (string | number)[], all?: boolean) => Record} get_issues - Function to get current issues + * @param {{ get_input: () => Record, set_input: (path: (string | number)[], value: any) => void, get_issues: (path?: (string | number)[], all?: boolean) => Record }} accessors - Accessor functions * @param {(string | number)[]} path - Current access path * @returns {any} Proxy object with name(), value(), and issues() methods */ -export function create_field_proxy(target, get_input, set_input, get_issues, path = []) { +export function create_field_proxy(target, accessors, path = []) { const get_value = () => { - const value = deep_get(get_input(), path); + const value = deep_get(accessors.get_input(), path); return deep_clone(value); }; @@ -669,29 +667,28 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy({}, get_input, set_input, get_issues, [ - ...path, - parseInt(prop, 10) - ]); + return create_field_proxy({}, accessors, [...path, parseInt(prop, 10)]); } const key = build_path_string(path); if (prop === 'set') { const set_func = function (/** @type {any} */ newValue) { - set_input(path, newValue); + accessors.set_input(path, newValue); return newValue; }; - return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(set_func, accessors, [...path, prop]); } if (prop === 'value') { - return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(get_value, accessors, [...path, prop]); } if (prop === 'issues' || prop === 'allIssues') { const issues_func = () => { - const all_issues = get_issues(path, prop === 'allIssues')[key === '' ? '$' : key]; + const all_issues = accessors.get_issues(path, prop === 'allIssues')[ + key === '' ? '$' : key + ]; if (prop === 'allIssues') { return all_issues?.map((issue) => ({ @@ -710,7 +707,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat return issues?.length ? issues : undefined; }; - return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(issues_func, accessors, [...path, prop]); } if (prop === 'as') { @@ -724,14 +721,14 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat type === 'select multiple' || (type === 'checkbox' && typeof input_value === 'string'); - const prefix = get_type_prefix(type, is_array, input_value); + const type_prefix = get_type_prefix(type, is_array, input_value); // Base properties for all input types /** @type {Record} */ const base_props = { - name: prefix + key + (is_array ? '[]' : ''), + name: type_prefix + key + (is_array ? '[]' : ''), get 'aria-invalid'() { - const issues = get_issues(); + const issues = accessors.get_issues(); return key in issues ? 'true' : undefined; } }; @@ -876,11 +873,11 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat }); }; - return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']); + return create_field_proxy(as_func, accessors, [...path, 'as']); } // Handle property access (nested fields) - return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy({}, accessors, [...path, prop]); } }); } From 9ed534f5c9daaa6f06e86cb116b69b6ea57272c1 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 21:01:37 -0700 Subject: [PATCH 02/14] validate form field suffixes --- .../kit/src/runtime/app/server/remote/form.js | 63 +++++++------- .../client/remote-functions/form.svelte.js | 62 +++++++------- packages/kit/src/runtime/form-utils.js | 57 ++++++++----- packages/kit/src/runtime/form-utils.spec.js | 82 ++++++++++++------- packages/kit/src/runtime/server/remote.js | 12 ++- packages/kit/src/types/internal.d.ts | 2 +- 6 files changed, 162 insertions(+), 116 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 5f42cf3df2b5..b612fc0487c1 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -163,7 +163,7 @@ export function form(validate_or_fn, maybe_fn) { // register under the client-side action id so the output is serialized // into the page, allowing the hydrated client to restore `result`/`issues`/`input` - get_implicit_lookup(__, state)[__.action_id ?? __.id] = () => cache['']; + get_implicit_lookup(__, state)[__.key ? `${__.id}/${__.key}` : __.id] = () => cache['']; } return output; @@ -173,7 +173,7 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, '__', { value: __ }); Object.defineProperty(instance, 'action', { - get: () => `?/remote=${__.id}`, + get: () => `?/remote=${__.key ? `${__.id}/${encodeURIComponent(__.key)}` : __.id}`, enumerable: true }); @@ -181,32 +181,30 @@ export function form(validate_or_fn, maybe_fn) { get() { // the form instance is created once per module and shared across requests, // so the current request's state has to be resolved at access time - return create_field_proxy( - {}, - { - get_input: () => get_cache(__, get_request_store().state)?.['']?.input ?? {}, - set_input: (path, value) => { - const cache = get_cache(__, get_request_store().state); - const entry = cache['']; - - if (entry?.submission) { - // don't override a submission - return; - } - - if (path.length === 0) { - (cache[''] ??= {}).input = value; - return; - } - - const input = entry?.input ?? {}; - deep_set(input, path.map(String), value); - (cache[''] ??= {}).input = input; - }, - get_issues: () => - flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []) - } - ); + return create_field_proxy({ + form_id: __.id, + get_input: () => get_cache(__, get_request_store().state)?.['']?.input ?? {}, + set_input: (path, value) => { + const cache = get_cache(__, get_request_store().state); + const entry = cache['']; + + if (entry?.submission) { + // don't override a submission + return; + } + + if (path.length === 0) { + (cache[''] ??= {}).input = value; + return; + } + + const input = entry?.input ?? {}; + deep_set(input, path.map(String), value); + (cache[''] ??= {}).input = input; + }, + get_issues: () => + flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []) + }); } }); @@ -270,12 +268,15 @@ export function form(validate_or_fn, maybe_fn) { value: (key) => { const { state } = get_request_store(); const cache_key = __.id + '|' + JSON.stringify(key); + /** @type {RemoteForm & { __: RemoteFormInternals }} */ let instance = (state.remote.forms ??= new Map()).get(cache_key); if (!instance) { - instance = create_instance(key); - instance.__.id = `${__.id}/${encodeURIComponent(JSON.stringify(key))}`; - instance.__.action_id = `${__.id}/${JSON.stringify(key)}`; + instance = /** @type {RemoteForm & { __: RemoteFormInternals }} */ ( + create_instance(key) + ); + instance.__.id = __.id; + instance.__.key = JSON.stringify(key); instance.__.name = __.name; state.remote.forms.set(cache_key, instance); 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 08bee764bd5b..65611a27e112 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -19,7 +19,8 @@ import { normalize_issue, serialize_binary_form, DELETE_KEY, - BINARY_FORM_CONTENT_TYPE + BINARY_FORM_CONTENT_TYPE, + validate_field_name } from '../../form-utils.js'; /** @@ -148,7 +149,7 @@ export function form(id) { * @returns {Record} */ function convert(form_data) { - const data = convert_formdata(form_data); + const data = convert_formdata(action_id_without_key, form_data); if (key !== undefined && !form_data.has('id')) { data.id = key; } @@ -417,10 +418,12 @@ export function form(id) { } if (event.submitter) { - const name = event.submitter.getAttribute('name'); + let name = event.submitter.getAttribute('name'); + const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { + name = validate_field_name(action_id_without_key, name); set_nested_value(input, name, value); } @@ -462,6 +465,7 @@ export function form(id) { let name = element.name; if (!name) return; + name = validate_field_name(action_id_without_key, name); const is_array = name.endsWith('[]'); if (is_array) name = name.slice(0, -2); @@ -534,7 +538,7 @@ export function form(id) { // the inputs are actually updated (so that it can be cancelled) await tick(); - input = convert_formdata(new FormData(form)); + input = convert_formdata(action_id_without_key, new FormData(form)); raw_issues = []; touched = {}; }; @@ -602,34 +606,32 @@ export function form(id) { }, fields: { get: () => - create_field_proxy( - {}, - { - get_input: () => input, - set_input: (path, value) => { - if (path.length === 0) { - input = value; - } else { - deep_set(input, path.map(String), value); - - const key = build_path_string(path); - touched[key] = true; - } - }, - get_issues: (path, all) => { - if (DEV && unread_issues !== null && path !== undefined) { - unread_issues = unread_issues.filter((issue) => { - return ( - (all ? issue.path.slice(0, path.length) : issue.path).join('.') !== - path.join('.') - ); - }); - } - - return issues; + create_field_proxy({ + form_id: action_id_without_key, + get_input: () => input, + set_input: (path, value) => { + if (path.length === 0) { + input = value; + } else { + deep_set(input, path.map(String), value); + + const key = build_path_string(path); + touched[key] = true; } + }, + get_issues: (path, all) => { + if (DEV && unread_issues !== null && path !== undefined) { + unread_issues = unread_issues.filter((issue) => { + return ( + (all ? issue.path.slice(0, path.length) : issue.path).join('.') !== + path.join('.') + ); + }); + } + + return issues; } - ) + }) }, result: { get: () => result diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 1597f13053b7..5d9e97ec00dc 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -28,22 +28,37 @@ export function set_nested_value(object, path_string, value) { deep_set(object, split_path(path_string), value); } +/** + * Validates that an input's name starts with the form's id, and strips the prefix. + * @param {string} form_id + * @param {string} name + */ +export function validate_field_name(form_id, name) { + if (!name.endsWith('/' + form_id)) { + throw new Error(`Form contained a field that wasn't created with form.fields.as(...): ${name}`); + } + return name.slice(0, -form_id.length - 1); +} + /** Pass this to set_nested_value to delete the last part of the given path */ export const DELETE_KEY = {}; /** * Convert `FormData` into a POJO + * @param {string} form_id * @param {FormData} data */ -export function convert_formdata(data) { +export function convert_formdata(form_id, data) { /** @type {Record} */ const result = {}; for (let key of data.keys()) { - const is_array = key.endsWith('[]'); /** @type {any[]} */ let values = data.getAll(key); + key = validate_field_name(form_id, key); + const is_array = key.endsWith('[]'); + if (is_array) key = key.slice(0, -2); // an empty `` will submit a non-existent file, bizarrely @@ -140,12 +155,13 @@ export function serialize_binary_form(data, meta) { /** * @param {Request} request + * @param {string} form_id * @returns {Promise<{ data: Record; meta: BinaryFormMeta; form_data: FormData | null }>} */ -export async function deserialize_binary_form(request) { +export async function deserialize_binary_form(request, form_id) { if (request.headers.get('content-type') !== BINARY_FORM_CONTENT_TYPE) { const form_data = await request.formData(); - return { data: convert_formdata(form_data), meta: {}, form_data }; + return { data: convert_formdata(form_id, form_data), meta: {}, form_data }; } if (!request.body) { throw deserialize_error('no body'); @@ -650,14 +666,19 @@ function deep_clone(value) { /** * Creates a proxy-based field accessor for form data + * @param {{ + * form_id: string, + * get_input: () => Record, + * set_input: (path: (string | number)[], value: any) => void, + * get_issues: (path?: (string | number)[], all?: boolean) => Record + * }} context - Form context, including value accessors and form metadata * @param {any} target - Function or empty POJO - * @param {{ get_input: () => Record, set_input: (path: (string | number)[], value: any) => void, get_issues: (path?: (string | number)[], all?: boolean) => Record }} accessors - Accessor functions * @param {(string | number)[]} path - Current access path * @returns {any} Proxy object with name(), value(), and issues() methods */ -export function create_field_proxy(target, accessors, path = []) { +export function create_field_proxy(context, target = {}, path = []) { const get_value = () => { - const value = deep_get(accessors.get_input(), path); + const value = deep_get(context.get_input(), path); return deep_clone(value); }; @@ -667,28 +688,26 @@ export function create_field_proxy(target, accessors, path = []) { // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy({}, accessors, [...path, parseInt(prop, 10)]); + return create_field_proxy(context, {}, [...path, parseInt(prop, 10)]); } const key = build_path_string(path); if (prop === 'set') { const set_func = function (/** @type {any} */ newValue) { - accessors.set_input(path, newValue); + context.set_input(path, newValue); return newValue; }; - return create_field_proxy(set_func, accessors, [...path, prop]); + return create_field_proxy(context, set_func, [...path, prop]); } if (prop === 'value') { - return create_field_proxy(get_value, accessors, [...path, prop]); + return create_field_proxy(context, get_value, [...path, prop]); } if (prop === 'issues' || prop === 'allIssues') { const issues_func = () => { - const all_issues = accessors.get_issues(path, prop === 'allIssues')[ - key === '' ? '$' : key - ]; + const all_issues = context.get_issues(path, prop === 'allIssues')[key === '' ? '$' : key]; if (prop === 'allIssues') { return all_issues?.map((issue) => ({ @@ -707,7 +726,7 @@ export function create_field_proxy(target, accessors, path = []) { return issues?.length ? issues : undefined; }; - return create_field_proxy(issues_func, accessors, [...path, prop]); + return create_field_proxy(context, issues_func, [...path, prop]); } if (prop === 'as') { @@ -726,9 +745,9 @@ export function create_field_proxy(target, accessors, path = []) { // Base properties for all input types /** @type {Record} */ const base_props = { - name: type_prefix + key + (is_array ? '[]' : ''), + name: type_prefix + key + (is_array ? '[]' : '') + '/' + context.form_id, get 'aria-invalid'() { - const issues = accessors.get_issues(); + const issues = context.get_issues(); return key in issues ? 'true' : undefined; } }; @@ -873,11 +892,11 @@ export function create_field_proxy(target, accessors, path = []) { }); }; - return create_field_proxy(as_func, accessors, [...path, 'as']); + return create_field_proxy(context, as_func, [...path, 'as']); } // Handle property access (nested fields) - return create_field_proxy({}, accessors, [...path, prop]); + return create_field_proxy(context, {}, [...path, prop]); } }); } diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 74a7d549fcd6..5802c58ffd54 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -62,14 +62,14 @@ describe('convert_formdata', () => { test('converts a FormData object', () => { const data = new FormData(); - data.append('foo', 'foo'); + data.append('foo/form', 'foo'); - data.append('object.nested.property', 'property'); - data.append('array[]', 'a'); - data.append('array[]', 'b'); - data.append('array[]', 'c'); + data.append('object.nested.property/form', 'property'); + data.append('array[]/form', 'a'); + data.append('array[]/form', 'b'); + data.append('array[]/form', 'c'); - const converted = convert_formdata(data); + const converted = convert_formdata('form', data); expect(converted).toEqual({ foo: 'foo', @@ -85,10 +85,10 @@ describe('convert_formdata', () => { test('handles multiple fields at the same nested level', () => { const data = new FormData(); - data.append('user.name.first', 'first'); - data.append('user.name.last', 'last'); + data.append('user.name.first/form', 'first'); + data.append('user.name.last/form', 'last'); - const converted = convert_formdata(data); + const converted = convert_formdata('form', data); expect(converted).toEqual({ user: { @@ -103,24 +103,33 @@ describe('convert_formdata', () => { test('omits empty file inputs', () => { const data = new FormData(); - data.append('file', new File([], '')); + data.append('file/form', new File([], '')); - expect(convert_formdata(data)).toEqual({}); + expect(convert_formdata('form', data)).toEqual({}); }); test('keeps real zero-byte files', () => { const data = new FormData(); const file = new File([], 'empty.txt'); - data.append('file', file); + data.append('file/form', file); - expect(convert_formdata(data)).toEqual({ file }); + expect(convert_formdata('form', data)).toEqual({ file }); + }); + + test('rejects field names without the form id suffix', () => { + const data = new FormData(); + data.append('foo/other/form', 'foo'); + + expect(() => convert_formdata('/this/form', data)).toThrow( + /wasn't created with form.fields.as/ + ); }); test.each(POLLUTION_ATTACKS)('prevents prototype pollution: %s', (attack) => { const data = new FormData(); - data.append(attack, 'bad'); - expect(() => convert_formdata(data)).toThrow(/Invalid key "/); + data.append(attack + '/form', 'bad'); + expect(() => convert_formdata('form', data)).toThrow(/Invalid key "/); }); }); @@ -151,7 +160,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); expect(res.form_data).toBeNull(); expect(res.data).toEqual(input.data); @@ -189,7 +199,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); const { small, large, empty } = res.data; expect(empty.name).toBe('empty.txt'); @@ -230,7 +241,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); /** @type {File} */ const file = res.data.file; @@ -263,7 +275,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE // No Content-Length — simulates proxy stripping it } - }) + }), + '' ); expect(res.data.foo).toBe('bar'); expect(res.data.file.name).toBe('hello.txt'); @@ -288,7 +301,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); expect(res.data.foo).toBe('bar'); expect(res.data.file.name).toBe('hello.txt'); @@ -360,7 +374,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': (header_bytes + data_length).toString() } - }) + }), + '' ) ).rejects.toThrow('data too short'); } finally { @@ -419,7 +434,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': total.toString() } - }) + }), + '' ) ).rejects.toThrow('invalid file metadata'); }, 1000); @@ -442,7 +458,7 @@ describe('binary form serializer', () => { payload: '[[1,3],{"file":2},["File",4],{},[-2,-2,7],"a.txt","text/plain",0]' } ])('rejects invalid file metadata: $name', async ({ payload }) => { - await expect(deserialize_binary_form(build_raw_request(payload))).rejects.toThrow( + await expect(deserialize_binary_form(build_raw_request(payload), '')).rejects.toThrow( 'invalid file metadata' ); }); @@ -480,7 +496,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': total.toString() } - }) + }), + '' ) ).rejects.toThrow('invalid file offset table'); }, 1000); @@ -512,7 +529,8 @@ describe('binary form serializer', () => { build_raw_request( '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",0,0,0]', offsets - ) + ), + '' ) ).rejects.toThrow('invalid file offset table'); }); @@ -541,7 +559,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); expect(res.data).toEqual({ a: 1 }); @@ -625,7 +644,7 @@ describe('binary form serializer', () => { // file_offsets: [0, 1] — file a starts at 0, file b starts at 1. // With size=3 each, they overlap (0..3 and 1..4). await expect( - deserialize_binary_form(build_raw_request_with_files(payload, '[0,1]', 4)) + deserialize_binary_form(build_raw_request_with_files(payload, '[0,1]', 4), '') ).rejects.toThrow('overlapping file data'); }); @@ -635,7 +654,7 @@ describe('binary form serializer', () => { const payload = '[[1,3],{"a":2,"b":4},["File",6],{},["File",7],0,[8,9,10,11,12],[8,9,10,11,12],"a.txt","text/plain",1,0,0]'; await expect( - deserialize_binary_form(build_raw_request_with_files(payload, '[0]', 1)) + deserialize_binary_form(build_raw_request_with_files(payload, '[0]', 1), '') ).rejects.toThrow('duplicate file offset table index'); }); @@ -646,7 +665,7 @@ describe('binary form serializer', () => { '[[1,3],{"a":2,"b":4},["File",6],{},["File",7],0,[8,9,10,11,12],[8,9,10,11,13],"a.txt","text/plain",1,0,0,1]'; // file_offsets: [0, 3] — file a at 0 (size 1), file b at 3 (size 1), gap at 1..3. await expect( - deserialize_binary_form(build_raw_request_with_files(payload, '[0,3]', 4)) + deserialize_binary_form(build_raw_request_with_files(payload, '[0,3]', 4), '') ).rejects.toThrow('gaps in file data'); }); @@ -707,7 +726,7 @@ describe('binary form serializer', () => { const offsets = JSON.stringify(new Array(file_count).fill(0)); await expect( - deserialize_binary_form(build_raw_request_with_files(payload, offsets, file_size)) + deserialize_binary_form(build_raw_request_with_files(payload, offsets, file_size), '') ).rejects.toThrow('overlapping file data'); }, 1000); @@ -730,7 +749,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); expect(res.data.a.size).toBe(0); expect(res.data.b.size).toBe(0); diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 0d7526f53ccc..4f0ac83850d5 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -208,7 +208,11 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - const { data: input, meta, form_data } = await deserialize_binary_form(event.request); + const { + data: input, + meta, + form_data + } = await deserialize_binary_form(event.request, internals.id); state.remote.requested = create_requested_map(meta.remote_refreshes); // If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set) @@ -500,9 +504,9 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { } try { - const fn = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__).fn; + const __ = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__); - const { data, meta, form_data } = await deserialize_binary_form(event.request); + const { data, meta, form_data } = await deserialize_binary_form(event.request, __.id); if (action_id && !('id' in data)) { data.id = JSON.parse(decodeURIComponent(action_id)); @@ -510,7 +514,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { await with_request_store( { event, state: { ...state, is_in_remote_form_or_command: true } }, - () => fn(data, meta, form_data) + () => __.fn(data, meta, form_data) ); // We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it. diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index bbf3e5c3a4e7..05c831bc0de9 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -675,7 +675,7 @@ export interface RemoteFormInternals extends BaseRemoteInternals { * For keyed (`form.for(key)`) instances: the id as the client computes it * (the key is JSON-stringified but not URI-encoded, unlike `id`) */ - action_id?: string; + key?: string; fn(body: Record, meta: BinaryFormMeta, form_data: FormData | null): Promise; } From 47a9dcc008ff1e9df25153a11adabf4742289f1e Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 21:10:59 -0700 Subject: [PATCH 03/14] changeset --- .changeset/easy-items-smile.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/easy-items-smile.md diff --git a/.changeset/easy-items-smile.md b/.changeset/easy-items-smile.md new file mode 100644 index 000000000000..e6b13aebd0bf --- /dev/null +++ b/.changeset/easy-items-smile.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: validate that all remote form fields were created with form.fields.foo.as(...) From d935f3fc1e5f136bc13391eea2f1da56671aec1c Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 21:17:30 -0700 Subject: [PATCH 04/14] fix test --- packages/kit/src/runtime/form-utils.spec.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 563d26aad4d3..ae93b11b0319 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -787,13 +787,14 @@ describe('create_field_proxy', () => { const input = { created_at: original }; const proxy = create_field_proxy( - {}, - () => input, - () => {}, - () => ({}), - () => ({}), - () => ({}), - [] + { + form_id: 'form', + get: () => input, + set: () => {}, + get_issues: () => ({}), + get_touched: () => ({}), + get_dirty: () => ({}) + }, ); const cloned = proxy.created_at.value(); From 14bd5c3747dd78c4b556f6a49e8404cf0019e158 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 21:18:23 -0700 Subject: [PATCH 05/14] format --- packages/kit/src/runtime/form-utils.spec.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index ae93b11b0319..85c10993b5a0 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -786,16 +786,14 @@ describe('create_field_proxy', () => { const original = new Date('2025-06-25T00:00:00Z'); const input = { created_at: original }; - const proxy = create_field_proxy( - { - form_id: 'form', - get: () => input, - set: () => {}, - get_issues: () => ({}), - get_touched: () => ({}), - get_dirty: () => ({}) - }, - ); + const proxy = create_field_proxy({ + form_id: 'form', + get: () => input, + set: () => {}, + get_issues: () => ({}), + get_touched: () => ({}), + get_dirty: () => ({}) + }); const cloned = proxy.created_at.value(); From d65134303b33e22f7e59b84ae71593d5192f23a8 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Sun, 12 Jul 2026 21:28:01 -0700 Subject: [PATCH 06/14] fix ai stuff --- .../src/runtime/client/remote-functions/form.svelte.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index ff43c5c06ab3..70201b3793d1 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -162,7 +162,7 @@ export function form(id) { */ function convert(form_data) { const data = convert_formdata(action_id_without_key, form_data); - if (key !== undefined && !form_data.has('id')) { + if (key !== undefined && !('id' in data)) { data.id = key; } return data; @@ -407,7 +407,9 @@ export function form(id) { if ( previous_submitter_name !== null && - !Array.from(form_data.keys()).map(strip_prefix).includes(previous_submitter_name) + !Array.from(form_data.keys()) + .map((k) => strip_prefix(validate_field_name(action_id_without_key, k))) + .includes(previous_submitter_name) ) { // Strip any `n:`/`b:` type prefix before clearing, otherwise // `set_nested_value` would coerce `undefined` to `NaN`/`false` @@ -552,7 +554,7 @@ export function form(id) { let name = /** @type {HTMLInputElement} */ (e.target).name; if (!name) return; - name = strip_prefix(name).replace(/\[\]$/, ''); + name = strip_prefix(validate_field_name(action_id_without_key, name)).replace(/\[\]$/, ''); touched[name] = true; From 69896f3f9f8fbf06a8957b1273b4bb65f9793d89 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 13 Jul 2026 15:42:22 -0400 Subject: [PATCH 07/14] fix most, but not all, tests --- .../kit/test/apps/async/test/client.test.js | 22 +++--- packages/kit/test/apps/async/test/test.js | 74 +++++++++---------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 30474d7d8137..b7ecdde572b7 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -419,7 +419,7 @@ test.describe('remote function mutations', () => { test('fields.set updates DOM before validate', async ({ page }) => { await page.goto('/remote/form/imperative'); - const input = page.locator('input[name="message"]'); + const input = page.locator('input[name^="message"]'); await input.fill('123'); await page.locator('#set-and-validate').click(); @@ -981,8 +981,8 @@ test.describe('remote function mutations', () => { const form1 = page.locator('form').nth(0); - const text = form1.locator('input[name="text_field"]'); - const checkbox = form1.locator('input[name="b:checkbox_field"]'); + const text = form1.locator('input[name^="text_field"]'); + const checkbox = form1.locator('input[name^="b:checkbox_field"]'); // initial values rendered correctly await expect(text).toHaveValue('Example text'); @@ -1061,7 +1061,7 @@ test.describe('remote function mutations', () => { page }) => { await page.goto('/remote/form/native-result'); - await page.locator('#plain input[name="message"]').fill('hello'); + await page.locator('#plain input[name^="message"]').fill('hello'); await page.click('#plain button'); // wait for the page resulting from the full-page POST to hydrate @@ -1073,20 +1073,20 @@ test.describe('remote function mutations', () => { page }) => { await page.goto('/remote/form/native-result'); - await page.locator('#plain input[name="message"]').fill('ab'); + await page.locator('#plain input[name^="message"]').fill('ab'); await page.click('#plain button'); // wait for the page resulting from the full-page POST to hydrate await expect(page.locator('#hydrated')).toHaveText('true'); await expect(page.locator('#issue')).toHaveText('too short'); - await expect(page.locator('#plain input[name="message"]')).toHaveValue('ab'); + await expect(page.locator('#plain input[name^="message"]')).toHaveValue('ab'); }); test('keyed form result from a native (non-enhanced) submission survives hydration', async ({ page }) => { await page.goto('/remote/form/native-result'); - await page.locator('#keyed input[name="message"]').fill('hello'); + await page.locator('#keyed input[name^="message"]').fill('hello'); await page.click('#keyed button'); // wait for the page resulting from the full-page POST to hydrate @@ -1100,7 +1100,7 @@ test.describe('remote function mutations', () => { page }) => { await page.goto('/remote/form/native-result'); - await page.locator('#keyed-slash input[name="message"]').fill('hello'); + await page.locator('#keyed-slash input[name^="message"]').fill('hello'); await page.click('#keyed-slash button'); // wait for the page resulting from the full-page POST to hydrate @@ -1126,7 +1126,7 @@ test.describe('remote function mutations', () => { test('form submission with element id `reset` resets the form', async ({ page }) => { await page.goto('/remote/form/reset-id'); - await page.locator('[name="message"]').fill('short'); + await page.locator('[name^="message"]').fill('short'); await page.click('button'); await expect(page.locator('.error')).toHaveText('too short'); @@ -1134,11 +1134,11 @@ test.describe('remote function mutations', () => { await page.click('[type="reset"]'); await expect(page.locator('.error')).toHaveCount(0); - await page.locator('[name="message"]').fill('long enough'); + await page.locator('[name^="message"]').fill('long enough'); await page.click('button'); await expect(page.locator('#result')).toHaveText('long enough'); - await expect(page.locator('[name="message"]')).toBeEmpty(); + await expect(page.locator('[name^="message"]')).toBeEmpty(); }); }); diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 353e7bc8bdd8..002165447695 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -148,7 +148,7 @@ test.describe('remote functions', () => { await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); await expect(page.getByText('set_message.result')).toHaveText('set_message.result: hello'); - await expect(page.locator('[data-unscoped] input[name="message"]')).toHaveValue(''); + await expect(page.locator('[data-unscoped] input[name^="message"]')).toHaveValue(''); }); test('form submitters work', async ({ page }) => { @@ -302,7 +302,7 @@ test.describe('remote functions', () => { await expect(page.getByText('scoped.result')).toHaveText( 'scoped.result: hello (from: scoped:form-scoped)' ); - await expect(page.locator('[data-scoped] input[name="message"]')).toHaveValue(''); + await expect(page.locator('[data-scoped] input[name^="message"]')).toHaveValue(''); }); test('form enhance(...) works', async ({ page, javaScriptEnabled }) => { @@ -324,7 +324,7 @@ test.describe('remote functions', () => { await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); // 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.locator('[data-enhanced] input[name^="message"]')).toHaveValue('hello'); await expect(page.getByText('enhanced.callback_element_matches:')).toHaveText( 'enhanced.callback_element_matches: true' ); @@ -332,7 +332,7 @@ test.describe('remote functions', () => { 'enhanced.callback_has_enhance: false' ); } else { - await expect(page.locator('[data-enhanced] input[name="message"]')).toHaveValue(''); + await expect(page.locator('[data-enhanced] input[name^="message"]')).toHaveValue(''); } await expect(page.getByText('enhanced.result')).toHaveText( @@ -481,7 +481,7 @@ test.describe('remote functions', () => { await page.goto('/remote/form/preflight-only'); - const a = page.locator('[name="a"]'); + const a = page.locator('[name^="a"]'); const button = page.locator('button'); const issues = page.locator('.issues'); @@ -507,8 +507,8 @@ test.describe('remote functions', () => { await page.goto('/remote/form/validate'); const myForm = page.locator('form#my-form'); - const foo = page.locator('input[name="foo"]'); - const bar = page.locator('input[name="bar"]'); + const foo = page.locator('input[name^="foo"]'); + const bar = page.locator('input[name^="bar"]'); const submit = page.locator('button:has-text("imperative validation")'); await foo.fill('a'); @@ -536,7 +536,7 @@ test.describe('remote functions', () => { await submit.click(); await expect(myForm).toContainText('Imperative: foo cannot be c'); - const nestedValue = page.locator('input[name="nested.value"]'); + const nestedValue = page.locator('input[name^="nested.value"]'); const validate = page.locator('button#validate'); const allIssues = page.locator('#allIssues'); @@ -550,7 +550,7 @@ test.describe('remote functions', () => { await page.goto('/remote/form/validate'); - const baz = page.locator('input[name="baz"]'); + const baz = page.locator('input[name^="baz"]'); const submit = page.locator('#my-form-2 button'); await baz.fill('c'); @@ -577,12 +577,12 @@ test.describe('remote functions', () => { await page.goto('/remote/form/underscore'); - await page.fill('input[name="username"]', 'abcdefg'); - await page.fill('input[name="_password"]', 'pqrstuv'); + await page.fill('input[name^="username"]', 'abcdefg'); + await page.fill('input[name^="_password"]', 'pqrstuv'); await page.locator('button').click(); - await expect(page.locator('input[name="username"]')).toHaveValue('abcdefg'); - await expect(page.locator('input[name="_password"]')).toHaveValue(''); + await expect(page.locator('input[name^="username"]')).toHaveValue('abcdefg'); + await expect(page.locator('input[name^="_password"]')).toHaveValue(''); }); test('prerendered entries not called in prod', async ({ page, clicknav }) => { @@ -608,14 +608,14 @@ test.describe('remote functions', () => { expect(initialValue ? JSON.parse(initialValue) : null).toEqual({}); // Fill leaf field - await page.fill('input[name="leaf"]', 'leaf-value'); + await page.fill('input[name^="leaf"]', 'leaf-value'); const afterLeaf = await page.locator('#full-value').textContent(); expect(afterLeaf ? JSON.parse(afterLeaf) : null).toEqual({ leaf: 'leaf-value' }); // Fill object.leaf field - await page.fill('input[name="object.leaf"]', 'object-leaf-value'); + await page.fill('input[name^="object.leaf"]', 'object-leaf-value'); const afterObjectLeaf = await page.locator('#full-value').textContent(); expect(afterObjectLeaf ? JSON.parse(afterObjectLeaf) : null).toEqual({ leaf: 'leaf-value', @@ -625,7 +625,7 @@ test.describe('remote functions', () => { }); // Fill object.array fields - await page.fill('input[name="object.array[0]"]', 'array-item-1'); + await page.fill('input[name^="object.array[0]"]', 'array-item-1'); const afterArrayItem1 = await page.locator('#full-value').textContent(); expect(afterArrayItem1 ? JSON.parse(afterArrayItem1) : null).toEqual({ leaf: 'leaf-value', @@ -635,7 +635,7 @@ test.describe('remote functions', () => { } }); - await page.fill('input[name="object.array[1]"]', 'array-item-2'); + await page.fill('input[name^="object.array[1]"]', 'array-item-2'); const afterArrayItem2 = await page.locator('#full-value').textContent(); expect(afterArrayItem2 ? JSON.parse(afterArrayItem2) : null).toEqual({ leaf: 'leaf-value', @@ -646,7 +646,7 @@ test.describe('remote functions', () => { }); // Fill array[0].leaf field - await page.fill('input[name="array[0].leaf"]', 'array-0-leaf'); + await page.fill('input[name^="array[0].leaf"]', 'array-0-leaf'); const afterArray0 = await page.locator('#full-value').textContent(); expect(afterArray0 ? JSON.parse(afterArray0) : null).toEqual({ leaf: 'leaf-value', @@ -658,7 +658,7 @@ test.describe('remote functions', () => { }); // Fill array[1].leaf field - await page.fill('input[name="array[1].leaf"]', 'array-1-leaf'); + await page.fill('input[name^="array[1].leaf"]', 'array-1-leaf'); const afterArray1 = await page.locator('#full-value').textContent(); expect(afterArray1 ? JSON.parse(afterArray1) : null).toEqual({ leaf: 'leaf-value', @@ -692,14 +692,14 @@ test.describe('remote functions', () => { await page.goto('/remote/form/snapshot'); - await page.fill('input[name="a.b.c"]', 'original'); + await page.fill('input[name^="a.b.c"]', 'original'); await page.getByRole('button', { name: 'submit' }).click(); // wait until the snapshot has been taken and the submission is in flight await expect(page.locator('#status')).toHaveText('status: submitting'); // mutate the form state *after* the snapshot was taken - await page.fill('input[name="a.b.c"]', 'changed'); + await page.fill('input[name^="a.b.c"]', 'changed'); // let the submission complete await page.getByRole('button', { name: 'release' }).click(); @@ -756,12 +756,12 @@ test.describe('remote functions', () => { test('file uploads work', async ({ page }) => { await page.goto('/remote/form/file-upload'); - await page.locator('input[name="file1"]').setInputFiles({ + await page.locator('input[name^="file1"]').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') }); - await page.locator('input[name="file2"]').setInputFiles({ + await page.locator('input[name^="file2"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') @@ -780,12 +780,12 @@ test.describe('remote functions', () => { test('large file uploads work', async ({ page }) => { await page.goto('/remote/form/file-upload'); - await page.locator('input[name="file1"]').setInputFiles({ + await page.locator('input[name^="file1"]').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.alloc(1024 * 1024 * 10) }); - await page.locator('input[name="file2"]').setInputFiles({ + await page.locator('input[name^="file2"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') @@ -908,20 +908,20 @@ test.describe('remote functions', () => { const form2 = page.locator('form').nth(1); // first record values - await expect(form1.locator('input[name="text_field"]')).toHaveValue('Example text'); - await expect(form1.locator('input[name="n:number_field"]')).toHaveValue('42'); - await expect(form1.locator('select[name="select_field"]')).toHaveValue('apple'); - await expect(form1.locator('input[name="color_field"]')).toHaveValue('#ff0000'); - await expect(form1.locator('input[name="n:range_field"]')).toHaveValue('5'); - await expect(form1.locator('input[name="b:checkbox_field"]')).toBeChecked(); + await expect(form1.locator('input[name^="text_field"]')).toHaveValue('Example text'); + await expect(form1.locator('input[name^="n:number_field"]')).toHaveValue('42'); + await expect(form1.locator('select[name^="select_field"]')).toHaveValue('apple'); + await expect(form1.locator('input[name^="color_field"]')).toHaveValue('#ff0000'); + await expect(form1.locator('input[name^="n:range_field"]')).toHaveValue('5'); + await expect(form1.locator('input[name^="b:checkbox_field"]')).toBeChecked(); // second record values - await expect(form2.locator('input[name="text_field"]')).toHaveValue('Another example'); - await expect(form2.locator('input[name="n:number_field"]')).toHaveValue('100'); - await expect(form2.locator('select[name="select_field"]')).toHaveValue('banana'); - await expect(form2.locator('input[name="color_field"]')).toHaveValue('#ffff00'); - await expect(form2.locator('input[name="n:range_field"]')).toHaveValue('8'); - await expect(form2.locator('input[name="b:checkbox_field"]')).not.toBeChecked(); + await expect(form2.locator('input[name^="text_field"]')).toHaveValue('Another example'); + await expect(form2.locator('input[name^="n:number_field"]')).toHaveValue('100'); + await expect(form2.locator('select[name^="select_field"]')).toHaveValue('banana'); + await expect(form2.locator('input[name^="color_field"]')).toHaveValue('#ffff00'); + await expect(form2.locator('input[name^="n:range_field"]')).toHaveValue('8'); + await expect(form2.locator('input[name^="b:checkbox_field"]')).not.toBeChecked(); }); }); From fcaec376b3110b2a8af17b37f3ff466d791700b4 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Mon, 13 Jul 2026 12:53:13 -0700 Subject: [PATCH 08/14] fix last unhandled place --- packages/kit/src/runtime/app/server/remote/form.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 9fd3abfdb9b7..28d0153f9367 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -7,7 +7,8 @@ import { set_nested_value, deep_set, normalize_issue, - flatten_issues + flatten_issues, + validate_field_name } from '../../../form-utils.js'; import { get_cache, get_implicit_lookup, run_remote_function } from './shared.js'; import { ValidationError } from '@sveltejs/kit/internal'; @@ -102,7 +103,7 @@ export function form(validate_or_fn, maybe_fn) { } if (validated?.issues !== undefined) { - handle_issues(output, validated.issues, form_data); + handle_issues(output, validated.issues, form_data, __.id); } else { if (validated !== undefined) { data = validated.value; @@ -120,7 +121,7 @@ export function form(validate_or_fn, maybe_fn) { ); } catch (e) { if (e instanceof ValidationError) { - handle_issues(output, e.issues, form_data); + handle_issues(output, e.issues, form_data, __.id); } else { throw e; } @@ -257,8 +258,9 @@ export function form(validate_or_fn, maybe_fn) { * @param {{ issues?: InternalRemoteFormIssue[], input?: Record, result: any }} output * @param {readonly StandardSchemaV1.Issue[]} issues * @param {FormData | null} form_data - null if the form is progressively enhanced + * @param {string} form_id - hash/name of the form */ -function handle_issues(output, issues, form_data) { +function handle_issues(output, issues, form_data, form_id) { output.issues = issues.map((issue) => normalize_issue(issue, true)); // if it was a progressively-enhanced submission, we don't need @@ -270,6 +272,8 @@ function handle_issues(output, issues, form_data) { // redact sensitive fields if (/^[.\]]?_/.test(key)) continue; + key = validate_field_name(form_id, key); + const is_array = key.endsWith('[]'); const values = form_data.getAll(key).filter((value) => typeof value === 'string'); From 868535a818132d1f8f7777fc77e9d6752a7bd839 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Mon, 13 Jul 2026 12:58:26 -0700 Subject: [PATCH 09/14] reorder --- packages/kit/src/runtime/app/server/remote/form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 28d0153f9367..c0f36ec83b8c 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -272,10 +272,10 @@ function handle_issues(output, issues, form_data, form_id) { // redact sensitive fields if (/^[.\]]?_/.test(key)) continue; + const values = form_data.getAll(key).filter((value) => typeof value === 'string'); key = validate_field_name(form_id, key); const is_array = key.endsWith('[]'); - const values = form_data.getAll(key).filter((value) => typeof value === 'string'); if (is_array) key = key.slice(0, -2); From c5c278a9afaba09d86fd9985d3602ba87f56e9a4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 23 Jul 2026 06:19:51 -0400 Subject: [PATCH 10/14] fix --- packages/kit/test/apps/options-2/src/routes/remote/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/options-2/src/routes/remote/+page.svelte b/packages/kit/test/apps/options-2/src/routes/remote/+page.svelte index d3995c04acc4..88cc516e58cc 100644 --- a/packages/kit/test/apps/options-2/src/routes/remote/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/remote/+page.svelte @@ -17,7 +17,7 @@ count = await get_count(); })} > - + From 53bf813d5981d4cc740c33c24bb83ddaf8f49d64 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 23 Jul 2026 06:47:47 -0400 Subject: [PATCH 11/14] DRY out --- .../kit/src/runtime/app/server/remote/form.js | 18 ++-- .../client/remote-functions/form.svelte.js | 68 ++++++--------- packages/kit/src/runtime/form-utils.js | 86 +++++++++++-------- packages/kit/src/runtime/form-utils.spec.js | 34 ++++++++ 4 files changed, 119 insertions(+), 87 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index c0f36ec83b8c..1244359de4a7 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -8,7 +8,7 @@ import { deep_set, normalize_issue, flatten_issues, - validate_field_name + parse_form_field_name } from '../../../form-utils.js'; import { get_cache, get_implicit_lookup, run_remote_function } from './shared.js'; import { ValidationError } from '@sveltejs/kit/internal'; @@ -268,21 +268,17 @@ function handle_issues(output, issues, form_data, form_id) { if (form_data) { output.input = {}; - for (let key of form_data.keys()) { + for (const field_name of form_data.keys()) { // redact sensitive fields - if (/^[.\]]?_/.test(key)) continue; + if (/^[.\]]?_/.test(field_name)) continue; - const values = form_data.getAll(key).filter((value) => typeof value === 'string'); - key = validate_field_name(form_id, key); - - const is_array = key.endsWith('[]'); - - if (is_array) key = key.slice(0, -2); + const values = form_data.getAll(field_name).filter((value) => typeof value === 'string'); + const field = parse_form_field_name(form_id, field_name); set_nested_value( /** @type {Record} */ (output.input), - key, - is_array ? values : values[0] + field, + field.is_array ? values : values[0] ); } } 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 70201b3793d1..fb8fe2c55a81 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -26,7 +26,7 @@ import { deep_get, DELETE_KEY, BINARY_FORM_CONTENT_TYPE, - validate_field_name + parse_form_field_name } from '../../form-utils.js'; /** @@ -124,8 +124,8 @@ export function form(id) { /** @type {InternalRemoteFormIssue[] | null} */ let unread_issues = null; - /** @type {string | null} */ - let previous_submitter_name = null; + /** @type {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean } | null} */ + let previous_submitter = null; /** * In dev, warn if there are validation issues going unread @@ -406,30 +406,28 @@ export function form(id) { const form_data = new FormData(form, event.submitter); if ( - previous_submitter_name !== null && + previous_submitter !== null && !Array.from(form_data.keys()) - .map((k) => strip_prefix(validate_field_name(action_id_without_key, k))) - .includes(previous_submitter_name) + .map((name) => parse_form_field_name(action_id_without_key, name).name) + .includes(previous_submitter.name) ) { - // Strip any `n:`/`b:` type prefix before clearing, otherwise - // `set_nested_value` would coerce `undefined` to `NaN`/`false` - // instead of clearing the previously-submitted value. - set_nested_value(input, previous_submitter_name, undefined); + set_nested_value(input, previous_submitter, undefined); } if (event.submitter) { let name = event.submitter.getAttribute('name'); + let submitter = null; const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { - name = validate_field_name(action_id_without_key, name); - set_nested_value(input, name, value); + submitter = parse_form_field_name(action_id_without_key, name); + set_nested_value(input, submitter, value); } - previous_submitter_name = strip_prefix(name); + previous_submitter = submitter; } else { - previous_submitter_name = null; + previous_submitter = null; } if (DEV) { @@ -468,16 +466,14 @@ export function form(id) { // but that makes the types unnecessarily awkward const element = /** @type {HTMLInputElement} */ (e.target); - let name = element.name; + const name = element.name; if (!name) return; - name = validate_field_name(action_id_without_key, name); - const is_array = name.endsWith('[]'); - if (is_array) name = name.slice(0, -2); + const field = parse_form_field_name(action_id_without_key, name); const is_file = element.type === 'file'; - if (is_array) { + if (field.is_array) { let value; if (element.tagName === 'SELECT') { @@ -487,7 +483,7 @@ export function form(id) { ); } else { const elements = /** @type {HTMLInputElement[]} */ ( - Array.from(form.querySelectorAll(`[name="${name}[]"]`)) + Array.from(form.querySelectorAll(`[name="${name}"]`)) ); if (DEV) { @@ -508,7 +504,7 @@ export function form(id) { } } - set_nested_value(input, name, value); + set_nested_value(input, field, value); } else if (is_file) { if (DEV && element.multiple) { throw new Error( @@ -519,21 +515,19 @@ export function form(id) { const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; if (file) { - set_nested_value(input, name, file); + set_nested_value(input, field, file); } else { - set_nested_value(input, name, DELETE_KEY); + set_nested_value(input, field, DELETE_KEY); } } else { set_nested_value( input, - name, + field, element.type === 'checkbox' && !element.checked ? null : element.value ); } - name = strip_prefix(name); - - dirty[name] = true; + dirty[field.name] = true; }; const handle_reset = async () => { @@ -551,15 +545,15 @@ export function form(id) { /** @param {Event} e */ const handle_focusout = (e) => { - let name = /** @type {HTMLInputElement} */ (e.target).name; + const name = /** @type {HTMLInputElement} */ (e.target).name; if (!name) return; - name = strip_prefix(validate_field_name(action_id_without_key, name)).replace(/\[\]$/, ''); + const { name: field_name } = parse_form_field_name(action_id_without_key, name); - touched[name] = true; + touched[field_name] = true; - if (Object.hasOwn(dirty, name)) { - can_validate[name] = true; + if (Object.hasOwn(dirty, field_name)) { + can_validate[field_name] = true; } }; @@ -814,13 +808,3 @@ function validate_form_data(form_data, enctype) { } } } - -/** - * Remove the `n:` or `b:` prefix from a field name - * @template {string | null} T - * @param {T} name - * @returns {T} - */ -function strip_prefix(name) { - return /** @type {T} */ (name && name.replace(/^[nb]:/, '')); -} diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 163a1330a8b0..e72486ab5bfd 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -10,33 +10,60 @@ import { SvelteKitError } from '@sveltejs/kit/internal'; const decoder = new TextDecoder(); /** - * Sets a value in a nested object using a path string, mutating the original object + * Sets a parsed form field value in a nested object, mutating the original object. * @param {Record} object - * @param {string} path_string + * @param {{ name: string; type: 'number' | 'boolean' | null }} field * @param {any} value */ -export function set_nested_value(object, path_string, value) { - if (path_string.startsWith('n:')) { - path_string = path_string.slice(2); - value = value === '' ? undefined : parseFloat(value); - } else if (path_string.startsWith('b:')) { - path_string = path_string.slice(2); - value = value === 'on'; - } - - deep_set(object, split_path(path_string), value); +export function set_nested_value(object, field, value) { + deep_set(object, split_path(field.name), coerce_form_value(field.type, value)); } /** - * Validates that an input's name starts with the form's id, and strips the prefix. + * Separates a form field's path from the metadata encoded in its name. * @param {string} form_id * @param {string} name + * @returns {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean }} */ -export function validate_field_name(form_id, name) { +export function parse_form_field_name(form_id, name) { if (!name.endsWith('/' + form_id)) { throw new Error(`Form contained a field that wasn't created with form.fields.as(...): ${name}`); } - return name.slice(0, -form_id.length - 1); + + return parse_form_field_path(name.slice(0, -form_id.length - 1)); +} + +/** + * @param {string} name + */ +function parse_form_field_path(name) { + /** @type {'number' | 'boolean' | null} */ + let type = null; + + if (name.startsWith('n:')) { + name = name.slice(2); + type = 'number'; + } else if (name.startsWith('b:')) { + name = name.slice(2); + type = 'boolean'; + } + + const is_array = name.endsWith('[]'); + if (is_array) name = name.slice(0, -2); + + return { name, type, is_array }; +} + +/** + * @param {'number' | 'boolean' | null} type + * @param {any} value + * @returns {any} + */ +function coerce_form_value(type, value) { + if (Array.isArray(value)) return value.map((value) => coerce_form_value(type, value)); + if (type === 'number') return value === '' ? undefined : parseFloat(value); + if (type === 'boolean') return value === 'on'; + return value; } /** Pass this to set_nested_value to delete the last part of the given path */ @@ -51,34 +78,25 @@ export function convert_formdata(form_id, data) { /** @type {Record} */ const result = {}; - for (let key of data.keys()) { + for (const field_name of data.keys()) { /** @type {any[]} */ - let values = data.getAll(key); - - key = validate_field_name(form_id, key); - const is_array = key.endsWith('[]'); + const values = data.getAll(field_name); - if (is_array) key = key.slice(0, -2); + const field = parse_form_field_name(form_id, field_name); // an empty `` will submit a non-existent file, bizarrely - values = values.filter( + const entries = values.filter( (entry) => typeof entry === 'string' || entry.name !== '' || entry.size > 0 ); - if (values.length === 0 && !is_array) continue; - - if (key.startsWith('n:')) { - key = key.slice(2); - values = values.map((v) => (v === '' ? undefined : parseFloat(/** @type {string} */ (v)))); - } else if (key.startsWith('b:')) { - key = key.slice(2); - values = values.map((v) => v === 'on'); - } + if (entries.length === 0 && !field.is_array) continue; - if (values.length > 1 && !is_array) { - throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`); + if (entries.length > 1 && !field.is_array) { + throw new Error( + `Form cannot contain duplicated keys — "${field.name}" has ${entries.length} values` + ); } - set_nested_value(result, key, is_array ? values : values[0]); + set_nested_value(result, field, field.is_array ? entries : entries[0]); } return result; diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 85c10993b5a0..836250de91dd 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -6,6 +6,7 @@ import { create_field_proxy, deep_set, deserialize_binary_form, + parse_form_field_name, serialize_binary_form, split_path } from './form-utils.js'; @@ -51,6 +52,39 @@ describe('split_path', () => { }); describe('convert_formdata', () => { + test('normalizes type prefixes and array suffixes', () => { + expect(parse_form_field_name('form', 'n:items[]/form')).toEqual({ + name: 'items', + type: 'number', + is_array: true + }); + expect(parse_form_field_name('form', 'b:enabled/form')).toEqual({ + name: 'enabled', + type: 'boolean', + is_array: false + }); + }); + + test('rejects field names without the form id suffix', () => { + expect(() => parse_form_field_name('form', 'foo/other')).toThrow( + /wasn't created with form.fields.as/ + ); + }); + + test('coerces typed values after normalizing field names', () => { + const data = new FormData(); + data.append('n:count/form', '42'); + data.append('n:items[]/form', '1'); + data.append('n:items[]/form', '2'); + data.append('b:enabled/form', 'on'); + + expect(convert_formdata('form', data)).toEqual({ + count: 42, + items: [1, 2], + enabled: true + }); + }); + test('converts a FormData object', () => { const data = new FormData(); From 97f84f861fc493cc94e7754a07e8c95707ec4cda Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 23 Jul 2026 06:52:14 -0400 Subject: [PATCH 12/14] tweak --- .../kit/src/runtime/app/server/remote/form.js | 4 ++-- .../client/remote-functions/form.svelte.js | 18 +++++++++--------- packages/kit/src/runtime/form-utils.js | 18 ++++++++---------- packages/kit/src/runtime/form-utils.spec.js | 10 ++++------ 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 1244359de4a7..3a5acdeff776 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -8,7 +8,7 @@ import { deep_set, normalize_issue, flatten_issues, - parse_form_field_name + parse_form_key } from '../../../form-utils.js'; import { get_cache, get_implicit_lookup, run_remote_function } from './shared.js'; import { ValidationError } from '@sveltejs/kit/internal'; @@ -273,7 +273,7 @@ function handle_issues(output, issues, form_data, form_id) { if (/^[.\]]?_/.test(field_name)) continue; const values = form_data.getAll(field_name).filter((value) => typeof value === 'string'); - const field = parse_form_field_name(form_id, field_name); + const field = parse_form_key(form_id, field_name); set_nested_value( /** @type {Record} */ (output.input), diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index fb8fe2c55a81..cbba398b6292 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -26,7 +26,7 @@ import { deep_get, DELETE_KEY, BINARY_FORM_CONTENT_TYPE, - parse_form_field_name + parse_form_key } from '../../form-utils.js'; /** @@ -408,20 +408,20 @@ export function form(id) { if ( previous_submitter !== null && !Array.from(form_data.keys()) - .map((name) => parse_form_field_name(action_id_without_key, name).name) + .map((name) => parse_form_key(action_id_without_key, name).name) .includes(previous_submitter.name) ) { set_nested_value(input, previous_submitter, undefined); } if (event.submitter) { - let name = event.submitter.getAttribute('name'); + const name = event.submitter.getAttribute('name'); let submitter = null; const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { - submitter = parse_form_field_name(action_id_without_key, name); + submitter = parse_form_key(action_id_without_key, name); set_nested_value(input, submitter, value); } @@ -469,7 +469,7 @@ export function form(id) { const name = element.name; if (!name) return; - const field = parse_form_field_name(action_id_without_key, name); + const field = parse_form_key(action_id_without_key, name); const is_file = element.type === 'file'; @@ -548,12 +548,12 @@ export function form(id) { const name = /** @type {HTMLInputElement} */ (e.target).name; if (!name) return; - const { name: field_name } = parse_form_field_name(action_id_without_key, name); + const field = parse_form_key(action_id_without_key, name); - touched[field_name] = true; + touched[field.name] = true; - if (Object.hasOwn(dirty, field_name)) { - can_validate[field_name] = true; + if (Object.hasOwn(dirty, field.name)) { + can_validate[field.name] = true; } }; diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index e72486ab5bfd..0c30c2f560a2 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -22,21 +22,19 @@ export function set_nested_value(object, field, value) { /** * Separates a form field's path from the metadata encoded in its name. * @param {string} form_id - * @param {string} name + * @param {string} key * @returns {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean }} */ -export function parse_form_field_name(form_id, name) { - if (!name.endsWith('/' + form_id)) { +export function parse_form_key(form_id, key) { + const suffix = '/' + form_id; + let name = key; + + if (!name.endsWith(suffix)) { throw new Error(`Form contained a field that wasn't created with form.fields.as(...): ${name}`); } - return parse_form_field_path(name.slice(0, -form_id.length - 1)); -} + name = name.slice(0, -suffix.length); -/** - * @param {string} name - */ -function parse_form_field_path(name) { /** @type {'number' | 'boolean' | null} */ let type = null; @@ -82,7 +80,7 @@ export function convert_formdata(form_id, data) { /** @type {any[]} */ const values = data.getAll(field_name); - const field = parse_form_field_name(form_id, field_name); + const field = parse_form_key(form_id, field_name); // an empty `` will submit a non-existent file, bizarrely const entries = values.filter( diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 836250de91dd..7f463587e9fb 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -6,7 +6,7 @@ import { create_field_proxy, deep_set, deserialize_binary_form, - parse_form_field_name, + parse_form_key, serialize_binary_form, split_path } from './form-utils.js'; @@ -53,12 +53,12 @@ describe('split_path', () => { describe('convert_formdata', () => { test('normalizes type prefixes and array suffixes', () => { - expect(parse_form_field_name('form', 'n:items[]/form')).toEqual({ + expect(parse_form_key('form', 'n:items[]/form')).toEqual({ name: 'items', type: 'number', is_array: true }); - expect(parse_form_field_name('form', 'b:enabled/form')).toEqual({ + expect(parse_form_key('form', 'b:enabled/form')).toEqual({ name: 'enabled', type: 'boolean', is_array: false @@ -66,9 +66,7 @@ describe('convert_formdata', () => { }); test('rejects field names without the form id suffix', () => { - expect(() => parse_form_field_name('form', 'foo/other')).toThrow( - /wasn't created with form.fields.as/ - ); + expect(() => parse_form_key('form', 'foo/other')).toThrow(/wasn't created with form.fields.as/); }); test('coerces typed values after normalizing field names', () => { From 2c38d3cc8fd121b1cef6fd2c48b6214a870787a9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 23 Jul 2026 06:54:53 -0400 Subject: [PATCH 13/14] tweak --- packages/kit/src/runtime/client/remote-functions/form.svelte.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cbba398b6292..1babf7f4a36e 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -408,7 +408,7 @@ export function form(id) { if ( previous_submitter !== null && !Array.from(form_data.keys()) - .map((name) => parse_form_key(action_id_without_key, name).name) + .map((k) => parse_form_key(action_id_without_key, k).name) .includes(previous_submitter.name) ) { set_nested_value(input, previous_submitter, undefined); From 19cbd86fa4c7c4a1c953aba151c28027c24a49cc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 23 Jul 2026 07:35:11 -0400 Subject: [PATCH 14/14] fix --- .../src/runtime/client/remote-functions/form.svelte.js | 7 +++++-- packages/kit/src/runtime/form-utils.js | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 1babf7f4a36e..676d9df9583c 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -26,7 +26,8 @@ import { deep_get, DELETE_KEY, BINARY_FORM_CONTENT_TYPE, - parse_form_key + parse_form_key, + coerce_form_value } from '../../form-utils.js'; /** @@ -416,13 +417,15 @@ export function form(id) { if (event.submitter) { const name = event.submitter.getAttribute('name'); + + /** @type {null | ReturnType} */ let submitter = null; const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { submitter = parse_form_key(action_id_without_key, name); - set_nested_value(input, submitter, value); + set_nested_value(input, submitter, coerce_form_value(submitter.type, value)); } previous_submitter = submitter; diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 0c30c2f560a2..1ba52fea0933 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -16,7 +16,7 @@ const decoder = new TextDecoder(); * @param {any} value */ export function set_nested_value(object, field, value) { - deep_set(object, split_path(field.name), coerce_form_value(field.type, value)); + deep_set(object, split_path(field.name), value); } /** @@ -57,7 +57,7 @@ export function parse_form_key(form_id, key) { * @param {any} value * @returns {any} */ -function coerce_form_value(type, value) { +export function coerce_form_value(type, value) { if (Array.isArray(value)) return value.map((value) => coerce_form_value(type, value)); if (type === 'number') return value === '' ? undefined : parseFloat(value); if (type === 'boolean') return value === 'on'; @@ -94,7 +94,11 @@ export function convert_formdata(form_id, data) { ); } - set_nested_value(result, field, field.is_array ? entries : entries[0]); + set_nested_value( + result, + field, + coerce_form_value(field.type, field.is_array ? entries : entries[0]) + ); } return result;