From a23295b9becb0cbf0ec7668cb93880d7382e5042 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 10 Jul 2026 19:11:10 +0000 Subject: [PATCH] fix: align `form` number handling with HTML spec --- .changeset/number-input-spec-alignment.md | 5 + packages/kit/src/runtime/form-utils.js | 71 +++++++- packages/kit/src/runtime/form-utils.spec.js | 178 ++++++++++++++++++++ 3 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 .changeset/number-input-spec-alignment.md diff --git a/.changeset/number-input-spec-alignment.md b/.changeset/number-input-spec-alignment.md new file mode 100644 index 000000000000..a503f110d59b --- /dev/null +++ b/.changeset/number-input-spec-alignment.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': fix +--- + +fix: align `form` number handling with HTML spec diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 385ff7dfc60e..74a7cc274418 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -9,6 +9,35 @@ import { SvelteKitError } from '@sveltejs/kit/internal'; const decoder = new TextDecoder(); +/** + * Regex matching the HTML spec's "valid floating-point number" definition + * (https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number). + * Unlike `parseFloat`, this rejects strings like `"20."`, `"20,"`, `"+20"`, `"Infinity"`, etc. + */ +const valid_floating_point_number_regex = /^-?(\d+|\.\d+|\d+\.\d+)([eE][+-]?\d+)?$/; + +/** + * Checks if a string is a valid floating-point number per the HTML spec. + * @param {string} value + * @returns {boolean} + */ +export function is_valid_floating_point_number(value) { + return valid_floating_point_number_regex.test(value); +} + +/** + * Parses a string as a floating-point number using the HTML spec's + * "valid floating-point number" definition. Returns `undefined` for + * empty or invalid strings, mirroring the spec's value sanitization + * algorithm which sets invalid values to the empty string. + * @param {string} value + * @returns {number | undefined} + */ +function parse_number_value(value) { + if (value === '' || !valid_floating_point_number_regex.test(value)) return undefined; + return parseFloat(value); +} + /** * Sets a value in a nested object using a path string, mutating the original object * @param {Record} object @@ -18,7 +47,7 @@ const decoder = new TextDecoder(); 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); + value = parse_number_value(value); } else if (path_string.startsWith('b:')) { path_string = path_string.slice(2); value = value === 'on'; @@ -53,7 +82,7 @@ export function convert_formdata(data) { if (key.startsWith('n:')) { key = key.slice(2); - values = values.map((v) => (v === '' ? undefined : parseFloat(/** @type {string} */ (v)))); + values = values.map((v) => parse_number_value(v)); } else if (key.startsWith('b:')) { key = key.slice(2); values = values.map((v) => v === 'on'); @@ -896,7 +925,43 @@ export function create_field_proxy(target, get, set, get_issues, get_touched, ge }); } - // Handle all other input types (text, number, etc.) + // Handle number/range inputs — these need special handling to avoid + // clobbering the browser's intermediate editing state (e.g. "20.") + // which the browser reports as element.value === "" for type=number. + // When the field has been edited and the current value is undefined + // (meaning the input is currently invalid), return '' so that Svelte's + // set_value guard (element.value === value) prevents the write — + // the browser reports element.value as "" for invalid type=number input + if (type === 'number' || type === 'range') { + return Object.defineProperties(base_props, { + defaultValue: { + enumerable: true, + get() { + return input_value; + } + }, + value: { + enumerable: true, + get() { + const value = get_value(); + if (value != null) return String(value); + + // Field has no valid number — if it has been edited, + // return '' instead of falling back to input_value, + // so Svelte doesn't clobber the browser's intermediate + // editing state (e.g. "20." which the browser reports as "") + if (key !== '' && get_dirty()[key]) { + return ''; + } + + // Not yet edited — show the default value for initial render + return input_value != null ? String(input_value) : ''; + } + } + }); + } + + // Handle all other input types (text, etc.) return Object.defineProperties(base_props, { defaultValue: { enumerable: true, diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 41d0a286f50d..c44d7b9c6d8c 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, + is_valid_floating_point_number, serialize_binary_form, split_path } from './form-utils.js'; @@ -114,6 +115,92 @@ describe('convert_formdata', () => { data.append(attack, 'bad'); expect(() => convert_formdata(data)).toThrow(/Invalid key "/); }); + + test('coerces number fields using the strict valid floating-point number definition', () => { + const data = new FormData(); + data.append('n:integer', '42'); + data.append('n:decimal', '20.5'); + data.append('n:negative', '-3.14'); + data.append('n:leading_dot', '.5'); + data.append('n:exponent', '1e3'); + data.append('n:negative_exponent', '1.5e-2'); + data.append('n:trailing_dot', '20.'); + data.append('n:comma_decimal', '20,5'); + data.append('n:plus_sign', '+20'); + data.append('n:whitespace', ' 20 '); + data.append('n:infinity', 'Infinity'); + data.append('n:nan', 'NaN'); + data.append('n:empty', ''); + data.append('n:just_dot', '.'); + data.append('n:zero', '0'); + data.append('n:negative_zero', '-0'); + + expect(convert_formdata(data)).toEqual({ + integer: 42, + decimal: 20.5, + negative: -3.14, + leading_dot: 0.5, + exponent: 1000, + negative_exponent: 0.015, + trailing_dot: undefined, + comma_decimal: undefined, + plus_sign: undefined, + whitespace: undefined, + infinity: undefined, + nan: undefined, + empty: undefined, + just_dot: undefined, + zero: 0, + negative_zero: -0 + }); + }); + + test('coerces number array fields', () => { + const data = new FormData(); + data.append('n:values[]', '1'); + data.append('n:values[]', '2.5'); + data.append('n:values[]', ''); + + expect(convert_formdata(data)).toEqual({ + values: [1, 2.5, undefined] + }); + }); +}); + +describe('is_valid_floating_point_number', () => { + test.each([ + ['20', true], + ['20.5', true], + ['.5', true], + ['-.5', true], + ['-20.5', true], + ['0', true], + ['-0', true], + ['00', true], + ['0.0', true], + ['1e3', true], + ['1.5e-2', true], + ['1E3', true], + ['1e+3', true], + ['-1e-3', true], + ['', false], + ['.', false], + ['20.', false], + ['20,', false], + ['20,5', false], + ['+20', false], + [' 20 ', false], + ['Infinity', false], + ['NaN', false], + ['abc', false], + ['1.2.3', false], + ['1e', false], + ['e3', false], + ['--20', false], + ['20-5', false] + ])('%s => %s', (input, expected) => { + expect(is_valid_floating_point_number(input)).toBe(expected); + }); }); describe('binary form serializer', () => { @@ -782,4 +869,95 @@ describe('create_field_proxy', () => { expect(cloned.getTime()).toBe(original.getTime()); expect(cloned).not.toBe(original); }); + + // Regression test for https://github.com/sveltejs/kit/issues/16270 + // The as('number') value getter must return '' (not the default value) + // when a dirty field has no valid number, so that Svelte's set_value + // guard prevents clobbering the browser's intermediate editing state. + test("as('number') value getter returns '' for dirty fields with undefined value", () => { + const input = { price: undefined }; + /** @type {Record} */ + const dirty = { price: true }; + + const proxy = create_field_proxy( + {}, + () => input, + () => {}, + () => ({}), + () => ({}), + () => dirty, + [] + ); + + const props = proxy.price.as('number', 42); + expect(props.value).toBe(''); + }); + + test("as('number') value getter returns default value for non-dirty fields with undefined value", () => { + const input = { price: undefined }; + + const proxy = create_field_proxy( + {}, + () => input, + () => {}, + () => ({}), + () => ({}), + () => ({}), + [] + ); + + const props = proxy.price.as('number', 42); + expect(props.value).toBe('42'); + }); + + test("as('number') value getter returns stringified number for valid values", () => { + const input = { price: 20.5 }; + + const proxy = create_field_proxy( + {}, + () => input, + () => {}, + () => ({}), + () => ({}), + () => ({ price: true }), + [] + ); + + const props = proxy.price.as('number', 42); + expect(props.value).toBe('20.5'); + }); + + test("as('number') value getter returns '' for dirty fields with undefined value and no default", () => { + const input = { price: undefined }; + + const proxy = create_field_proxy( + {}, + () => input, + () => {}, + () => ({}), + () => ({}), + () => ({ price: true }), + [] + ); + + const props = proxy.price.as('number'); + expect(props.value).toBe(''); + }); + + test("as('range') value getter behaves the same as as('number') for dirty undefined fields", () => { + const input = { value: undefined }; + + const proxy = create_field_proxy( + {}, + () => input, + () => {}, + () => ({}), + () => ({}), + () => ({ value: true }), + [] + ); + + const props = proxy.value.as('range', 50); + expect(props.value).toBe(''); + }); });