diff --git a/.changeset/quiet-inputs-keep.md b/.changeset/quiet-inputs-keep.md
new file mode 100644
index 0000000000..0efcfcc058
--- /dev/null
+++ b/.changeset/quiet-inputs-keep.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't overwrite an unchanged spread `value`, preserving incomplete number input
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index 0fe1b8b2d3..7d2dd62baf 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -422,7 +422,21 @@ function set_attributes(
} 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
// 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) {
set_selected(/** @type {HTMLOptionElement} */ (element), value);
} else {
diff --git a/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js
new file mode 100644
index 0000000000..cec1e179ac
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/_config.js
@@ -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');
+ }
+});
diff --git a/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte
new file mode 100644
index 0000000000..3e7a5cc335
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/samples/spread-input-number-incomplete-value/main.svelte
@@ -0,0 +1,5 @@
+
+
+ (value = e.currentTarget.value)} />