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();
})}
>
-
+