diff --git a/.changeset/grumpy-boats-beg.md b/.changeset/grumpy-boats-beg.md new file mode 100644 index 000000000000..f677743defa5 --- /dev/null +++ b/.changeset/grumpy-boats-beg.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep input in sync when binding updated via effect 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..2fb7943bcc2f 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -1,4 +1,3 @@ -/** @import { Batch } from '../../../reactivity/batch.js' */ import { DEV } from 'esm-env'; import { render_effect, teardown } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; @@ -19,8 +18,6 @@ import { current_batch } from '../../../reactivity/batch.js'; export function bind_value(input, get, set = get) { var runes = is_runes(); - var batches = new WeakSet(); - listen_to_event_and_reset_event(input, 'input', (is_reset) => { if (DEV && input.type === 'checkbox') { // TODO should this happen in prod too? @@ -32,10 +29,6 @@ export function bind_value(input, get, set = get) { value = is_numberlike_input(input) ? to_number(value) : value; set(value); - if (current_batch !== null) { - batches.add(current_batch); - } - // In runes mode, respect any validation in accessors (doesn't apply in legacy mode, // because we use mutable state which ensures the render effect always runs) if (runes && value !== (value = get())) { @@ -62,10 +55,6 @@ export function bind_value(input, get, set = get) { (untrack(get) == null && input.value) ) { set(is_numberlike_input(input) ? to_number(input.value) : input.value); - - if (current_batch !== null) { - batches.add(current_batch); - } } render_effect(() => { @@ -76,9 +65,9 @@ export function bind_value(input, get, set = get) { var value = get(); - if (input === document.activeElement && batches.has(/** @type {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: + if (input === document.activeElement && current_batch?.flushing_async) { + // Never rewrite the contents of a focused input when flushing async work. + // We can get here if, for example, an update is deferred because of async work depending on the input: // // //
{await find(query)}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ec082bb595ff..b527d7f5966a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -152,6 +152,11 @@ export class Batch { */ skipped_effects = new Set(); + /** + * True while a batch that had asynchronous work (i.e. a pending count) is being flushed. + */ + flushing_async = false; + /** * * @param {Effect[]} root_effects @@ -412,6 +417,8 @@ export class Batch { this.#pending -= 1; if (this.#pending === 0) { + this.flushing_async = true; + for (const e of this.#render_effects) { set_signal_status(e, DIRTY); schedule_effect(e); @@ -431,6 +438,8 @@ export class Batch { this.#effects = []; this.flush(); + + this.flushing_async = true; } else { this.deactivate(); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js new file mode 100644 index 000000000000..6442db9f5460 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js @@ -0,0 +1,37 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await new Promise((resolve) => setTimeout(resolve, 110)); + + const [input] = target.querySelectorAll('input'); + + assert.equal(input.value, 'a'); + assert.htmlEqual(target.innerHTML, `a
`); + + flushSync(() => { + input.focus(); + input.value = 'ab'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + flushSync(() => { + input.focus(); + input.value = 'abc'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(input.value, 'abc'); + assert.htmlEqual(target.innerHTML, `ab
`); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(input.value, 'abc'); + assert.htmlEqual(target.innerHTML, `abc
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte new file mode 100644 index 000000000000..42d2256aef99 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte @@ -0,0 +1,19 @@ + + +{await push(value)}
+ + + {#snippet pending()} +loading
+ {/snippet} +2
`); + + flushSync(() => { + input.focus(); + input.value = '3'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + assert.equal(input.value, '3'); + assert.htmlEqual(target.innerHTML, `3
`); + + flushSync(() => { + input.focus(); + input.value = '6'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + assert.equal(input.value, '5'); + assert.htmlEqual(target.innerHTML, `5
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte new file mode 100644 index 000000000000..9b33901650ef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte @@ -0,0 +1,22 @@ + + +{value}
+