diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index e468cdc648..0f9f145e0b 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -50,6 +50,7 @@ import { active_batch, Batch, claimed_by_other, + collected_effects, current_batch, flushSync, previous_batch, @@ -708,7 +709,6 @@ export function get(signal) { // have their status reset (the owning batch relies on both), and their // value in this world follows from the active overlay /** @type {Batch | null} */ - // eslint-disable-next-line no-useless-assignment var owner = null; if ( @@ -716,13 +716,20 @@ export function get(signal) { active_batch.values !== null && (owner = claimed_by_other(derived)) !== null ) { - // the world-local value is memoized in the active overlay (and invalidated - // there when dependencies change). Reads are registered with the owner - // batch — when it commits, the reader re-runs with the real values - if (!active_batch.values.has(derived)) { + if (is_unseen_read(derived, owner) && 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) + owner = null; + } else if (!active_batch.values.has(derived)) { + // the world-local value is memoized in the active overlay (and invalidated + // there when dependencies change). Reads are registered with the owner + // batch — when it commits, the reader re-runs with the real values active_batch.values.set(derived, [execute_derived(derived), owner]); } - } else { + } + + if (owner === null) { // connect disconnected deriveds if we are reading them inside an effect, // or inside another derived that is already connected var should_connect = @@ -764,8 +771,11 @@ export function get(signal) { // reads outside a reaction during a flush happen in one-shot init // code (e.g. a component initialising inside a newly-created // branch). They have no dependency history and no re-run - // mechanism, so they see the latest value + // mechanism — entangle the batches if possible, so that both + // worlds commit together, and read the latest value if ((signal.f & DERIVED) === 0) { + entangle(signal); + if ((signal.f & ERROR_VALUE) !== 0) { throw signal.v; } @@ -775,15 +785,10 @@ export function get(signal) { } else if (!untracking) { override_owner = override_owner.resolved(); - var readers = (override_owner.stale_readers ??= new Map()); - var seen = readers.get(active_reaction); - - if (seen === undefined) { - readers.set(active_reaction, (seen = new Map())); - } + var seen = override_owner.stale_readers?.get(active_reaction); // a reader keeps seeing the value it first observed while the owner is pending - if (seen.has(signal)) { + if (seen !== undefined && seen.has(signal)) { return seen.get(signal); } @@ -795,15 +800,31 @@ export function get(signal) { (active_reaction.deps === null || !includes.call(active_reaction.deps, signal)) ) { // the reaction never depended on this signal before the owner's write — - // the pre-write world never contained this combination of values, - // so read the latest value instead + // the pre-write world never contained this combination of values. + // Entangle the batches if possible, so that both worlds commit + // together and this value is simply the batch's own write... + var entangled = entangle(signal); + if ((signal.f & ERROR_VALUE) !== 0) { throw signal.v; } + if (entangled) { + return signal.v; + } + + // ...otherwise, read the latest value — the owner batch will + // re-run us when it commits, if the value we saw turns out + // to differ from the committed one override_value = signal.v; } + var readers = (override_owner.stale_readers ??= new Map()); + + if (seen === undefined) { + readers.set(active_reaction, (seen = new Map())); + } + seen.set(signal, override_value); return override_value; @@ -821,6 +842,54 @@ export function get(signal) { return signal.v; } +/** + * Whether the current read of `signal` — which is owned by another live batch — + * has no history: the reader neither depended on the signal in a previous run, + * nor observed a value for it while the owner batch was pending. (Reads outside + * a reaction never have history.) + * @param {Value} signal + * @param {Batch} owner + * @returns {boolean} + */ +function is_unseen_read(signal, owner) { + if (active_reaction === null) return true; + if (untracking || (active_reaction.f & REACTION_IS_UPDATING) === 0) return false; + + if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) { + return false; + } + + var seen = owner.resolved().stale_readers?.get(active_reaction); + return seen === undefined || !seen.has(signal); +} + +/** + * 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. + * @param {Value} signal + * @returns {boolean} + */ +function entangle(signal) { + if (collected_effects === null) { + // 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 + // are now waiting behind it instead) — the recomputed overlay tells us + // whether the signal still belongs to another world + var override = batch.values?.get(signal); + return override === undefined || override[1] === null; +} + /** * (Re)connect a disconnected derived, so that it is notified * of changes in `mark_reactions` 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 081bb88f8f..951744862a 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 @@ -10,22 +10,23 @@ export default test({ y.click(); await tick(); - // the new branch's reactive reads of `x` are new dependencies on a value the - // pending batch has written, so they see the latest value ('universe') + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes assert.htmlEqual( target.innerHTML, ` - universe ` ); resolve.click(); await tick(); - // the init-time console.log runs outside a reaction and sees the latest value; - // the second child's $effect already saw the committed value, so it does not re-run + // both branches commit together, fully consistent. The init-time + // console.logs ran eagerly (with the latest value), the $effects + // were deferred until the commit 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 e21eb02417..7c9f577abd 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 @@ -10,8 +10,9 @@ export default test({ y.click(); await tick(); - // the new branch's reads of `x` are new dependencies on a value the - // pending batch has written, so they see the latest value ('universe') + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes assert.htmlEqual( target.innerHTML, ` @@ -19,17 +20,12 @@ export default test({
- universe - "universe" - universe - universe - universe - "universe" ` ); resolve.click(); await tick(); + // both branches commit together, fully consistent assert.htmlEqual( target.innerHTML, ` 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 5ad32b24c5..f0ef3785a5 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 @@ -22,8 +22,10 @@ export default test({ resolve.click(); await tick(); - // the new branch's async expression read `x` as a new dependency on a value - // the pending batch had written, so it saw the latest value ('universe') + // the new branch's async expression read `x`, which the pending batch had + // written, as a new dependency — the two batches entangled, so even though + // the new branch's own async work has completed, it is held back until the + // first branch's async work completes too assert.htmlEqual( target.innerHTML, ` @@ -31,19 +33,12 @@ export default test({
- universe - "universe" - universe - universe - universe - "universe" ` ); resolve.click(); await tick(); - resolve.click(); - await tick(); + // both branches commit together, fully consistent assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js index 5b0d3e996c..9e3e4369a4 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js @@ -11,23 +11,23 @@ export default test({ y.click(); await tick(); - // the new branch's reads of `x` are new dependencies on a value the - // pending batch has written, so they see the latest value ('universe') — - // including the init-time console.log, which runs outside a reaction - assert.deepEqual(logs, ['universe', 'universe', '$effect: universe']); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes. Its $effect is deferred, but the + // init-time console.log necessarily runs eagerly (with the latest value) + assert.deepEqual(logs, ['universe', 'universe']); assert.htmlEqual( target.innerHTML, ` - universe ` ); resolve.click(); await tick(); - // the second child's $effect already saw the committed value, so it does not re-run + // both branches commit together, fully consistent 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-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/_config.js new file mode 100644 index 0000000000..28b8229839 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/_config.js @@ -0,0 +1,51 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

WORLD

+ ` + ); + + y.click(); + await tick(); + // the new branch reads `upper` — a derived owned by the pending batch — as + // a new dependency. The two batches entangle, so the new branch is held + // back until the async work completes (rather than rendering with the + // derived's pre-write value, 'WORLD') + assert.htmlEqual( + target.innerHTML, + ` + + + +

WORLD

+ ` + ); + + resolve.click(); + await tick(); + // both branches commit together, fully consistent + assert.htmlEqual( + target.innerHTML, + ` + + + +

UNIVERSE

+ universe +

UNIVERSE

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte new file mode 100644 index 0000000000..aa95730425 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte @@ -0,0 +1,29 @@ + + + + + + + + +

{upper}

+ +{#if x.x === 'universe'} + {await delay(x.x)} +{/if} + +{#if y > 0} +

{upper}

+{/if} 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 5b0d3e996c..9e3e4369a4 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,23 +11,23 @@ export default test({ y.click(); await tick(); - // the new branch's reads of `x` are new dependencies on a value the - // pending batch has written, so they see the latest value ('universe') — - // including the init-time console.log, which runs outside a reaction - assert.deepEqual(logs, ['universe', 'universe', '$effect: universe']); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes. Its $effect is deferred, but the + // init-time console.log necessarily runs eagerly (with the latest value) + assert.deepEqual(logs, ['universe', 'universe']); assert.htmlEqual( target.innerHTML, ` - universe ` ); resolve.click(); await tick(); - // the second child's $effect already saw the committed value, so it does not re-run + // both branches commit together, fully consistent assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); assert.htmlEqual( target.innerHTML,