From 17d3d817a7e01bcaed34ce870b2c16013cbf6a05 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 16 Jul 2026 13:25:38 +0200 Subject: [PATCH] prevent infinite loop in eager batches --- .../src/internal/client/reactivity/batch.js | 27 +++++++++++++++++-- .../async-eager-effect-loop/_config.js | 16 +++++++++++ .../async-eager-effect-loop/main.svelte | 14 ++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index bf2651227f..38604427f1 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -235,6 +235,14 @@ export class Batch { */ #maybe_dirty_effects = null; + /** + * Effects run by an eager batch that must be re-established in this batch's world. + * Unlike deferred effects, these are scheduled only once. + * Lazily initialised for perf reasons + * @type {Set | null} + */ + #reestablish_effects = null; + /** * A map of branches that still exist, but will be destroyed when this batch * is committed — we skip over these during `process`. @@ -512,8 +520,8 @@ export class Batch { owner.linked && !owner.is_fork ) { - // TODO - (owner.#dirty_effects ??= new Set()).add(/** @type {Effect} */ (signal)); + // Not putting this into #dirty_effects to prevent infinite loops + (owner.#reestablish_effects ??= new Set()).add(/** @type {Effect} */ (signal)); } return false; @@ -641,6 +649,9 @@ export class Batch { other.#dirty_effects = null; other.#maybe_dirty_effects = null; + this.#reestablish_effects = transfer_set(this.#reestablish_effects, other.#reestablish_effects); + other.#reestablish_effects = null; + if (other.#skipped_branches !== null) { this.#skipped_branches ??= new Map(); @@ -782,6 +793,18 @@ export class Batch { } } + if (this.#reestablish_effects !== null) { + var reestablish_effects = this.#reestablish_effects; + this.#reestablish_effects = null; + + for (const e of reestablish_effects) { + this.#dirty_effects?.delete(e); + this.#maybe_dirty_effects?.delete(e); + set_signal_status(e, DIRTY); + this.schedule(e); + } + } + // We always reschedule previously-deferred effects, not just when // #is_deferred() is true, because traversing the tree could make // an if block that contains the last blocking pending effect falsy, diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js new file mode 100644 index 0000000000..4dee4cc511 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, '

0

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

2

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte new file mode 100644 index 0000000000..8ba1897206 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte @@ -0,0 +1,14 @@ + + + + +

{await delay(count, $state.eager(count))}