From b5f74b0843bf598de9e92e66ec6e7ef34304037f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 27 Aug 2026 13:56:40 +0200 Subject: [PATCH] fix --- .../src/internal/client/reactivity/batch.js | 14 +++++ .../svelte/src/internal/client/runtime.js | 38 +++++++---- .../_config.js | 63 +++++++++++++++++++ .../main.svelte | 35 +++++++++++ 4 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2c7cc95ce3..a76509358c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -302,6 +302,15 @@ export class Batch { */ is_eager = false; + /** + * `true` once this batch has committed (some of) its UI — from that point on + * it can no longer entangle with other pending batches on new reads, because + * what is on screen was rendered with this batch's own world (a batch can + * stay live after committing, e.g. while a boundary shows its pending + * snippet until the async work inside it settles) + */ + committed = false; + /** * If this batch was merged into another one (because their reactivity graphs * turned out to overlap), this points to the batch it was merged into. Stale @@ -732,6 +741,7 @@ export class Batch { } this.restarts = Math.max(this.restarts, other.restarts); + this.committed ||= other.committed; // `other`'s settled() promise resolves when this batch settles if (other.#deferred !== null) { @@ -922,6 +932,10 @@ export class Batch { this.#dirty_effects = null; this.#maybe_dirty_effects = null; + // this batch's UI is about to hit the DOM — new reads can no longer + // entangle it with other pending batches + this.committed = true; + // append/remove branches if (this.#commit_callbacks !== null) { for (const fn of this.#commit_callbacks) fn(this); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 0f9f145e0b..fdea636ef0 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -572,6 +572,14 @@ export function get(signal) { var flags = signal.f; var is_derived = (flags & DERIVED) !== 0; + /** + * Whether a read outside the init/update cycle (i.e. after an `await`) added + * `signal` to the reaction's deps for the first time. During the init/update + * cycle this stays `false` — first-time reads are detected by checking + * `deps` instead (new deps accumulate in `new_deps` in that case) + */ + var first_read = false; + captured_signals?.add(signal); // Register the dependency on the current reaction signal. @@ -609,6 +617,7 @@ export function get(signal) { active_reaction.deps ??= []; if (!includes.call(active_reaction.deps, signal)) { active_reaction.deps.push(signal); + first_read = true; } var reactions = signal.reactions; @@ -716,7 +725,7 @@ export function get(signal) { active_batch.values !== null && (owner = claimed_by_other(derived)) !== null ) { - if (is_unseen_read(derived, owner) && entangle(derived)) { + if (is_unseen_read(derived, owner, first_read) && entangle(derived)) { // a read with no history can entangle the two batches instead, so // that they commit together — the derived is now part of this // batch's world and behaves normally (below) @@ -795,9 +804,10 @@ export function get(signal) { var override_value = override[0]; if ( - (active_reaction.f & REACTION_IS_UPDATING) !== 0 && (signal.f & DERIVED) === 0 && - (active_reaction.deps === null || !includes.call(active_reaction.deps, signal)) + ((active_reaction.f & REACTION_IS_UPDATING) !== 0 + ? active_reaction.deps === null || !includes.call(active_reaction.deps, signal) + : first_read) ) { // the reaction never depended on this signal before the owner's write — // the pre-write world never contained this combination of values. @@ -849,13 +859,18 @@ export function get(signal) { * a reaction never have history.) * @param {Value} signal * @param {Batch} owner + * @param {boolean} first_read whether a post-`await` read just added `signal` to the reaction's deps * @returns {boolean} */ -function is_unseen_read(signal, owner) { +function is_unseen_read(signal, owner, first_read) { if (active_reaction === null) return true; - if (untracking || (active_reaction.f & REACTION_IS_UPDATING) === 0) return false; + if (untracking) return false; - if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) { + if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { + if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) { + return false; + } + } else if (!first_read) { return false; } @@ -866,21 +881,22 @@ function is_unseen_read(signal, owner) { /** * Attempt to entangle the active batch with the batch that owns `signal`, * merging their worlds so that both commit together. This is only possible - * while the batch is traversing the effect tree — before any of its UI has - * been committed. Returns true if the signal now belongs to the active - * batch's own world. + * while none of the batch's UI has been committed — during effect tree + * traversal, or in an async continuation of a still-pending batch. Returns + * true if the signal now belongs to the active batch's own world. * @param {Value} signal * @returns {boolean} */ function entangle(signal) { - if (collected_effects === null) { + var batch = /** @type {Batch} */ (active_batch).resolved(); + + if (collected_effects === null && batch.committed) { // too late — the batch's UI is (at least partially) committed already. // Readers fall back to observing the latest value, and are re-run when // the owner commits (if the value they saw turns out to be stale) return false; } - var batch = /** @type {Batch} */ (active_batch).resolved(); batch.claim(signal); // claiming may not have merged the batches (e.g. the owner is sealed, and we diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js new file mode 100644 index 0000000000..f0d530cf10 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js @@ -0,0 +1,63 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [a, t, shift_a, shift_t] = target.querySelectorAll('button'); + + shift_a.click(); + shift_t.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 0

+

late read: -1

+ ` + ); + + // batch A: writes `a`, stays pending (its promise is unresolved) + a.click(); + await tick(); + + // batch B: writes `t`; resolve its promise so the continuation + // reads `a` for the first time while A is still pending. This + // entangles B with A — both worlds are held back and commit together + t.click(); + await tick(); + shift_t.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 0

+

late read: -1

+ ` + ); + + // commit the merged batch + shift_a.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 1

+

late read: 1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte new file mode 100644 index 0000000000..428dd4c774 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte @@ -0,0 +1,35 @@ + + + + + + + + +

async a: {await push('a', a)}

+ + +

late read: {(await push('t', t), t > 0 ? a : -1)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +