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(...) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index a41748dc3e91..3a5acdeff776 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, + 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'; @@ -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; } @@ -135,7 +136,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; @@ -145,7 +146,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 }); @@ -153,10 +154,10 @@ 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_cache(__, get_request_store().state)?.['']?.input ?? {}, - (path, value) => { + return create_field_proxy({ + form_id: __.id, + get: () => get_cache(__, get_request_store().state)?.['']?.input ?? {}, + set: (path, value) => { const cache = get_cache(__, get_request_store().state); const entry = cache['']; @@ -174,11 +175,11 @@ export function form(validate_or_fn, maybe_fn) { deep_set(input, path.map(String), value); (cache[''] ??= {}).input = input; }, - () => flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []), - () => ({}), - () => ({}), - [] - ); + get_issues: () => + flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []), + get_touched: () => ({}), + get_dirty: () => ({}) + }); } }); @@ -228,12 +229,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); @@ -254,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 @@ -263,19 +268,17 @@ function handle_issues(output, issues, form_data) { 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; - - const is_array = key.endsWith('[]'); - const values = form_data.getAll(key).filter((value) => typeof value === 'string'); + if (/^[.\]]?_/.test(field_name)) continue; - if (is_array) key = key.slice(0, -2); + const values = form_data.getAll(field_name).filter((value) => typeof value === 'string'); + const field = parse_form_key(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 a83a1e0d1b08..676d9df9583c 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -25,7 +25,9 @@ import { serialize_binary_form, deep_get, DELETE_KEY, - BINARY_FORM_CONTENT_TYPE + BINARY_FORM_CONTENT_TYPE, + parse_form_key, + coerce_form_value } from '../../form-utils.js'; /** @@ -123,8 +125,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 @@ -160,8 +162,8 @@ export function form(id) { * @returns {Record} */ function convert(form_data) { - const data = convert_formdata(form_data); - if (key !== undefined && !form_data.has('id')) { + const data = convert_formdata(action_id_without_key, form_data); + if (key !== undefined && !('id' in data)) { data.id = key; } return data; @@ -405,26 +407,30 @@ export function form(id) { const form_data = new FormData(form, event.submitter); if ( - previous_submitter_name !== null && - !Array.from(form_data.keys()).map(strip_prefix).includes(previous_submitter_name) + previous_submitter !== null && + !Array.from(form_data.keys()) + .map((k) => parse_form_key(action_id_without_key, k).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) { const name = event.submitter.getAttribute('name'); + + /** @type {null | ReturnType} */ + let submitter = null; + const value = /** @type {any} */ (event.submitter).value; if (name !== null && value !== undefined) { - set_nested_value(input, name, value); + submitter = parse_form_key(action_id_without_key, name); + set_nested_value(input, submitter, coerce_form_value(submitter.type, value)); } - previous_submitter_name = strip_prefix(name); + previous_submitter = submitter; } else { - previous_submitter_name = null; + previous_submitter = null; } if (DEV) { @@ -463,15 +469,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; - const is_array = name.endsWith('[]'); - if (is_array) name = name.slice(0, -2); + const field = parse_form_key(action_id_without_key, name); const is_file = element.type === 'file'; - if (is_array) { + if (field.is_array) { let value; if (element.tagName === 'SELECT') { @@ -481,7 +486,7 @@ export function form(id) { ); } else { const elements = /** @type {HTMLInputElement[]} */ ( - Array.from(form.querySelectorAll(`[name="${name}[]"]`)) + Array.from(form.querySelectorAll(`[name="${name}"]`)) ); if (DEV) { @@ -502,7 +507,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( @@ -513,21 +518,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 () => { @@ -535,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 = {}; dirty = {}; @@ -545,15 +548,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(name).replace(/\[\]$/, ''); + const field = parse_form_key(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; } }; @@ -608,10 +611,10 @@ export function form(id) { }, fields: { get: () => - create_field_proxy( - {}, - () => input, - (path, value) => { + create_field_proxy({ + form_id: action_id_without_key, + get: () => input, + set: (path, value) => { if (path.length === 0) { input = value; } else if (value !== deep_get(input, path)) { @@ -626,7 +629,7 @@ export function form(id) { } } }, - (path, all) => { + get_issues: (path, all) => { if (DEV && unread_issues !== null && path !== undefined) { unread_issues = unread_issues.filter((issue) => { return ( @@ -638,10 +641,9 @@ export function form(id) { return issues; }, - () => touched, - () => dirty, - [] - ) + get_touched: () => touched, + get_dirty: () => dirty + }) }, result: { get: () => result @@ -809,13 +811,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 35a76e22c030..1ba52fea0933 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -10,21 +10,58 @@ 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'; +export function set_nested_value(object, field, value) { + deep_set(object, split_path(field.name), value); +} + +/** + * Separates a form field's path from the metadata encoded in its name. + * @param {string} form_id + * @param {string} key + * @returns {{ name: string; type: 'number' | 'boolean' | null; is_array: boolean }} + */ +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}`); } - deep_set(object, split_path(path_string), value); + name = name.slice(0, -suffix.length); + + /** @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} + */ +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'; + return value; } /** Pass this to set_nested_value to delete the last part of the given path */ @@ -32,38 +69,36 @@ 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('[]'); + for (const field_name of data.keys()) { /** @type {any[]} */ - let values = data.getAll(key); + const values = data.getAll(field_name); - if (is_array) key = key.slice(0, -2); + const field = parse_form_key(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, + coerce_form_value(field.type, field.is_array ? entries : entries[0]) + ); } return result; @@ -139,12 +174,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'); @@ -653,18 +689,21 @@ function deep_clone(value) { /** * Creates a proxy-based field accessor for form data + * @param {{ + * form_id: string, + * get: () => Record, + * set: (path: (string | number)[], value: any) => void, + * get_issues: (path?: (string | number)[], all?: boolean) => Record, + * get_touched: () => Record, + * get_dirty: () => Record + * }} context - Form context, including value accessors and form metadata * @param {any} target - Function or empty POJO - * @param {() => Record} get - Function to get current input data - * @param {(path: (string | number)[], value: any) => void} set - Function to set input data - * @param {(path?: (string | number)[], all?: boolean) => Record} get_issues - Function to get current issues - * @param {() => Record} get_touched - Function to get touched fields - * @param {() => Record} get_dirty - Function to get dirty fields * @param {(string | number)[]} path - Current access path * @returns {any} Proxy object with name(), value(), and issues() methods */ -export function create_field_proxy(target, get, set, get_issues, get_touched, get_dirty, path) { +export function create_field_proxy(context, target = {}, path = []) { const get_value = () => { - const value = deep_get(get(), path); + const value = deep_get(context.get(), path); return deep_clone(value); }; @@ -674,10 +713,7 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy({}, get, set, get_issues, get_touched, get_dirty, [ - ...path, - parseInt(prop, 10) - ]); + return create_field_proxy(context, {}, [...path, parseInt(prop, 10)]); } const key = build_path_string(path); @@ -685,20 +721,19 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge if (prop === 'set') { const set_func = function (/** @type {any} */ newValue) { - set(path, newValue); + context.set(path, newValue); return newValue; }; - - return create_field_proxy(set_func, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, set_func, next); } if (prop === 'value') { - return create_field_proxy(get_value, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, get_value, next); } if (prop === 'issues' || prop === 'allIssues') { const issues_func = () => { - const all_issues = 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) => ({ @@ -717,12 +752,12 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge return issues?.length ? issues : undefined; }; - return create_field_proxy(issues_func, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, issues_func, next); } if (prop === 'touched' || prop === 'dirty') { const fn = () => { - const object = prop === 'dirty' ? get_dirty() : get_touched(); + const object = prop === 'dirty' ? context.get_dirty() : context.get_touched(); if (key === '') { return Object.keys(object).length > 0; @@ -745,7 +780,7 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge return false; }; - return create_field_proxy(fn, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, fn, next); } if (prop === 'as') { @@ -759,14 +794,14 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge 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 ? '[]' : '') + '/' + context.form_id, get 'aria-invalid'() { - const issues = get_issues(); + const issues = context.get_issues(); return key in issues ? 'true' : undefined; } }; @@ -911,11 +946,11 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge }); }; - return create_field_proxy(as_func, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, as_func, next); } // Handle property access (nested fields) - return create_field_proxy({}, get, set, get_issues, get_touched, get_dirty, next); + return create_field_proxy(context, {}, next); } }); } diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 41d0a286f50d..7f463587e9fb 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_key, serialize_binary_form, split_path } from './form-utils.js'; @@ -51,17 +52,48 @@ describe('split_path', () => { }); describe('convert_formdata', () => { + test('normalizes type prefixes and array suffixes', () => { + expect(parse_form_key('form', 'n:items[]/form')).toEqual({ + name: 'items', + type: 'number', + is_array: true + }); + expect(parse_form_key('form', 'b:enabled/form')).toEqual({ + name: 'enabled', + type: 'boolean', + is_array: false + }); + }); + + test('rejects field names without the form id suffix', () => { + expect(() => parse_form_key('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(); - 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', @@ -77,10 +109,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: { @@ -95,24 +127,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 "/); }); }); @@ -136,7 +177,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); @@ -174,7 +216,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'); @@ -215,7 +258,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); /** @type {File} */ const file = res.data.file; @@ -248,7 +292,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'); @@ -273,7 +318,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'); @@ -345,7 +391,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': (header_bytes + data_length).toString() } - }) + }), + '' ) ).rejects.toThrow('data too short'); } finally { @@ -404,7 +451,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': total.toString() } - }) + }), + '' ) ).rejects.toThrow('invalid file metadata'); }, 1000); @@ -427,7 +475,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' ); }); @@ -465,7 +513,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': total.toString() } - }) + }), + '' ) ).rejects.toThrow('invalid file offset table'); }, 1000); @@ -497,7 +546,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'); }); @@ -526,7 +576,8 @@ describe('binary form serializer', () => { 'Content-Type': BINARY_FORM_CONTENT_TYPE, 'Content-Length': blob.size.toString() } - }) + }), + '' ); expect(res.data).toEqual({ a: 1 }); @@ -610,7 +661,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'); }); @@ -620,7 +671,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'); }); @@ -631,7 +682,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'); }); @@ -692,7 +743,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); @@ -715,7 +766,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); @@ -766,15 +818,14 @@ describe('create_field_proxy', () => { const original = new Date('2025-06-25T00:00:00Z'); const input = { created_at: original }; - const proxy = create_field_proxy( - {}, - () => input, - () => {}, - () => ({}), - () => ({}), - () => ({}), - [] - ); + const proxy = create_field_proxy({ + form_id: 'form', + get: () => input, + set: () => {}, + get_issues: () => ({}), + get_touched: () => ({}), + get_dirty: () => ({}) + }); const cloned = proxy.created_at.value(); diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js index 8bf1b8e3c0f5..195c057c4263 100644 --- a/packages/kit/src/runtime/server/remote-functions.js +++ b/packages/kit/src/runtime/server/remote-functions.js @@ -240,7 +240,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) @@ -565,9 +569,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)); @@ -575,7 +579,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 2b01a7e9cec8..8a2bb1bf211e 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -646,7 +646,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; } diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index af6a6121a7e9..81bc2b6361d8 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -510,7 +510,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(); @@ -1072,8 +1072,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'); @@ -1152,7 +1152,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 @@ -1164,20 +1164,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 @@ -1191,7 +1191,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 @@ -1217,7 +1217,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'); @@ -1225,11 +1225,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 137de469e260..55d962e1af8f 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -185,7 +185,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 }) => { @@ -339,7 +339,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 }) => { @@ -361,7 +361,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' ); @@ -369,7 +369,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( @@ -518,7 +518,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'); @@ -544,8 +544,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'); @@ -573,7 +573,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'); @@ -587,7 +587,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'); @@ -614,12 +614,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 }) => { @@ -645,14 +645,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', @@ -662,7 +662,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', @@ -672,7 +672,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', @@ -683,7 +683,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', @@ -695,7 +695,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', @@ -729,14 +729,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(); @@ -793,12 +793,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') @@ -817,12 +817,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') @@ -945,20 +945,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(); }); }); 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(); })} > - +