Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/number-input-spec-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': fix
---

fix: align `form` number handling with HTML spec
71 changes: 68 additions & 3 deletions packages/kit/src/runtime/form-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can an invalid value other than '' even happen? I can't find a way to enter one

return parseFloat(value);
}

/**
* Sets a value in a nested object using a path string, mutating the original object
* @param {Record<string, any>} object
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions packages/kit/src/runtime/form-utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, boolean>} */
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('');
});
});
Loading