fix: don't overwrite an unchanged spread `value` (#18864)

Fixes #18862

When a `value` is applied through a spread (`<input {...props} />`),
`set_attributes` always writes `element.value`, even if the element
already has that value. Writing `value` on a number input clears any
incomplete text: typing `250.` makes `input.value` `''` (the text is
still visible, but `validity.badInput` is `true`). If the spread `value`
then becomes `''` too, writing `''` erases what the user typed.

This is what happens with SvelteKit remote forms:
`fields.quantity.as('number')` coerces `''` to `undefined`, the spread
`value` becomes `''`, and Svelte writes it back to the input. When `.`
is typed:

- Chromium clears the input in every locale, because `250.` is always
incomplete input there.
- WebKit/Safari clears it in locales where `.` is not the decimal
separator (e.g. `fr-CH`), which is where the reporter saw it.

A plain `<input type="number">` never clears. Neither does the same
`value` in Svelte without a spread (`value={...}`), because `set_value`
already skips writing when `element.value === value`. This PR adds the
same check to the spread path. Svelte 3 had this check too (#3426,
#3495); it was lost in Svelte 5.

The first write still always happens. Writing `value` sets the input's
dirty value flag, and without that flag a `defaultValue` applied
afterwards would change the current value (covered by
`form-default-value-spread`).

This also likely affects sveltejs/kit#16270 (same symptom, plus a cursor
jump caused by redundant writes). That issue is handled on the kit side
in sveltejs/kit#16320; this PR is independent of it.

The test runs in a real browser (`runtime-browser`). It types with
`document.execCommand('insertText')`, which goes through Chromium's own
editing, so `250.` is real incomplete input. The locale is not mocked.
The test fails without this change and passes with it.

### Before submitting the PR, please make sure you do the following

- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`

---------

Co-authored-by: Paolo Ricciuti <ricciutipaolo@gmail.com>
pull/18821/merge
Mariana Castro 3 days ago committed by GitHub
parent 325620ba63
commit 38098a9311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't overwrite an unchanged spread `value`, preserving incomplete number input

@ -422,7 +422,21 @@ function set_attributes(
} else if (!is_custom_element && (key === '__value' || (key === 'value' && value != null))) { } else if (!is_custom_element && (key === '__value' || (key === 'value' && value != null))) {
// @ts-ignore We're not running this for custom elements because __value is actually // @ts-ignore We're not running this for custom elements because __value is actually
// how Lit stores the current value on the element, and messing with that would break things. // how Lit stores the current value on the element, and messing with that would break things.
element.value = element.__value = value; element.__value = value;
// we don't set the value if it hasn't changed. This supports invalid number inputs like `1e` because
// 1. user types 1e
// 2. the state is updated reading e.target.value which is ''
// 3. the spreaded value is ''
// 4. updating input.value would thus, clear the user value
if (
prev_value == null ||
// @ts-ignore
element.value !== value ||
(value === 0 && element.nodeName === PROGRESS_TAG)
) {
// @ts-ignore
element.value = value;
}
} else if (key === 'selected' && is_option_element) { } else if (key === 'selected' && is_option_element) {
set_selected(/** @type {HTMLOptionElement} */ (element), value); set_selected(/** @type {HTMLOptionElement} */ (element), value);
} else { } else {

@ -0,0 +1,28 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../assert';
export default test({
async test({ assert, target }) {
const input = target.querySelector('input');
ok(input);
input.focus();
// we need to use `document.execCommand('insertText', false, ...)` to simulate user input
// because directly setting an invalid value to `input.value` would simply clear the input
// and dispatching an event would not update the input correctly
document.execCommand('insertText', false, '1');
flushSync();
// `1e` is incomplete on every platform, unlike `1.` which Chromium on Linux accepts as `1`
document.execCommand('insertText', false, 'e');
flushSync();
assert.equal(input.value, '');
assert.equal(input.validity.badInput, true);
document.execCommand('insertText', false, '5');
flushSync();
assert.equal(input.value, '1e5');
}
});

@ -0,0 +1,5 @@
<script>
let value = $state('');
</script>
<input {...{ type: 'number', value }} oninput={(e) => (value = e.currentTarget.value)} />
Loading…
Cancel
Save