diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 57726caf94..4927a52e18 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -52,14 +52,6 @@ export const EFFECT_OFFSCREEN = 1 << 25; * should not be used for any other purpose! */ export const WAS_MARKED = 1 << 16; -/** - * A derived created for a template expression. These are leaves of the reactivity - * graph (there could theoretically be one per read value), so they don't entangle - * batches — instead, while multiple batches exist, they are evaluated per-world - * without caching - */ -export const TEMPLATE_EXPRESSION = 1 << 26; - // Flags used for async export const REACTION_IS_UPDATING = 1 << 21; export const ASYNC = 1 << 22; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 348ae4d856..e26ae6cd29 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,5 +1,5 @@ /** @import { Blocker, Effect, Source, Value } from '#client' */ -import { DESTROYED, STALE_REACTION, TEMPLATE_EXPRESSION } from '#client/constants'; +import { DESTROYED, STALE_REACTION } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, @@ -8,7 +8,6 @@ import { set_component_context, set_dev_stack } from '../context.js'; -import { Boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -39,13 +38,7 @@ export function flatten(blockers, sync, async, fn) { // Filter out already-settled blockers - no need to wait for them var pending = blockers.filter((b) => !b.settled); - var deriveds = sync.map((expression) => { - var signal = d(expression); - // template expressions are leaves of the reactivity graph — they don't - // entangle batches, and are evaluated per-world while batches overlap - signal.f |= TEMPLATE_EXPRESSION; - return signal; - }); + var deriveds = sync.map((expression) => d(expression)); if (DEV) { deriveds.forEach((d, i) => { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 33ab2ac3e8..310ff69782 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -18,8 +18,7 @@ import { MANAGED_EFFECT, REACTION_RAN, DESTROYING, - USER_EFFECT, - TEMPLATE_EXPRESSION + USER_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property, has_own_property, includes } from '../../shared/utils.js'; @@ -492,9 +491,6 @@ export class Batch { return false; } - // template expression deriveds are leaves — they don't entangle - if ((signal.f & TEMPLATE_EXPRESSION) !== 0) return false; - var owner = signal.batch && signal.batch.resolved(); if (this.is_eager) { @@ -1169,6 +1165,14 @@ export class Batch { var owner = effect.batch && effect.batch.resolved(); if (owner !== this) continue; + if (!depends_on_fork_values(effect, fork)) { + // the fork's view of every dependency matches the committed + // state — the latest real run is valid for the fork's world + // too, so it doesn't need to re-run speculatively + fork.fork_effects.set(effect, effect_versions.get(effect) ?? 0); + continue; + } + fork.schedule(effect); var effects = (forks ??= new Map()).get(fork); if (effects === undefined) forks.set(fork, [effect]); @@ -1666,6 +1670,31 @@ export function eager(fn) { return value; } +/** + * Whether `reaction` depends — directly or through deriveds — on a signal + * whose value in `fork`'s world differs from the real one (i.e. one of the + * fork's own speculative writes) + * @param {Reaction} reaction + * @param {Batch} fork + * @returns {boolean} + */ +function depends_on_fork_values(reaction, fork) { + var deps = reaction.deps; + if (deps === null) return false; + + for (var i = 0; i < deps.length; i++) { + var dep = deps[i]; + + if (fork.current.has(dep)) return true; + + if ((dep.f & DERIVED) !== 0 && depends_on_fork_values(/** @type {Derived} */ (dep), fork)) { + return true; + } + } + + return false; +} + /** * When a fork is committed, deriveds affected by its writes must recompute with * the now-committed values, and affected effects must be checked. Async and @@ -1723,7 +1752,20 @@ function mark_committed_reactions(value, batch, marked, status) { owner.linked && !owner.is_fork ) { - batch.claim(effect); + if (batch.current.has(value) && batch.current.get(value) === value.v) { + // the committed value matches the real (pending) one the + // in-flight run used — entangling the two batches is enough + batch.claim(effect); + } else { + // the newer in-flight run of this effect was computed with + // pre-commit values — entangle with its batch (via `schedule` + // -> `claim`) and re-run it with the committed values + if ((reaction.f & DIRTY) === 0) { + set_signal_status(reaction, status); + } + + batch.schedule(effect); + } } } else if ((flags & DIRTY) === 0) { set_signal_status(reaction, status); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index e94564133b..fc5d3d80b6 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -23,8 +23,7 @@ import { ERROR_VALUE, WAS_MARKED, MANAGED_EFFECT, - REACTION_RAN, - TEMPLATE_EXPRESSION + REACTION_RAN } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { @@ -173,25 +172,24 @@ export function is_dirty(reaction) { for (var i = 0; i < length; i++) { var dependency = dependencies[i]; - if (active_batch !== null && active_batch.values !== null && (dependency.f & DERIVED) !== 0) { - var is_template = (dependency.f & TEMPLATE_EXPRESSION) !== 0; - - if (is_template || claimed_by_other(/** @type {Derived} */ (dependency)) !== null) { - // deriveds that are evaluated per-world (in `get`) while multiple - // batches exist: if dirty, treat them as changed — the dirtiness - // may originate from a write in our own world, and re-running the - // reaction is harmless otherwise - if ((dependency.f & (DIRTY | MAYBE_DIRTY)) !== 0) { - return true; - } - - if (!is_template) { - // a clean derived belonging to another live batch's world is - // unchanged in ours — the owner's recompute (which may have - // bumped its write version) doesn't affect our world - continue; - } + if ( + active_batch !== null && + active_batch.values !== null && + (dependency.f & DERIVED) !== 0 && + claimed_by_other(/** @type {Derived} */ (dependency)) !== null + ) { + // deriveds belonging to another live batch's world are evaluated + // per-world (in `get`): if dirty, treat them as changed — the + // dirtiness may originate from a write in our own world, and + // re-running the reaction is harmless otherwise + if ((dependency.f & (DIRTY | MAYBE_DIRTY)) !== 0) { + return true; } + + // a clean derived belonging to another live batch's world is + // unchanged in ours — the owner's recompute (which may have + // bumped its write version) doesn't affect our world + continue; } if (is_dirty(/** @type {Derived} */ (dependency))) { @@ -682,22 +680,18 @@ export function get(signal) { return value; } - // While multiple batches exist, some deriveds must be evaluated in the - // context of the current world, without touching their cached state: - // - deriveds belonging to another live batch's world must not be - // recomputed or have their status reset (the owning batch relies on - // both) — their value in this world follows from the active overlay - // - dirty template expression deriveds are leaves that can be shared - // between non-overlapping batches, so each world evaluates its own value + // While multiple batches exist, deriveds belonging to another live + // batch's world must be evaluated in the context of the current world, + // without touching their cached state — they must not be recomputed or + // have their status reset (the owning batch relies on both), and their + // value in this world follows from the active overlay /** @type {Batch | null} */ var owner = null; if ( active_batch !== null && active_batch.values !== null && - ((derived.f & TEMPLATE_EXPRESSION) !== 0 - ? (derived.f & (DIRTY | MAYBE_DIRTY)) !== 0 - : (owner = claimed_by_other(derived)) !== 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 diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js new file mode 100644 index 0000000000..b7ace65f23 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [forkButton, y, shift, commit] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `
0
${buttons}`); + + // speculative world: x becomes 1, async expression runs with x + y = 1 + forkButton.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `0
${buttons}`); + + // real world: y becomes 1, async expression runs with x + y = 1 + // (computed with the pre-fork x = 0) — this run supersedes the + // fork's validation of the effect + y.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `0
${buttons}`); + + // commit the fork while the real batch is still pending — x = 1 is + // written, and the async expression must eventually re-run with the + // committed value, because its in-flight run used x = 0 + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `0
${buttons}`); + + // resolve all in-flight runs (superseded ones are no-ops) + for (let i = 0; i < 4; i += 1) { + shift.click(); + await tick(); + } + + // x = 1, y = 1 — anything else means the effect resolved with a value + // computed from stale inputs and was never re-run + assert.htmlEqual(target.innerHTML, `2
${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte new file mode 100644 index 0000000000..3dba3d1004 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte @@ -0,0 +1,20 @@ + + +{await delay(x + y)}
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js new file mode 100644 index 0000000000..31474640ef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [forkButton, real, shift, discard] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['b 0']); + logs.length = 0; + + // speculative world: fork writes b and c, runs the async expression + forkButton.click(); + await tick(); + assert.deepEqual(logs, ['b 1']); + + // real world: b++ re-runs the async expression for real + real.click(); + await tick(); + assert.deepEqual(logs, ['b 1', 'b 1']); + + // resolve the fork's in-flight run — the fork is still speculative, + // nothing should be committed or re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['b 1', 'b 1']); + + // resolve the real run — the real batch commits b = 1. The fork's world + // value of `b` is also 1 (its own write, now also committed), so the + // fork's async expression sees unchanged inputs and should not re-run + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '1 0
' + ); + assert.deepEqual(logs, ['b 1', 'b 1']); + + discard.click(); + await tick(); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte new file mode 100644 index 0000000000..f2a6df36d7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte @@ -0,0 +1,20 @@ + + +{await delay(console.log(`b ${b}`), b)} {c}
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js index 31b42b1a9d..180a190dae 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-2/_config.js @@ -23,10 +23,6 @@ export default test({ await tick(); assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1'); - pop.click(); - await tick(); - assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1'); - pop.click(); await tick(); assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); @@ -39,11 +35,6 @@ export default test({ await tick(); assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); - // resolve the fork revalidation triggered by the real batch settling - pop.click(); - await tick(); - assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); - commit.click(); await tick(); assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js new file mode 100644 index 0000000000..e8ef8d5eba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, b, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `0
0
${buttons}`); + + // batch A writes `a` and pends on its async expression + a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `0
0
${buttons}`); + + // batch B writes `b` — it shares the `{add(a, b)}` template expression + // with batch A, so the two describe the same world and are merged + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `0
0
${buttons}`); + + // the async expression settles — the merged batch commits both writes + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `1
2
${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte new file mode 100644 index 0000000000..54de8bc956 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte @@ -0,0 +1,22 @@ + + +{await delay(a)}
+{add(a, b)}
+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js new file mode 100644 index 0000000000..7206812832 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [z, w, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `true
0
${buttons}`); + assert.deepEqual(logs, ['eval true']); + + // with no other batch pending, the equality cut-off works: `c` + // recomputes to the same value, the template expression is not + // re-evaluated + z.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // write w — the batch is pending on its async expression + w.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // z++ recomputes `c` to the same value again — the template expression + // should still not be re-evaluated, even though another batch is pending + z.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // the pending batch settles — nothing `c` depends on changed + shift.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + assert.htmlEqual(target.innerHTML, `true
1
${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte new file mode 100644 index 0000000000..9c54dd79d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte @@ -0,0 +1,25 @@ + + +{log(c)}
+{await delay(w)}
+ + +