From 597609c53c409546a5c1a727e944725ec059df80 Mon Sep 17 00:00:00 2001 From: Kamil Jakubus Date: Wed, 19 Aug 2026 20:34:15 +0200 Subject: [PATCH] fix: merge async batches on new dependencies --- .../src/internal/client/reactivity/batch.js | 40 +++++++++++++++++++ .../svelte/src/internal/client/runtime.js | 12 ++++++ .../async-state-new-branch-1/_config.js | 12 +----- .../async-state-new-branch-2/_config.js | 8 +--- .../async-state-new-branch-3/_config.js | 8 +--- .../samples/async-state-new-branch/_config.js | 11 +---- 6 files changed, 58 insertions(+), 33 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 8251f79298..0ea0b8063d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -171,6 +171,14 @@ export class Batch { */ #new_effects = []; + /** + * Values that were first read by a reaction while this batch was time travelling + * over an earlier batch that changed them. These reads connect the batches even + * though this batch did not write to the values itself. + * @type {Set} + */ + #new_dependencies = new Set(); + /** * Deferred effects (which run after async work has completed) that are DIRTY * @type {Set} @@ -482,6 +490,12 @@ export class Batch { while (batch !== null) { if (!batch.is_fork) { + for (const value of this.#new_dependencies) { + if (batch.current.has(value)) { + return batch; + } + } + // if the batches are connected, break for (const [value, [, is_derived]] of this.current) { if (batch.current.has(value) && !is_derived) { @@ -500,6 +514,10 @@ export class Batch { * @param {Batch} batch */ #merge(batch) { + for (const value of batch.#new_dependencies) { + this.#new_dependencies.add(value); + } + for (const [source, value] of batch.current) { if (!this.previous.has(source) && batch.previous.has(source)) { this.previous.set(source, batch.previous.get(source)); @@ -598,6 +616,28 @@ export class Batch { } } + /** + * If a reaction discovers a dependency that was changed by an earlier pending + * batch, connect the two batches and expose the current value while traversing. + * The current batch will be merged into the earlier one before it can commit. + * @param {Value} value + */ + capture_dependency(value) { + if (this.is_fork || this.current.has(value)) return; + + var batch = this.#prev; + + while (batch !== null) { + if (!batch.is_fork && batch.current.has(value)) { + this.#new_dependencies.add(value); + batch_values?.set(value, /** @type {[any, boolean]} */ (batch.current.get(value))[0]); + return; + } + + batch = batch.#prev; + } + } + activate() { current_batch = this; } diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 4458595d35..abaff08f7f 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -540,6 +540,14 @@ export function settled() { export function get(signal) { var flags = signal.f; var is_derived = (flags & DERIVED) !== 0; + var is_new_dependency = + batch_values !== null && + !untracking && + ((active_reaction !== null && + (active_reaction.deps === null || !includes.call(active_reaction.deps, signal))) || + (active_reaction === null && + active_effect !== null && + (active_effect.f & REACTION_RAN) === 0)); captured_signals?.add(signal); @@ -699,6 +707,10 @@ export function get(signal) { } if (batch_values?.has(signal)) { + if (is_new_dependency) { + (current_batch ?? previous_batch)?.capture_dependency(signal); + } + return batch_values.get(signal); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js index dee8af2446..28ce3c9d4f 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js @@ -16,20 +16,12 @@ export default test({ - world - ` // if this does not show world - that would also be ok + ` ); resolve.click(); await tick(); - assert.deepEqual(logs, [ - 'universe', - 'world', - '$effect: world', - '$effect: universe', - '$effect: universe' - ]); - // assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js index d99f0df731..00b38262e8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js @@ -17,13 +17,7 @@ export default test({
- world - "world" - world - world - world - "world" - ` // if this does not show world "world" world world world "world" - then this would also be ok + ` ); resolve.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js index eb4485e8a6..aa9e274887 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js @@ -29,13 +29,7 @@ export default test({
- world - "world" - world - world - world - "world" - ` // if this does not show world "world" world world world "world" - then this would also be ok + ` ); resolve.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js index f4b6cc777b..601fbdeabe 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js @@ -11,26 +11,19 @@ export default test({ y.click(); await tick(); - assert.deepEqual(logs, ['universe', 'world', '$effect: world']); + assert.deepEqual(logs, ['universe', 'universe']); assert.htmlEqual( target.innerHTML, ` - world ` ); resolve.click(); await tick(); - assert.deepEqual(logs, [ - 'universe', - 'world', - '$effect: world', - '$effect: universe', - '$effect: universe' - ]); + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); assert.htmlEqual( target.innerHTML, `