From b8893f95d9ad3f805ee637303bfc9b407baea651 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Jul 2025 17:50:35 +0200 Subject: [PATCH 01/19] - add state changes resulting from an $effect to a separate new batch - schedule rerunning effects based on the sources that are dirty, not just rerunning them all blindly (excempting async effects which will have run by that time already) --- .../src/internal/client/reactivity/batch.js | 176 ++++++++++-------- .../src/internal/client/reactivity/sources.js | 7 +- 2 files changed, 103 insertions(+), 80 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ec082bb595ff..6b7258ec4817 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -28,7 +28,7 @@ import * as e from '../errors.js'; import { flush_tasks } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; -import { old_values } from './sources.js'; +import { mark_reactions, old_values } from './sources.js'; import { unlink_effect } from './effects.js'; import { unset_context } from './async.js'; @@ -70,13 +70,15 @@ let last_scheduled_effect = null; let is_flushing = false; +let flushing_sync = false; + export class Batch { /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` * @type {Map} */ - #current = new Map(); + current = new Map(); /** * The values of any sources that are updated in this batch _before_ those updates took place. @@ -156,7 +158,7 @@ export class Batch { * * @param {Effect[]} root_effects */ - #process(root_effects) { + process(root_effects) { queued_root_effects = []; /** @type {Map | null} */ @@ -169,7 +171,7 @@ export class Batch { current_values = new Map(); batch_deriveds = new Map(); - for (const [source, current] of this.#current) { + for (const [source, current] of this.current) { current_values.set(source, { v: source.v, wv: source.wv }); source.v = current; } @@ -300,7 +302,7 @@ export class Batch { this.#previous.set(source, value); } - this.#current.set(source, source.v); + this.current.set(source, source.v); } activate() { @@ -346,49 +348,7 @@ export class Batch { } flush_effects() { - var was_updating_effect = is_updating_effect; - is_flushing = true; - - try { - var flush_count = 0; - set_is_updating_effect(true); - - while (queued_root_effects.length > 0) { - if (flush_count++ > 1000) { - if (DEV) { - var updates = new Map(); - - for (const source of this.#current.keys()) { - for (const [stack, update] of source.updated ?? []) { - var entry = updates.get(stack); - - if (!entry) { - entry = { error: update.error, count: 0 }; - updates.set(stack, entry); - } - - entry.count += update.count; - } - } - - for (const update of updates.values()) { - // eslint-disable-next-line no-console - console.error(update.error); - } - } - - infinite_loop_guard(); - } - - this.#process(queued_root_effects); - old_values.clear(); - } - } finally { - is_flushing = false; - set_is_updating_effect(was_updating_effect); - - last_scheduled_effect = null; - } + flush_effects(); } /** @@ -412,19 +372,8 @@ export class Batch { this.#pending -= 1; if (this.#pending === 0) { - for (const e of this.#render_effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); - } - - for (const e of this.#effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); - } - - for (const e of this.#block_effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); + for (const source of this.current.keys()) { + mark_reactions(source, DIRTY, false); } this.#render_effects = []; @@ -487,32 +436,88 @@ export function flushSync(fn) { e.flush_sync_in_effect(); } - var result; + var prev_flushing_sync = flushing_sync; + flushing_sync = true; - const batch = Batch.ensure(false); + try { + var result; - if (fn) { - batch.flush_effects(); + const batch = Batch.ensure(false); - result = fn(); - } + if (fn) { + batch.flush_effects(); - while (true) { - flush_tasks(); + result = fn(); + } + + while (true) { + flush_tasks(); + + if (queued_root_effects.length === 0) { + // TODO this might need adjustment + if (batch === current_batch) { + batch.flush(); + } + + // this would be reset in `batch.flush_effects()` but since we are early returning here, + // we need to reset it here as well in case the first time there's 0 queued root effects + last_scheduled_effect = null; - if (queued_root_effects.length === 0) { - if (batch === current_batch) { - batch.flush(); + return /** @type {T} */ (result); } - // this would be reset in `batch.flush_effects()` but since we are early returning here, - // we need to reset it here as well in case the first time there's 0 queued root effects - last_scheduled_effect = null; + batch.flush_effects(); + } + } finally { + flushing_sync = prev_flushing_sync; + } +} - return /** @type {T} */ (result); +function flush_effects() { + var was_updating_effect = is_updating_effect; + is_flushing = true; + + try { + var flush_count = 0; + var batch = /** @type {Batch} */ (current_batch); + set_is_updating_effect(true); + + while (queued_root_effects.length > 0) { + if (flush_count++ > 1000) { + if (DEV) { + var updates = new Map(); + + for (const source of batch.current.keys()) { + for (const [stack, update] of source.updated ?? []) { + var entry = updates.get(stack); + + if (!entry) { + entry = { error: update.error, count: 0 }; + updates.set(stack, entry); + } + + entry.count += update.count; + } + } + + for (const update of updates.values()) { + // eslint-disable-next-line no-console + console.error(update.error); + } + } + + infinite_loop_guard(); + } + + batch = /** @type {Batch} */ (current_batch); + batch.process(queued_root_effects); + old_values.clear(); } + } finally { + is_flushing = false; + set_is_updating_effect(was_updating_effect); - batch.flush_effects(); + last_scheduled_effect = null; } } @@ -545,6 +550,7 @@ function flush_queued_effects(effects) { if ((effect.f & (DESTROYED | INERT)) === 0) { if (is_dirty(effect)) { var wv = write_version; + var current_size = /** @type {Batch} */ (current_batch).current.size; update_effect(effect); @@ -568,6 +574,22 @@ function flush_queued_effects(effects) { // if state is written in a user effect, abort and re-schedule, lest we run // effects that should be removed as a result of the state change if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { + // 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. + // We need to bring over the just written sources though to correctly mark the right reactions as dirty. + var old_batch = /** @type {Batch} */ (current_batch); + batches.delete(old_batch); + current_batch = null; + var new_batch = Batch.ensure(!flushing_sync); + var current_idx = 0; + // We're taking advantage of the spec here which says that entries in a Map are traversed by insertion order + for (const source of old_batch.current) { + if (current_idx >= current_size) { + new_batch.capture(source[0], source[1]); + } + current_idx++; + } + i++; break; } } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index f6b14f3360de..9600714feb71 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -301,9 +301,10 @@ export function increment(source) { /** * @param {Value} signal * @param {number} status should be DIRTY or MAYBE_DIRTY + * @param {boolean} schedule_async * @returns {void} */ -function mark_reactions(signal, status) { +export function mark_reactions(signal, status, schedule_async = true) { var reactions = signal.reactions; if (reactions === null) return; @@ -324,13 +325,13 @@ function mark_reactions(signal, status) { } // don't set a DIRTY reaction to MAYBE_DIRTY - if ((flags & DIRTY) === 0) { + if ((flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0)) { set_signal_status(reaction, status); } if ((flags & DERIVED) !== 0) { mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY); - } else if ((flags & DIRTY) === 0) { + } else if ((flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0)) { schedule_effect(/** @type {Effect} */ (reaction)); } } From 26f46bac35eb5853eacb33096abd7c2254f102d7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Jul 2025 17:52:53 +0200 Subject: [PATCH 02/19] test --- .../async-effect-triggers-await/_config.js | 32 +++++++++++++++++++ .../async-effect-triggers-await/main.svelte | 24 ++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js new file mode 100644 index 000000000000..c551cc6b8c39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

1

+

1

+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

2

+

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte new file mode 100644 index 000000000000..153fe03f0d89 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte @@ -0,0 +1,24 @@ + + + + +

{JSON.stringify((await data), null, 2)}

+ {#if true} + +

{unrelated}

+ {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
From 0c0662bbdca1159c3630f43c5d4d7ef194e4cf89 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Jul 2025 20:21:03 +0200 Subject: [PATCH 03/19] better fix --- .../src/internal/client/reactivity/batch.js | 31 +++++++++---------- .../src/internal/client/reactivity/sources.js | 4 +-- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 6e748ffbdf9e..2c959855002c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -70,7 +70,7 @@ let last_scheduled_effect = null; let is_flushing = false; -let flushing_sync = false; +export let flushing_sync = false; export class Batch { /** @@ -204,9 +204,22 @@ export class Batch { this.#effects = []; this.#block_effects = []; + // 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. + current_batch = null; + flush_queued_effects(render_effects); flush_queued_effects(effects); + // Reinstate the current batch if there was no new one created, as `process()` runs in a loop in `flush_effects()`. + // That method expects `current_batch` to be set, and could run the loop again if effects result in new effects + // being scheduled but without writes happening in which case no new batch is created. + if (current_batch === null) { + current_batch = this; + } else { + batches.delete(this); + } + this.#deferred?.resolve(); } else { // otherwise mark effects clean so they get scheduled on the next run @@ -551,7 +564,6 @@ function flush_queued_effects(effects) { if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) { var wv = write_version; - var current_size = /** @type {Batch} */ (current_batch).current.size; update_effect(effect); @@ -575,21 +587,6 @@ function flush_queued_effects(effects) { // if state is written in a user effect, abort and re-schedule, lest we run // effects that should be removed as a result of the state change if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { - // 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. - // We need to bring over the just written sources though to correctly mark the right reactions as dirty. - var old_batch = /** @type {Batch} */ (current_batch); - batches.delete(old_batch); - current_batch = null; - var new_batch = Batch.ensure(!flushing_sync); - var current_idx = 0; - // We're taking advantage of the spec here which says that entries in a Map are traversed by insertion order - for (const source of old_batch.current) { - if (current_idx >= current_size) { - new_batch.capture(source[0], source[1]); - } - current_idx++; - } break; } } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 9600714feb71..41fda858a52b 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -33,7 +33,7 @@ import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack, tag_proxy } from '../dev/tracing.js'; import { component_context, is_runes } from '../context.js'; -import { Batch, schedule_effect } from './batch.js'; +import { Batch, flushing_sync, schedule_effect } from './batch.js'; import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; @@ -179,7 +179,7 @@ export function internal_set(source, value) { source.v = value; - const batch = Batch.ensure(); + const batch = Batch.ensure(!flushing_sync); batch.capture(source, old_value); if (DEV) { From 4cb484709f39edfb1843b3a5834fa6278a990d88 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Jul 2025 21:13:41 +0200 Subject: [PATCH 04/19] tests --- .../_config.js | 26 ++++++++++++++++++ .../main.svelte | 27 +++++++++++++++++++ .../binding-update-while-focused-2/_config.js | 24 +++++++++++++++++ .../main.svelte | 21 +++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte 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..782ae945f9c3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js @@ -0,0 +1,26 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = '3'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.equal(input.value, '3'); + assert.htmlEqual(target.innerHTML, `

3

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

2

`); + } +}); 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..763ce6ebf073 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte @@ -0,0 +1,27 @@ + + + +

{await value}

+ + + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js new file mode 100644 index 000000000000..7a56c79d7176 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js @@ -0,0 +1,24 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = '3'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + flushSync(); + + assert.equal(input.value, '3'); + assert.htmlEqual(target.innerHTML, `

3

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

2

`); + } +}); 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..b0597c223b99 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte @@ -0,0 +1,21 @@ + + +

{value}

+ From 9e54e9ae20205182cdcc13e26db9056353c79a15 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 22 Jul 2025 22:56:16 +0200 Subject: [PATCH 05/19] this fixes the last test somehow --- .../svelte/src/internal/client/reactivity/batch.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2c959855002c..f230159978a8 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -472,11 +472,14 @@ export function flushSync(fn) { batch.flush(); } - // this would be reset in `batch.flush_effects()` but since we are early returning here, - // we need to reset it here as well in case the first time there's 0 queued root effects - last_scheduled_effect = null; + // TODO this feels wrong + if (queued_root_effects.length === 0) { + // this would be reset in `batch.flush_effects()` but since we are early returning here, + // we need to reset it here as well in case the first time there's 0 queued root effects + last_scheduled_effect = null; - return /** @type {T} */ (result); + return /** @type {T} */ (result); + } } batch.flush_effects(); From 23b44ced5225bbcf4a3133c5c3f8514b77f4ffd5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 16:57:22 -0400 Subject: [PATCH 06/19] fix #16477 --- packages/svelte/src/internal/client/reactivity/batch.js | 8 ++++++-- .../samples/1000-reading-derived-effects/Component.svelte | 7 +++++++ .../samples/1000-reading-derived-effects/_config.js | 5 +++++ .../samples/1000-reading-derived-effects/main.svelte | 8 ++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2c959855002c..ea4698073c64 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -563,7 +563,7 @@ function flush_queued_effects(effects) { var effect = effects[i++]; if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) { - var wv = write_version; + var n = current_batch ? current_batch.current.size : 0; update_effect(effect); @@ -586,7 +586,11 @@ function flush_queued_effects(effects) { // if state is written in a user effect, abort and re-schedule, lest we run // effects that should be removed as a result of the state change - if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { + if ( + current_batch !== null && + current_batch.current.size > n && + (effect.f & USER_EFFECT) !== 0 + ) { break; } } diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte new file mode 100644 index 000000000000..7a54323cb97f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js new file mode 100644 index 000000000000..2e4a27cf0912 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + async test() {} +}); diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte new file mode 100644 index 000000000000..258918e903bd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte @@ -0,0 +1,8 @@ + + +{#each arr} + +{/each} From 09357ae9529c9b7dfa6d64d45132b76cd6201eca Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 18:25:07 -0400 Subject: [PATCH 07/19] typo --- .../samples/1000-reading-derived-effects/main.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte index 258918e903bd..bd326edfb92a 100644 --- a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte @@ -1,5 +1,5 @@ From a77ee84a1b2254e1e6357a19a53c982d40e121f7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 18:48:52 -0400 Subject: [PATCH 08/19] copy over changeset from #16477 --- .changeset/thick-mice-kick.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thick-mice-kick.md diff --git a/.changeset/thick-mice-kick.md b/.changeset/thick-mice-kick.md new file mode 100644 index 000000000000..eec55b77eee4 --- /dev/null +++ b/.changeset/thick-mice-kick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: exclude derived writes from effect abort and rescheduling From 72f8bfe73af57a23f59148df5f285996f616e6ec Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 18:49:45 -0400 Subject: [PATCH 09/19] copy over changeset from #16464 --- .changeset/grumpy-boats-beg.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/grumpy-boats-beg.md 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 From 0ead78284bc39ea6625249b8323c2086dcfa59e2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 18:51:00 -0400 Subject: [PATCH 10/19] changeset --- .changeset/shiny-walls-fix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shiny-walls-fix.md diff --git a/.changeset/shiny-walls-fix.md b/.changeset/shiny-walls-fix.md new file mode 100644 index 000000000000..91ed548728a3 --- /dev/null +++ b/.changeset/shiny-walls-fix.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent infinite async loop From f92dfc158aeb23ec8a07be75f4359a8342d392cb Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 19:00:55 -0400 Subject: [PATCH 11/19] dedupe --- packages/svelte/src/internal/client/reactivity/sources.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 41fda858a52b..00c788596d2f 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -324,14 +324,16 @@ export function mark_reactions(signal, status, schedule_async = true) { continue; } + var should_schedule = (flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0); + // don't set a DIRTY reaction to MAYBE_DIRTY - if ((flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0)) { + if (should_schedule) { set_signal_status(reaction, status); } if ((flags & DERIVED) !== 0) { mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY); - } else if ((flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0)) { + } else if (should_schedule) { schedule_effect(/** @type {Effect} */ (reaction)); } } From a6d464478a1f18009bfb42ee356870dae5b66eca Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 19:04:38 -0400 Subject: [PATCH 12/19] move flushing_sync check inside Batch.ensure --- packages/svelte/src/internal/client/reactivity/batch.js | 8 ++++---- packages/svelte/src/internal/client/reactivity/sources.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index edbebbd3a261..c5371ad7cc9c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -70,7 +70,7 @@ let last_scheduled_effect = null; let is_flushing = false; -export let flushing_sync = false; +let flushing_sync = false; export class Batch { /** @@ -407,12 +407,12 @@ export class Batch { return (this.#deferred ??= deferred()).promise; } - static ensure(autoflush = true) { + static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); batches.add(current_batch); - if (autoflush) { + if (!flushing_sync) { Batch.enqueue(() => { if (current_batch !== batch) { // a flushSync happened in the meantime @@ -455,7 +455,7 @@ export function flushSync(fn) { try { var result; - const batch = Batch.ensure(false); + const batch = Batch.ensure(); if (fn) { batch.flush_effects(); diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 00c788596d2f..3b28c8fdceeb 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -33,7 +33,7 @@ import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack, tag_proxy } from '../dev/tracing.js'; import { component_context, is_runes } from '../context.js'; -import { Batch, flushing_sync, schedule_effect } from './batch.js'; +import { Batch, schedule_effect } from './batch.js'; import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; @@ -179,7 +179,7 @@ export function internal_set(source, value) { source.v = value; - const batch = Batch.ensure(!flushing_sync); + var batch = Batch.ensure(); batch.capture(source, old_value); if (DEV) { From f68635595c993a4198cafae8b9ad173f6a33b2d1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 19:04:58 -0400 Subject: [PATCH 13/19] unused --- packages/svelte/src/internal/client/reactivity/batch.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c5371ad7cc9c..eeb40c761faf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -21,8 +21,7 @@ import { is_updating_effect, set_is_updating_effect, set_signal_status, - update_effect, - write_version + update_effect } from '../runtime.js'; import * as e from '../errors.js'; import { flush_tasks } from '../dom/task.js'; From a98c75ea31a8c946ff50151e81d0f6a65e08bac7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 19:05:20 -0400 Subject: [PATCH 14/19] flushing_sync -> is_flushing_sync --- .../svelte/src/internal/client/reactivity/batch.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index eeb40c761faf..a08440d8c948 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -69,7 +69,7 @@ let last_scheduled_effect = null; let is_flushing = false; -let flushing_sync = false; +let is_flushing_sync = false; export class Batch { /** @@ -411,7 +411,7 @@ export class Batch { const batch = (current_batch = new Batch()); batches.add(current_batch); - if (!flushing_sync) { + if (!is_flushing_sync) { Batch.enqueue(() => { if (current_batch !== batch) { // a flushSync happened in the meantime @@ -448,8 +448,8 @@ export function flushSync(fn) { e.flush_sync_in_effect(); } - var prev_flushing_sync = flushing_sync; - flushing_sync = true; + var prev_flushing_sync = is_flushing_sync; + is_flushing_sync = true; try { var result; @@ -484,7 +484,7 @@ export function flushSync(fn) { batch.flush_effects(); } } finally { - flushing_sync = prev_flushing_sync; + is_flushing_sync = prev_flushing_sync; } } From 457b8495307f35a51d97bbcffffa178dcfc52b7c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 19:07:33 -0400 Subject: [PATCH 15/19] remove flush_effects method --- .../src/internal/client/reactivity/batch.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a08440d8c948..0bb2970e8b4f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -341,13 +341,13 @@ export class Batch { flush() { if (queued_root_effects.length > 0) { - this.flush_effects(); + flush_effects(); } else { this.#commit(); } if (current_batch !== this) { - // this can happen if a `flushSync` occurred during `this.flush_effects()`, + // this can happen if a `flushSync` occurred during `flush_effects()`, // which is permitted in legacy mode despite being a terrible idea return; } @@ -359,10 +359,6 @@ export class Batch { this.deactivate(); } - flush_effects() { - flush_effects(); - } - /** * Append and remove branches to/from the DOM */ @@ -454,11 +450,10 @@ export function flushSync(fn) { try { var result; - const batch = Batch.ensure(); + var batch = Batch.ensure(); if (fn) { - batch.flush_effects(); - + flush_effects(); result = fn(); } @@ -473,7 +468,7 @@ export function flushSync(fn) { // TODO this feels wrong if (queued_root_effects.length === 0) { - // this would be reset in `batch.flush_effects()` but since we are early returning here, + // this would be reset in `flush_effects()` but since we are early returning here, // we need to reset it here as well in case the first time there's 0 queued root effects last_scheduled_effect = null; @@ -481,7 +476,7 @@ export function flushSync(fn) { } } - batch.flush_effects(); + flush_effects(); } } finally { is_flushing_sync = prev_flushing_sync; From f1f34f7382c05eb232176a82a4cc441ce7cf5166 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 20:00:21 -0400 Subject: [PATCH 16/19] dedupe declaration --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 0bb2970e8b4f..24da6ef008e5 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -489,10 +489,11 @@ function flush_effects() { try { var flush_count = 0; - var batch = /** @type {Batch} */ (current_batch); set_is_updating_effect(true); while (queued_root_effects.length > 0) { + var batch = /** @type {Batch} */ (current_batch); + if (flush_count++ > 1000) { if (DEV) { var updates = new Map(); @@ -519,7 +520,6 @@ function flush_effects() { infinite_loop_guard(); } - batch = /** @type {Batch} */ (current_batch); batch.process(queued_root_effects); old_values.clear(); } From 10b2d64e0cafbcc3d5831e6151a3b904db9c3507 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 20:15:49 -0400 Subject: [PATCH 17/19] tweak --- packages/svelte/src/internal/client/reactivity/batch.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 24da6ef008e5..b2d9f54329c0 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -450,8 +450,6 @@ export function flushSync(fn) { try { var result; - var batch = Batch.ensure(); - if (fn) { flush_effects(); result = fn(); @@ -461,10 +459,7 @@ export function flushSync(fn) { flush_tasks(); if (queued_root_effects.length === 0) { - // TODO this might need adjustment - if (batch === current_batch) { - batch.flush(); - } + current_batch?.flush(); // TODO this feels wrong if (queued_root_effects.length === 0) { @@ -492,7 +487,7 @@ function flush_effects() { set_is_updating_effect(true); while (queued_root_effects.length > 0) { - var batch = /** @type {Batch} */ (current_batch); + var batch = Batch.ensure(); if (flush_count++ > 1000) { if (DEV) { From 6c2f0f92b034505ebd0893fcd3fa08a43ff7c508 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 20:16:28 -0400 Subject: [PATCH 18/19] tweak --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b2d9f54329c0..ddcf64d1a868 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -444,7 +444,7 @@ export function flushSync(fn) { e.flush_sync_in_effect(); } - var prev_flushing_sync = is_flushing_sync; + var was_flushing_sync = is_flushing_sync; is_flushing_sync = true; try { @@ -474,7 +474,7 @@ export function flushSync(fn) { flush_effects(); } } finally { - is_flushing_sync = prev_flushing_sync; + is_flushing_sync = was_flushing_sync; } } From 82e431fb771e098fcf1242a8d09f2e9ea00f42f8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 22 Jul 2025 20:33:53 -0400 Subject: [PATCH 19/19] =?UTF-8?q?update=20comment=20=E2=80=94=20it=20=5Fdo?= =?UTF-8?q?es=5F=20feel=20slightly=20wrong,=20but=20no=20wronger=20than=20?= =?UTF-8?q?the=20rest=20of=20this=20cursed=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/svelte/src/internal/client/reactivity/batch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ddcf64d1a868..ce413fa1e186 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -461,7 +461,7 @@ export function flushSync(fn) { if (queued_root_effects.length === 0) { current_batch?.flush(); - // TODO this feels wrong + // we need to check again, in case we just updated an `$effect.pending()` if (queued_root_effects.length === 0) { // this would be reset in `flush_effects()` but since we are early returning here, // we need to reset it here as well in case the first time there's 0 queued root effects