From b49e34caf147cc4e3d82eb1741d58ade7121f362 Mon Sep 17 00:00:00 2001 From: razinshafayet Date: Sun, 15 Feb 2026 21:41:25 +0600 Subject: [PATCH 1/4] fix: respect HTML constraints for remote forms --- .changeset/bright-rivers-sparkle.md | 5 + .../client/remote-functions/form.svelte.js | 180 +++++++++++++++++- .../remote/form/html-constraints/+page.svelte | 25 +++ .../form/html-constraints/form.remote.ts | 20 ++ packages/kit/test/apps/async/test/test.js | 75 ++++++++ 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 .changeset/bright-rivers-sparkle.md create mode 100644 packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte create mode 100644 packages/kit/test/apps/async/src/routes/remote/form/html-constraints/form.remote.ts diff --git a/.changeset/bright-rivers-sparkle.md b/.changeset/bright-rivers-sparkle.md new file mode 100644 index 000000000000..3076d719210e --- /dev/null +++ b/.changeset/bright-rivers-sparkle.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": patch +--- + +fix: respect HTML constraints for remote forms 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 cc192c00f546..4905e0b6567c 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -20,7 +20,8 @@ import { build_path_string, normalize_issue, serialize_binary_form, - BINARY_FORM_CONTENT_TYPE + BINARY_FORM_CONTENT_TYPE, + split_path } from '../../form-utils.js'; /** @@ -298,6 +299,35 @@ export function form(id) { return; } + const no_validate = clone(form).noValidate; // respects
+ const submitter_no_validate = + event.submitter && + /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).hasAttribute( + 'formnovalidate' + ); + + if (!no_validate && !submitter_no_validate) { + // reportValidity() triggers browser UI; returns false if invalid (minlength/maxlength/pattern/etc.) + if (!form.reportValidity()) { + event.preventDefault(); + return; + } + + const invalid_length_control = get_invalid_length_control(form); + if (invalid_length_control) { + // In this edge case the browser can miss minlength/maxlength and leave + // `validationMessage` empty. Setting a custom validity forces native UI. + invalid_length_control.setCustomValidity( + invalid_length_control.validationMessage || + get_length_validation_message(invalid_length_control) + ); + invalid_length_control.reportValidity(); + invalid_length_control.setCustomValidity(''); + event.preventDefault(); + return; + } + } + event.preventDefault(); const form_data = new FormData(form, event.submitter); @@ -509,8 +539,14 @@ export function form(id) { /** @type {InternalRemoteFormIssue[]} */ let array = []; + let is_server_validation = false; const data = convert(form_data); + const html_constraint_issues = get_html_constraint_issues(element, { + include_untouched: includeUntouched, + submitted, + touched + }); const validated = await preflight_schema?.['~standard'].validate(data); @@ -520,7 +556,7 @@ export function form(id) { if (validated?.issues) { array = validated.issues.map((issue) => normalize_issue(issue, false)); - } else if (!preflightOnly) { + } else if (!preflightOnly && html_constraint_issues.length === 0) { const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, { method: 'POST', headers: { @@ -545,14 +581,24 @@ export function form(id) { devalue.parse(result.result, app.decoders) ); } + + is_server_validation = true; + } + + if (html_constraint_issues.length > 0) { + const html_constraint_names = new Set( + html_constraint_issues.map((issue) => issue.name) + ); + array = [ + ...array.filter((issue) => !html_constraint_names.has(issue.name)), + ...html_constraint_issues + ]; } if (!includeUntouched && !submitted) { array = array.filter((issue) => touched[issue.name]); } - const is_server_validation = !validated?.issues && !preflightOnly; - raw_issues = is_server_validation ? array : merge_with_server_issues(form_data, raw_issues, array); @@ -617,6 +663,132 @@ function clone(element) { return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element)); } +/** + * In some cases programmatic value updates can bypass minlength/maxlength checks during submit. + * Re-check text controls and let the browser show its native validation UI. + * @param {HTMLFormElement} form + */ +function get_invalid_length_control(form) { + for (const element of form.elements) { + if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) continue; + if (!element.willValidate || element.disabled || !element.name) continue; + + if (has_invalid_length(element)) return element; + } + + return null; +} + +/** + * @param {HTMLInputElement | HTMLTextAreaElement} element + */ +function has_invalid_length(element) { + const value = element.value; + const min_length = element.minLength; + const max_length = element.maxLength; + + if (value.length > 0 && min_length > -1 && value.length < min_length) return true; + if (max_length > -1 && value.length > max_length) return true; + return false; +} + +/** + * In edge cases where length validity is missed, `validationMessage` may be empty. + * Use a deterministic message only as a fallback. + * @param {HTMLInputElement | HTMLTextAreaElement} element + */ +function get_length_validation_message(element) { + const value_length = element.value.length; + const min_length = element.minLength; + const max_length = element.maxLength; + + if (value_length > 0 && min_length > -1 && value_length < min_length) { + return `Please lengthen this text to ${min_length} characters or more.`; + } + + if (max_length > -1 && value_length > max_length) { + return `Please shorten this text to ${max_length} characters or fewer.`; + } + + return 'Please match the requested text length.'; +} + +/** + * @param {HTMLFormElement} form + * @param {{ include_untouched: boolean, submitted: boolean, touched: Record }} options + * @returns {InternalRemoteFormIssue[]} + */ +function get_html_constraint_issues(form, options) { + /** @type {InternalRemoteFormIssue[]} */ + const issues = []; + const seen = new Set(); + + for (const element of form.elements) { + if ( + !( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) + ) { + continue; + } + + if (!element.willValidate || element.disabled || !element.name) continue; + + const name = normalize_control_name(element.name); + const dedupe_key = + element instanceof HTMLInputElement && element.type === 'radio' ? `radio:${name}` : null; + if (dedupe_key && seen.has(dedupe_key)) continue; + if (!options.include_untouched && !options.submitted && !options.touched[name]) continue; + const invalid_length = + (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && + has_invalid_length(element); + if (!invalid_length && element.checkValidity()) continue; + + const parsed = parse_issue_path(name); + if (!parsed) continue; + + issues.push({ + name: parsed.name, + path: parsed.path, + message: invalid_length + ? element.validationMessage || get_length_validation_message(element) + : element.validationMessage || 'Invalid value', + server: false + }); + + if (dedupe_key) { + seen.add(dedupe_key); + } + } + + return issues; +} + +/** + * @param {string} name + */ +function normalize_control_name(name) { + if (name.endsWith('[]')) name = name.slice(0, -2); + return name.replace(/^[nb]:/, ''); +} + +/** + * @param {string} name + * @returns {{ name: string, path: Array } | null} + */ +function parse_issue_path(name) { + try { + return { + name, + path: split_path(name).map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment)) + }; + } catch { + return null; + } +} + /** * @param {FormData} form_data * @param {string} enctype diff --git a/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte new file mode 100644 index 000000000000..ef2897ce7816 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte @@ -0,0 +1,25 @@ + + + + + + + +

{validate_code.result || ''}

+ +
validate_code_preflight.validate({ preflightOnly: true })} +> + +
+ +

{validate_code_preflight.fields.code.issues()?.length ?? 0}

diff --git a/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/form.remote.ts b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/form.remote.ts new file mode 100644 index 000000000000..66a4fa1ab2e9 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/form.remote.ts @@ -0,0 +1,20 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const validate_code = form( + v.object({ + code: v.string() + }), + async (data) => { + return data.code; + } +); + +export const validate_code_preflight = form( + v.object({ + code: v.string() + }), + async (data) => { + return data.code; + } +); diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index f9715f83842a..78349a4cfc3d 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -315,6 +315,81 @@ test.describe('remote functions', () => { await expect(issues).not.toContainText('a is too short'); }); + test('form respects HTML constraints before submitting remote requests', async ({ + page, + javaScriptEnabled + }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/html-constraints'); + + let request_count = 0; + + /** @param {import('@playwright/test').Request} request */ + const handler = (request) => { + if (request.url().includes('/_app/remote') && request.method() === 'POST') { + request_count += 1; + } + }; + + page.on('request', handler); + + const input = page.locator('[data-submit] input[name="code"]'); + const submit = page.locator('[data-submit] button'); + const result = page.locator('#result'); + + await input.focus(); + await input.pressSequentially('1'); + await submit.click(); + await expect(result).toHaveText(''); + await page.waitForTimeout(100); + expect(request_count).toBe(0); + + await input.fill(''); + await input.pressSequentially('123456'); + await submit.click(); + await expect(result).toHaveText('123456'); + expect(request_count).toBe(1); + + page.off('request', handler); + }); + + test('form validate({ preflightOnly: true }) respects HTML constraints on dirty fields', async ({ + page, + javaScriptEnabled + }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/html-constraints'); + + let request_count = 0; + + /** @param {import('@playwright/test').Request} request */ + const handler = (request) => { + if (request.url().includes('/_app/remote') && request.method() === 'POST') { + request_count += 1; + } + }; + + page.on('request', handler); + + const input = page.locator('[data-preflight] input[name="code"]'); + const issue_count = page.locator('#preflight-issue-count'); + + await input.focus(); + await input.pressSequentially('1'); + await input.blur(); + await expect(issue_count).toHaveText('1'); + expect(request_count).toBe(0); + + await input.fill('123456'); + await input.blur(); + await expect(issue_count).toHaveText('0'); + expect(request_count).toBe(0); + + page.off('request', handler); + }); + test('form validate works', async ({ page, javaScriptEnabled }) => { if (!javaScriptEnabled) return; From e800a5dba51e03f4a0ee6d3e8cc907a934caf514 Mon Sep 17 00:00:00 2001 From: razinshafayet Date: Mon, 16 Feb 2026 12:21:36 +0600 Subject: [PATCH 2/4] chore: make remote form negative request assertion robust --- packages/kit/test/apps/async/test/test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 78349a4cfc3d..7468d6449385 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -342,8 +342,12 @@ test.describe('remote functions', () => { await input.pressSequentially('1'); await submit.click(); await expect(result).toHaveText(''); - await page.waitForTimeout(100); - expect(request_count).toBe(0); + await expect( + page.waitForRequest( + (request) => request.url().includes('/_app/remote') && request.method() === 'POST', + { timeout: 200 } + ) + ).rejects.toThrow(); await input.fill(''); await input.pressSequentially('123456'); From 5e06b82b6a5486ce0f85274804eb55e0bf6b5ebf Mon Sep 17 00:00:00 2001 From: razinshafayet Date: Sat, 7 Mar 2026 14:50:16 +0600 Subject: [PATCH 3/4] enhance fixes --- .../client/remote-functions/form.svelte.js | 128 ++++++++++++------ .../remote/form/html-constraints/+page.svelte | 15 +- packages/kit/test/apps/async/test/test.js | 41 +++++- 3 files changed, 138 insertions(+), 46 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 4905e0b6567c..776429d2a818 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -88,6 +88,9 @@ export function form(id) { /** @type {Record} */ let touched = {}; + /** @type {WeakSet} */ + let user_edited_text_controls = new WeakSet(); + let submitted = false; /** @@ -313,17 +316,16 @@ export function form(id) { return; } - const invalid_length_control = get_invalid_length_control(form); + const invalid_length_control = get_invalid_length_control( + form, + user_edited_text_controls + ); if (invalid_length_control) { - // In this edge case the browser can miss minlength/maxlength and leave - // `validationMessage` empty. Setting a custom validity forces native UI. - invalid_length_control.setCustomValidity( - invalid_length_control.validationMessage || - get_length_validation_message(invalid_length_control) - ); - invalid_length_control.reportValidity(); - invalid_length_control.setCustomValidity(''); event.preventDefault(); + // Browser validity can miss minlength/maxlength after a user edit if the + // value is later reapplied programmatically. Focus the control so the submit + // does not silently disappear when no native message is available. + invalid_length_control.focus(); return; } } @@ -356,14 +358,26 @@ export function form(id) { element = form; touched = {}; + user_edited_text_controls = new WeakSet(); form.addEventListener('submit', onsubmit); /** @param {Event} e */ const handle_input = (e) => { - // strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement - // but that makes the types unnecessarily awkward - const element = /** @type {HTMLInputElement} */ (e.target); + const element = e.target; + if ( + !( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) + ) { + return; + } + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + user_edited_text_controls.add(element); + } let name = element.name; if (!name) return; @@ -378,7 +392,7 @@ export function form(id) { if (is_array) { let value; - if (element.tagName === 'SELECT') { + if (element instanceof HTMLSelectElement) { value = Array.from( element.querySelectorAll('option:checked'), (e) => /** @type {HTMLOptionElement} */ (e).value @@ -408,13 +422,16 @@ export function form(id) { set_nested_value(input, name, value); } else if (is_file) { - if (DEV && element.multiple) { + const input_element = /** @type {HTMLInputElement} */ (element); + + if (DEV && input_element.multiple) { throw new Error( `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` ); } - const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; + const file = /** @type {HTMLInputElement & { files: FileList }} */ (input_element) + .files[0]; if (file) { set_nested_value(input, name, file); @@ -432,7 +449,9 @@ export function form(id) { set_nested_value( input, name, - element.type === 'checkbox' && !element.checked ? null : element.value + element instanceof HTMLInputElement && element.type === 'checkbox' && !element.checked + ? null + : element.value ); } @@ -449,6 +468,7 @@ export function form(id) { await tick(); input = convert_formdata(new FormData(form)); + user_edited_text_controls = new WeakSet(); }; form.addEventListener('reset', handle_reset); @@ -498,11 +518,13 @@ export function form(id) { (path, value) => { if (path.length === 0) { input = value; + clear_user_edited_text_controls(element, user_edited_text_controls); } else { deep_set(input, path.map(String), value); const key = build_path_string(path); touched[key] = true; + clear_user_edited_text_controls(element, user_edited_text_controls, key); } }, () => issues @@ -545,7 +567,8 @@ export function form(id) { const html_constraint_issues = get_html_constraint_issues(element, { include_untouched: includeUntouched, submitted, - touched + touched, + user_edited_text_controls }); const validated = await preflight_schema?.['~standard'].validate(data); @@ -586,6 +609,7 @@ export function form(id) { } if (html_constraint_issues.length > 0) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity const html_constraint_names = new Set( html_constraint_issues.map((issue) => issue.name) ); @@ -665,13 +689,15 @@ function clone(element) { /** * In some cases programmatic value updates can bypass minlength/maxlength checks during submit. - * Re-check text controls and let the browser show its native validation UI. + * Re-check text controls whose current value still came from direct user input. * @param {HTMLFormElement} form + * @param {WeakSet} user_edited_text_controls */ -function get_invalid_length_control(form) { +function get_invalid_length_control(form, user_edited_text_controls) { for (const element of form.elements) { if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) continue; if (!element.willValidate || element.disabled || !element.name) continue; + if (!user_edited_text_controls.has(element)) continue; if (has_invalid_length(element)) return element; } @@ -692,35 +718,20 @@ function has_invalid_length(element) { return false; } -/** - * In edge cases where length validity is missed, `validationMessage` may be empty. - * Use a deterministic message only as a fallback. - * @param {HTMLInputElement | HTMLTextAreaElement} element - */ -function get_length_validation_message(element) { - const value_length = element.value.length; - const min_length = element.minLength; - const max_length = element.maxLength; - - if (value_length > 0 && min_length > -1 && value_length < min_length) { - return `Please lengthen this text to ${min_length} characters or more.`; - } - - if (max_length > -1 && value_length > max_length) { - return `Please shorten this text to ${max_length} characters or fewer.`; - } - - return 'Please match the requested text length.'; -} - /** * @param {HTMLFormElement} form - * @param {{ include_untouched: boolean, submitted: boolean, touched: Record }} options + * @param {{ + * include_untouched: boolean, + * submitted: boolean, + * touched: Record, + * user_edited_text_controls: WeakSet + * }} options * @returns {InternalRemoteFormIssue[]} */ function get_html_constraint_issues(form, options) { /** @type {InternalRemoteFormIssue[]} */ const issues = []; + // eslint-disable-next-line svelte/prefer-svelte-reactivity const seen = new Set(); for (const element of form.elements) { @@ -743,6 +754,7 @@ function get_html_constraint_issues(form, options) { if (!options.include_untouched && !options.submitted && !options.touched[name]) continue; const invalid_length = (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && + options.user_edited_text_controls.has(element) && has_invalid_length(element); if (!invalid_length && element.checkValidity()) continue; @@ -752,9 +764,7 @@ function get_html_constraint_issues(form, options) { issues.push({ name: parsed.name, path: parsed.path, - message: invalid_length - ? element.validationMessage || get_length_validation_message(element) - : element.validationMessage || 'Invalid value', + message: element.validationMessage || 'Invalid value', server: false }); @@ -766,6 +776,28 @@ function get_html_constraint_issues(form, options) { return issues; } +/** + * @param {HTMLFormElement | null} form + * @param {WeakSet} user_edited_text_controls + * @param {string | null} [path] + */ +function clear_user_edited_text_controls(form, user_edited_text_controls, path = null) { + if (!form) return; + + for (const element of form.elements) { + if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + continue; + } + + if (path !== null) { + const name = normalize_control_name(element.name); + if (!matches_path(name, path)) continue; + } + + user_edited_text_controls.delete(element); + } +} + /** * @param {string} name */ @@ -789,6 +821,14 @@ function parse_issue_path(name) { } } +/** + * @param {string} name + * @param {string} path + */ +function matches_path(name, path) { + return name === path || name.startsWith(path + '.') || name.startsWith(path + '['); +} + /** * @param {FormData} form_data * @param {string} enctype diff --git a/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte index ef2897ce7816..0570f40196c9 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/form/html-constraints/+page.svelte @@ -9,7 +9,10 @@
- + +

{validate_code.result || ''}

@@ -20,6 +23,16 @@ onchange={() => validate_code_preflight.validate({ preflightOnly: true })} > +

{validate_code_preflight.fields.code.issues()?.length ?? 0}

diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 7468d6449385..f2b7bf0120e3 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -335,7 +335,7 @@ test.describe('remote functions', () => { page.on('request', handler); const input = page.locator('[data-submit] input[name="code"]'); - const submit = page.locator('[data-submit] button'); + const submit = page.locator('[data-submit] button[type="submit"]'); const result = page.locator('#result'); await input.focus(); @@ -394,6 +394,45 @@ test.describe('remote functions', () => { page.off('request', handler); }); + test('form allows programmatic values that only fail length constraints', async ({ + page, + javaScriptEnabled + }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/html-constraints'); + + const input = page.locator('[data-submit] input[name="code"]'); + const set = page.locator('[data-programmatic-submit]'); + const submit = page.locator('[data-submit] button[type="submit"]'); + const result = page.locator('#result'); + const request = page.waitForRequest( + (request) => request.url().includes('/_app/remote') && request.method() === 'POST' + ); + + await set.click(); + await expect(input).toHaveValue('1'); + await submit.click(); + await request; + await expect(result).toHaveText('1'); + }); + + test('form validate({ preflightOnly: true }) ignores programmatic length values', async ({ + page, + javaScriptEnabled + }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/html-constraints'); + + const input = page.locator('[data-preflight] input[name="code"]'); + const issue_count = page.locator('#preflight-issue-count'); + + await page.locator('[data-programmatic-preflight]').click(); + await expect(input).toHaveValue('1'); + await expect(issue_count).toHaveText('0'); + }); + test('form validate works', async ({ page, javaScriptEnabled }) => { if (!javaScriptEnabled) return; From 0dfafe63056d7dbede0c437c3f11110337412c65 Mon Sep 17 00:00:00 2001 From: razinshafayet Date: Sat, 7 Mar 2026 15:42:22 +0600 Subject: [PATCH 4/4] resolve merge conflict --- .../src/runtime/client/remote-functions/form.svelte.js | 8 ++++++++ 1 file changed, 8 insertions(+) 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 776429d2a818..4681dab69084 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -302,6 +302,14 @@ export function form(id) { return; } + const target = event.submitter?.hasAttribute('formtarget') + ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formTarget + : clone(form).target; + + if (target === '_blank') { + return; + } + const no_validate = clone(form).noValidate; // respects
const submitter_no_validate = event.submitter &&