From 92c9c963eaeec8e9c870fcadd15b8d36147ecd95 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 24 Jul 2025 09:42:20 -0400 Subject: [PATCH 1/3] fix: don't update a focused input with values from its own past --- .changeset/fast-mails-fail.md | 5 +++ .../client/dom/elements/bindings/input.js | 4 +- .../src/internal/client/reactivity/batch.js | 8 ++++ .../_config.js | 37 +++++++++++++++++++ .../main.svelte | 25 +++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .changeset/fast-mails-fail.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte diff --git a/.changeset/fast-mails-fail.md b/.changeset/fast-mails-fail.md new file mode 100644 index 000000000000..027cb01548c2 --- /dev/null +++ b/.changeset/fast-mails-fail.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't update a focused input with values from its own past diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index 7c1fccea0fbc..6fbdca587976 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -8,7 +8,7 @@ import { queue_micro_task } from '../../task.js'; import { hydrating } from '../../hydration.js'; import { untrack } from '../../../runtime.js'; import { is_runes } from '../../../context.js'; -import { current_batch } from '../../../reactivity/batch.js'; +import { current_batch, previous_batch } from '../../../reactivity/batch.js'; /** * @param {HTMLInputElement} input @@ -76,7 +76,7 @@ export function bind_value(input, get, set = get) { var value = get(); - if (input === document.activeElement && batches.has(/** @type {Batch} */ (current_batch))) { + if (input === document.activeElement && batches.has(/** @type {Batch} */ (previous_batch))) { // Never rewrite the contents of a focused input. We can get here if, for example, // an update is deferred because of async work depending on the input: // diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 89bad947c7fa..a7b848daf318 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -38,6 +38,9 @@ const batches = new Set(); /** @type {Batch | null} */ export let current_batch = null; +/** @type {Batch | null} */ +export let previous_batch = current_batch; + /** * When time travelling, we re-evaluate deriveds based on the temporary * values of their dependencies rather than their actual values, and cache @@ -72,7 +75,10 @@ let is_flushing = false; let is_flushing_sync = false; +let uid = 1; export class Batch { + id = uid++; + /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` @@ -218,6 +224,7 @@ export class Batch { // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with // newly updated sources, which could lead to infinite loops when effects run over and over again. + previous_batch = current_batch; current_batch = null; flush_queued_effects(render_effects); @@ -350,6 +357,7 @@ export class Batch { deactivate() { current_batch = null; + previous_batch = null; for (const update of effect_pending_updates) { effect_pending_updates.delete(update); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js new file mode 100644 index 000000000000..b0772ad3c071 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, instance }) { + instance.shift(); + await tick(); + + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = '1'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.htmlEqual(target.innerHTML, `

0

`); + assert.equal(input.value, '1'); + + input.focus(); + input.value = '2'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.htmlEqual(target.innerHTML, `

0

`); + assert.equal(input.value, '2'); + + instance.shift(); + await tick(); + assert.htmlEqual(target.innerHTML, `

1

`); + assert.equal(input.value, '2'); + + instance.shift(); + await tick(); + assert.htmlEqual(target.innerHTML, `

2

`); + assert.equal(input.value, '2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte new file mode 100644 index 000000000000..2fc898e6540d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte @@ -0,0 +1,25 @@ + + + + +

{await push(count)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
From ab0b2591616ac0bce20ed8cf037ba77afc334fd9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 24 Jul 2025 09:49:50 -0400 Subject: [PATCH 2/3] remove --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a7b848daf318..0bfca49e91e9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -74,11 +74,7 @@ let last_scheduled_effect = null; let is_flushing = false; let is_flushing_sync = false; - -let uid = 1; export class Batch { - id = uid++; - /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` From d5f0b6cf22372eb1bdc41d9c8cb380d1a7675853 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 24 Jul 2025 10:01:31 -0400 Subject: [PATCH 3/3] fix --- .../src/internal/client/dom/elements/bindings/input.js | 9 +++++++-- .../svelte/src/internal/client/reactivity/batch.js | 10 ++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index 6fbdca587976..7c73280dd664 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -76,13 +76,18 @@ export function bind_value(input, get, set = get) { var value = get(); - if (input === document.activeElement && batches.has(/** @type {Batch} */ (previous_batch))) { + if (input === document.activeElement) { + // we need both, because in non-async mode, render effects run before previous_batch is set + var batch = /** @type {Batch} */ (previous_batch ?? current_batch); + // Never rewrite the contents of a focused input. We can get here if, for example, // an update is deferred because of async work depending on the input: // // //

{await find(query)}

- return; + if (batches.has(batch)) { + return; + } } if (is_numberlike_input(input) && value === to_number(input.value)) { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 0bfca49e91e9..123bc95d163a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -38,8 +38,12 @@ const batches = new Set(); /** @type {Batch | null} */ export let current_batch = null; -/** @type {Batch | null} */ -export let previous_batch = current_batch; +/** + * This is needed to avoid overwriting inputs in non-async mode + * TODO 6.0 remove this, as non-async mode will go away + * @type {Batch | null} + */ +export let previous_batch = null; /** * When time travelling, we re-evaluate deriveds based on the temporary @@ -175,6 +179,8 @@ export class Batch { process(root_effects) { queued_root_effects = []; + previous_batch = null; + /** @type {Map | null} */ var current_values = null;