From 2481374e9540a8b002b69e270e0dc944ea38f731 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 16 Jul 2026 11:33:00 +0200 Subject: [PATCH] entangle at the source-level, too --- .../src/internal/client/reactivity/batch.js | 46 ++++++++++--------- .../src/internal/client/reactivity/sources.js | 2 + .../src/internal/client/reactivity/types.d.ts | 16 +++---- .../async-later-sync-overlaps/_config.js | 5 +- .../async-source-only-overlap/_config.js | 30 ++++++++++++ .../async-source-only-overlap/main.svelte | 18 ++++++++ 6 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index f3711d38d3..bf2651227f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -400,9 +400,9 @@ export class Batch { * Keep this batch separate from a sealed predecessor, coalescing it with any * other work that is already waiting for that predecessor. * @param {Batch} owner - * @param {Reaction} reaction + * @param {Reaction | Value} signal */ - #wait(owner, reaction) { + #wait(owner, signal) { var waiter = owner.waiter; if (waiter !== null) { @@ -418,7 +418,7 @@ export class Batch { owner.waiter = this; var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() }); waiting.batches.add(owner); - waiting.reactions.set(reaction, owner); + if (Object.hasOwn(signal, 'deps')) waiting.reactions.set(signal, owner); } /** @@ -431,9 +431,7 @@ export class Batch { if (waiter === null || !(waiter = waiter.resolved()).linked) return; - var waiting = /** @type {{ batches: Set, reactions: Map }} */ ( - waiter.waiting - ); + var waiting = /** @type {NonNullable} */ (waiter.waiting); waiting.batches.delete(this); for (const [reaction, owner] of waiting.reactions) { @@ -474,42 +472,48 @@ export class Batch { } /** - * Take ownership of a reaction. Deriveds and user/block/async effects can - * only ever belong to one live batch — if the reaction is already claimed + * Take ownership of a signal. Sources, deriveds and user/block/async effects can + * only ever belong to one live batch. If the signal is already claimed * by another live batch, the two reactivity graphs overlap, which means the * batches describe the same 'world', and they are merged into one. After too * many restarts, new work waits behind the current batch instead. * Template-level (render/managed) effects are exempt: they are leaves, so * independent batches can share them without their graphs being entangled. - * Forks are also exempt — they are speculative and live in their own world - * @param {Reaction} reaction - * @returns {boolean} Whether the reaction must wait for a sealed batch + * Forks are also exempt - they are speculative and live in their own world. + * @param {Reaction | Value} signal + * @returns {boolean} Whether the signal must wait for a sealed batch */ - claim(reaction) { + claim(signal) { // already claimed by this batch — nothing below could change anything - if (reaction.batch === this) return false; + if (signal.batch === this) return false; if (!async_mode_flag || this.is_fork) return false; - if ((reaction.f & (DERIVED | ASYNC | BLOCK_EFFECT | USER_EFFECT)) === 0) return false; + var is_source = !Object.hasOwn(signal, 'deps'); + + if (!is_source && (signal.f & (DERIVED | ASYNC | BLOCK_EFFECT | USER_EFFECT)) === 0) { + return false; + } // template expression deriveds are leaves — they don't entangle - if ((reaction.f & TEMPLATE_EXPRESSION) !== 0) return false; + if ((signal.f & TEMPLATE_EXPRESSION) !== 0) return false; - var owner = reaction.batch && reaction.batch.resolved(); + var owner = signal.batch && signal.batch.resolved(); if (this.is_eager) { // eager version bumps don't entangle — but an effect that belongs to // another live batch's world, and which we are about to run in ours, // must afterwards be re-established in the owner's world if ( - (reaction.f & DERIVED) === 0 && + !is_source && + (signal.f & DERIVED) === 0 && owner !== null && owner !== this && owner.linked && !owner.is_fork ) { - (owner.#dirty_effects ??= new Set()).add(/** @type {Effect} */ (reaction)); + // TODO + (owner.#dirty_effects ??= new Set()).add(/** @type {Effect} */ (signal)); } return false; @@ -518,12 +522,12 @@ export class Batch { if (owner !== null && owner !== this && owner.linked && !owner.is_fork) { if (owner.waiting !== null) { this.#merge(owner); - reaction.batch = this; + signal.batch = this; return false; } if (owner.restarts >= MAX_ENTANGLED_RESTARTS) { - this.#wait(owner, reaction); + this.#wait(owner, signal); return true; } @@ -532,7 +536,7 @@ export class Batch { this.restarts = Math.max(this.restarts, restarts); } - reaction.batch = this; + signal.batch = this; return false; } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index f733eab3f7..d678ce3b65 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -79,6 +79,7 @@ export function source(v, stack) { f: 0, // TODO ideally we could skip this altogether, but it causes type errors v, reactions: null, + batch: null, equals, rv: 0, wv: 0 @@ -233,6 +234,7 @@ export function internal_set(source, value, updated_during_traversal = null) { source.wv = increment_write_version(); + batch.claim(source); // For debugging, in case you want to know which reactions are being scheduled: // log_reactions(source); mark_reactions(source, DIRTY, updated_during_traversal); diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 0f3b462f34..13e05433f2 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -14,6 +14,14 @@ export interface Signal { f: number; /** Write version */ wv: number; + /** + * The batch that most recently claimed this signal. + * Only set for sources, non-render-only deriveds and user/block/async effects. + * If two batches claim the same reaction, their reactivity graphs overlap and are merged, + * unless the newer batch is waiting behind a sealed predecessor. + * A claim expires when its batch is committed or discarded (`!batch.linked`) + */ + batch: null | Batch; } export interface Value extends Signal { @@ -51,14 +59,6 @@ export interface Reaction extends Signal { deps: null | Value[]; /** An AbortController that aborts when the signal is destroyed */ ac: null | AbortController; - /** - * The batch that most recently claimed this reaction by marking it dirty. - * Only set for deriveds and user/block/async effects — if two batches claim - * the same reaction, their reactivity graphs overlap and they are merged, - * unless the newer batch is waiting behind a sealed predecessor. - * A claim expires when its batch is committed or discarded (`!batch.linked`) - */ - batch: null | Batch; } export interface Derived extends Value, Reaction { diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js index 7ea372165a..a65c8a6fba 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js @@ -13,14 +13,11 @@ export default test({ ' 0' ); - // `b` is only read by template leaves, so the second update doesn't - // entangle with the pending batch — it commits immediately, while the - // pending batch's `a` is still held back b.click(); await tick(); assert.htmlEqual( target.innerHTML, - ' 0' + ' 0' ); resolve.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js new file mode 100644 index 0000000000..a65c8a6fba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b, b, resolve] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte new file mode 100644 index 0000000000..1e107c0374 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte @@ -0,0 +1,18 @@ + + + + + +{await push(a)}