From a81f96549d6768586b6bdace0a3e5467cff114ce Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:44:43 +0200 Subject: [PATCH] fix: reject pending async deriveds on discard (#18308) The rejects all async deriveds of a batch as `OBSOLETE`, so they don't hang around and bail early without triggering the batch. If we don't do this, an async derived can trigger the already done batch, which schedules an effect that is never flushed. Because it is never flushed the branches it touched on its way up are never cleared, and so anything else in that subtree is now unreactive. --- .changeset/warm-cougars-behave.md | 5 +++ .../src/internal/client/reactivity/batch.js | 20 ++++++++-- .../async-fork-obsolete-ignore/_config.js | 39 +++++++++++++++++++ .../async-fork-obsolete-ignore/main.svelte | 23 +++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 .changeset/warm-cougars-behave.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte diff --git a/.changeset/warm-cougars-behave.md b/.changeset/warm-cougars-behave.md new file mode 100644 index 0000000000..15e32f7436 --- /dev/null +++ b/.changeset/warm-cougars-behave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reject pending async deriveds on discard diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3b82059788..7d14b80519 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -41,6 +41,7 @@ import { set_signal_status } from './status.js'; import { legacy_is_updating_store } from './store.js'; import { invariant } from '../../shared/dev.js'; import { log_effect_tree } from '../dev/debug.js'; +import { OBSOLETE } from './deriveds.js'; /** @type {Batch | null} */ let first_batch = null; @@ -511,6 +512,10 @@ export class Batch { if (d) deferred.promise.then(d.resolve).catch(d.reject); } + // Clear them or else those that are still pending might get rejected on discard (after merged-into batch is done). + // This can happen when batch Y merged into X and Y has a pending boundary and therefore still-pending async deriveds inside. + batch.async_deriveds.clear(); + // Mark is not guaranteed not touch these, so we transfer them this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); @@ -629,6 +634,10 @@ export class Batch { for (const fn of this.#discard_callbacks) fn(this); this.#discard_callbacks.clear(); + for (const deferred of this.async_deriveds.values()) { + deferred.reject(OBSOLETE); + } + this.#unlink(); this.#deferred?.resolve(); } @@ -677,12 +686,15 @@ export class Batch { } } - if (!batch.#started) continue; + var current = [...batch.current.keys()].filter( + (source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1]) + ); + + // If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail + if (!batch.#started || current.length === 0) continue; // Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds) - var others = [...batch.current.keys()].filter( - (s) => !(/** @type {[any, boolean]} */ (batch.current.get(s))[1]) && !this.current.has(s) - ); + var others = current.filter((source) => !this.current.has(source)); if (others.length === 0) { if (is_earlier) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js new file mode 100644 index 0000000000..e127fd03ec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [fork, real, resolve] = target.querySelectorAll('button'); + + fork.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 0 + + + + ` + ); + assert.deepEqual(logs, [0]); + + real.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 1 + + + + ` + ); + assert.deepEqual(logs, [0, 1]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte new file mode 100644 index 0000000000..85056e02b8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte @@ -0,0 +1,23 @@ + + +{await push(count)} + + +