From 4cf327905eb6cd72f78b913ec45626a22789c281 Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Sat, 11 Oct 2025 17:48:52 +0900 Subject: [PATCH 1/6] feat: expose field touched state via proxy API --- packages/kit/src/exports/public.d.ts | 2 + .../kit/src/runtime/app/server/remote/form.js | 16 ++- .../client/remote-functions/form.svelte.js | 35 +++--- packages/kit/src/runtime/form-utils.js | 101 ++++++++++++++++-- packages/kit/types/index.d.ts | 4 +- 5 files changed, 126 insertions(+), 32 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index cb42dc6cc1c0..5f550abb2676 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1878,6 +1878,8 @@ type RemoteFormFieldMethods = { value(): T; /** Set the values that will be submitted */ set(input: T): T; + /** Whether the field or any nested field has been interacted with since the form was mounted */ + touched(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; }; diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 7bc7259185e9..117f57527fcc 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -10,7 +10,8 @@ import { throw_on_old_property_access, deep_set, normalize_issue, - flatten_issues + flatten_issues, + build_path_string } from '../../../form-utils.js'; import { get_cache, run_remote_function } from './shared.js'; @@ -210,7 +211,6 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, 'fields', { get() { const data = get_cache(__)?.['']; - const issues = flatten_issues(data?.issues ?? []); return create_field_proxy( {}, @@ -224,9 +224,17 @@ export function form(validate_or_fn, maybe_fn) { const input = path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value); - (get_cache(__)[''] ??= {}).input = input; + const cache = get_cache(__); + const entry = (cache[''] ??= {}); + entry.input = input; + + if (path.length > 0) { + const key = build_path_string(path); + (entry.touched ??= {})[key] = true; + } }, - () => issues + () => flatten_issues(data?.issues ?? []), + () => data?.touched ?? {} ); } }); 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 02bbf9239720..f75c8e5ff0e6 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -317,15 +317,15 @@ export function form(id) { // but that makes the types unnecessarily awkward const element = /** @type {HTMLInputElement} */ (e.target); - let name = element.name; - if (!name) return; + const original_name = element.name; + if (!original_name) return; - const is_array = name.endsWith('[]'); - if (is_array) name = name.slice(0, -2); + const is_array = original_name.endsWith('[]'); + const base_name = is_array ? original_name.slice(0, -2) : original_name; const is_file = element.type === 'file'; - touched[name] = true; + const sanitized_name = base_name.replace(/^[nb]:/, ''); if (is_array) { let value; @@ -337,7 +337,7 @@ export function form(id) { ); } else { const elements = /** @type {HTMLInputElement[]} */ ( - Array.from(form.querySelectorAll(`[name="${name}[]"]`)) + Array.from(form.querySelectorAll(`[name="${base_name}[]"]`)) ); if (DEV) { @@ -358,39 +358,38 @@ export function form(id) { } } - set_nested_value(input, name, value); + set_nested_value(input, base_name, value); } else if (is_file) { if (DEV && element.multiple) { throw new Error( - `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` + `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${sanitized_name}" to "${sanitized_name}[]"` ); } const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; if (file) { - set_nested_value(input, name, file); + set_nested_value(input, base_name, file); } else { // Remove the property by setting to undefined and clean up - const path_parts = name.split(/\.|\[|\]/).filter(Boolean); + const path_parts = sanitized_name.split(/\.|\[|\]/).filter(Boolean); let current = /** @type {any} */ (input); for (let i = 0; i < path_parts.length - 1; i++) { - if (current[path_parts[i]] == null) return; - current = current[path_parts[i]]; + const part = path_parts[i]; + if (current[part] == null) return; + current = current[part]; } delete current[path_parts[path_parts.length - 1]]; } } else { set_nested_value( input, - name, + base_name, element.type === 'checkbox' && !element.checked ? null : element.value ); } - name = name.replace(/^[nb]:/, ''); - - touched[name] = true; + touched[sanitized_name] = true; }); form.addEventListener('reset', async () => { @@ -399,6 +398,7 @@ export function form(id) { await tick(); input = convert_formdata(new FormData(form)); + touched = {}; }); return () => { @@ -501,7 +501,8 @@ export function form(id) { touched[key] = true; } }, - () => issues + () => issues, + () => touched ) }, result: { diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index e268ff80b2b8..3f02d670fb5b 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -207,10 +207,18 @@ export function deep_get(object, path) { * @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 {() => Record} get_issues - Function to get current issues + * @param {() => Record} [get_touched] - Function to get touched 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_input, set_input, get_issues, path = []) { +export function create_field_proxy( + target, + get_input, + set_input, + get_issues, + get_touched = () => ({}), + path = [] +) { const get_value = () => { return deep_get(get_input(), path); }; @@ -221,10 +229,14 @@ 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( + {}, + get_input, + set_input, + get_issues, + get_touched, + [...path, parseInt(prop, 10)] + ); } const key = build_path_string(path); @@ -234,11 +246,26 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat 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, + get_input, + set_input, + get_issues, + get_touched, + [...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, + get_input, + set_input, + get_issues, + get_touched, + [...path, prop] + ); } if (prop === 'issues' || prop === 'allIssues') { @@ -258,7 +285,47 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat })); }; - return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy( + issues_func, + get_input, + set_input, + get_issues, + get_touched, + [...path, prop] + ); + } + + if (prop === 'touched') { + const touched_func = () => { + const touched = get_touched(); + + if (path.length === 0 || key === '') { + return Object.keys(touched).length > 0; + } + + if (touched[key]) return true; + + for (const candidate in touched) { + if (candidate === key) continue; + if (!candidate.startsWith(key)) continue; + + const next = candidate.slice(key.length, key.length + 1); + if (next === '.' || next === '[') { + return true; + } + } + + return false; + }; + + return create_field_proxy( + touched_func, + get_input, + set_input, + get_issues, + get_touched, + [...path, prop] + ); } if (prop === 'as') { @@ -407,11 +474,25 @@ 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, + get_input, + set_input, + get_issues, + get_touched, + [...path, 'as'] + ); } // Handle property access (nested fields) - return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy( + {}, + get_input, + set_input, + get_issues, + get_touched, + [...path, prop] + ); } }); } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 9fde95c8dd4c..6a8148fa380b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1854,6 +1854,8 @@ declare module '@sveltejs/kit' { value(): T; /** Set the values that will be submitted */ set(input: T): T; + /** Whether the field or any nested field has been interacted with since the form was mounted */ + touched(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; }; @@ -3504,4 +3506,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map \ No newline at end of file +//# sourceMappingURL=index.d.ts.map From b70968208df230ed7d582f90f1227efbdbdc5df7 Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Sat, 11 Oct 2025 17:49:06 +0900 Subject: [PATCH 2/6] test: cover remote form touched behaviour --- .../routes/remote/form/touched/+page.svelte | 30 +++++++++++++++++++ .../remote/form/touched/touched.remote.ts | 10 +++++++ packages/kit/test/apps/basics/test/test.js | 24 +++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte new file mode 100644 index 000000000000..1248c135f503 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte @@ -0,0 +1,30 @@ + + +

Remote Form Touched Test

+ +

Name touched: {touched_form.fields.name.touched() ? 'yes' : 'no'}

+

Age touched: {touched_form.fields.age.touched() ? 'yes' : 'no'}

+ + + +
+ + + + + +
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts new file mode 100644 index 000000000000..a13a2abdeda0 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts @@ -0,0 +1,10 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const touched_form = form( + v.object({ + name: v.optional(v.string()), + age: v.optional(v.number()) + }), + (data) => ({ submitted: data }) +); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 9b57a59b79d7..157533a234a6 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -2007,6 +2007,30 @@ test.describe('remote functions', () => { expect(JSON.parse(arrayValue)).toEqual([{ leaf: 'array-0-leaf' }, { leaf: 'array-1-leaf' }]); }); + test('form fields touched tracks interactions', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/touched'); + + const nameTouched = page.locator('#touched-name'); + const ageTouched = page.locator('#touched-age'); + + await expect(nameTouched).toHaveText('Name touched: no'); + await expect(ageTouched).toHaveText('Age touched: no'); + + await page.click('#set-btn'); + await expect(nameTouched).toHaveText('Name touched: yes'); + + await page.click('#reset-btn'); + await expect(nameTouched).toHaveText('Name touched: no'); + + await page.fill('#age-input', '42'); + await expect(ageTouched).toHaveText('Age touched: yes'); + + await page.click('#reset-btn'); + await expect(ageTouched).toHaveText('Age touched: no'); + }); + test('selects are not nuked when unrelated controls change', async ({ page, javaScriptEnabled From 123c7b1a9f578990a54d84dadc18788602b6b6ca Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Sat, 11 Oct 2025 17:52:06 +0900 Subject: [PATCH 3/6] changeset --- .changeset/red-masks-remain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-masks-remain.md diff --git a/.changeset/red-masks-remain.md b/.changeset/red-masks-remain.md new file mode 100644 index 000000000000..f6c87e2418f4 --- /dev/null +++ b/.changeset/red-masks-remain.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +feat: add field.touched() helper to remote form fields From 06136823e4faeae94e62c5d06cc29706780b5fa3 Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Sat, 11 Oct 2025 18:52:54 +0900 Subject: [PATCH 4/6] fix: run lint --- packages/kit/types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6a8148fa380b..00035a4c6bb7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3506,4 +3506,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map +//# sourceMappingURL=index.d.ts.map \ No newline at end of file From 878e77c65985599fd644e6785a4cf812ac393519 Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Fri, 24 Oct 2025 19:42:07 +0900 Subject: [PATCH 5/6] fix: keep remote form touched state reactive --- 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 f75c8e5ff0e6..873d114f3f54 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -81,7 +81,7 @@ export function form(id) { let element = null; /** @type {Record} */ - let touched = {}; + let touched = $state({}); let submitted = false; From bdedd6e7b655886df0ea60a7d06ab9f2bcba1412 Mon Sep 17 00:00:00 2001 From: LeeWxx Date: Fri, 24 Oct 2025 19:47:47 +0900 Subject: [PATCH 6/6] fix: formatting --- packages/kit/src/runtime/form-utils.js | 81 ++++++++------------------ 1 file changed, 25 insertions(+), 56 deletions(-) diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 3f02d670fb5b..a510badce772 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -229,14 +229,10 @@ export function create_field_proxy( // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy( - {}, - get_input, - set_input, - get_issues, - get_touched, - [...path, parseInt(prop, 10)] - ); + return create_field_proxy({}, get_input, set_input, get_issues, get_touched, [ + ...path, + parseInt(prop, 10) + ]); } const key = build_path_string(path); @@ -247,25 +243,17 @@ export function create_field_proxy( return newValue; }; - return create_field_proxy( - set_func, - get_input, - set_input, - get_issues, - get_touched, - [...path, prop] - ); + return create_field_proxy(set_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'value') { - return create_field_proxy( - get_value, - get_input, - set_input, - get_issues, - get_touched, - [...path, prop] - ); + return create_field_proxy(get_value, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'issues' || prop === 'allIssues') { @@ -285,14 +273,10 @@ export function create_field_proxy( })); }; - return create_field_proxy( - issues_func, - get_input, - set_input, - get_issues, - get_touched, - [...path, prop] - ); + return create_field_proxy(issues_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'touched') { @@ -318,14 +302,10 @@ export function create_field_proxy( return false; }; - return create_field_proxy( - touched_func, - get_input, - set_input, - get_issues, - get_touched, - [...path, prop] - ); + return create_field_proxy(touched_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'as') { @@ -474,25 +454,14 @@ export function create_field_proxy( }); }; - return create_field_proxy( - as_func, - get_input, - set_input, - get_issues, - get_touched, - [...path, 'as'] - ); + return create_field_proxy(as_func, get_input, set_input, get_issues, get_touched, [ + ...path, + 'as' + ]); } // Handle property access (nested fields) - return create_field_proxy( - {}, - get_input, - set_input, - get_issues, - get_touched, - [...path, prop] - ); + return create_field_proxy({}, get_input, set_input, get_issues, get_touched, [...path, prop]); } }); }