diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 141b914537..7bb410da39 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -1,5 +1,11 @@ /** @import { Effect, TemplateNode } from '#client' */ -import { Batch, current_batch } from '../../reactivity/batch.js'; +import { + Batch, + current_batch, + depends_on_fork_values, + speculative_branches, + speculative_selectors +} from '../../reactivity/batch.js'; import { branch, destroy_effect, @@ -7,7 +13,8 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HMR_ANCHOR } from '../../constants.js'; +import { EFFECT_PRESERVED, HMR_ANCHOR } from '../../constants.js'; +import { active_effect } from '../../runtime.js'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; import { DEV } from 'esm-env'; @@ -26,6 +33,9 @@ export class BranchManager { /** @type {Map} */ #batches = new Map(); + /** @type {Effect | null} */ + #effect = null; + /** * Map of keys to effects that are currently rendered in the DOM. * These effects are visible and actively part of the document tree. @@ -90,6 +100,8 @@ export class BranchManager { var offscreen = this.#offscreen.get(key); if (offscreen) { + speculative_branches.delete(offscreen.effect); + // effect could have been outro'ed before through a prior batch — resume if necessary resume_effect(offscreen.effect); this.#onscreen.set(key, offscreen.effect); @@ -111,6 +123,14 @@ export class BranchManager { } for (const [b, k] of this.#batches) { + var fork = b.resolved(); + if ( + fork.is_fork && + depends_on_fork_values(/** @type {Effect} */ (this.#effect), fork, batch.resolved()) + ) { + continue; + } + this.#batches.delete(b); if (b === batch) { @@ -171,6 +191,8 @@ export class BranchManager { const keys = Array.from(this.#batches.values()); for (const [k, branch] of this.#offscreen) { + speculative_branches.get(branch.effect)?.batches.delete(batch); + if (!keys.includes(k)) { destroy_effect(branch.effect); this.#offscreen.delete(k); @@ -185,7 +207,14 @@ export class BranchManager { */ ensure(key, fn) { var batch = /** @type {Batch} */ (current_batch); - var defer = should_defer_append(); + var defer = batch.is_fork || should_defer_append(); + this.#effect = /** @type {Effect} */ (active_effect); + + if (batch.is_fork) { + // Even constant selectors must survive so another batch can select their branches. + this.#effect.f |= EFFECT_PRESERVED; + speculative_selectors.add(this.#effect); + } if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { if (defer) { @@ -194,10 +223,16 @@ export class BranchManager { fragment.append(target); - this.#offscreen.set(key, { - effect: branch(() => fn(target)), - fragment - }); + var effect = branch(() => fn(target)); + this.#offscreen.set(key, { effect, fragment }); + + if (batch.is_fork) { + speculative_branches.set(effect, { + batches: new Set([batch]), + d: new Set(), + m: new Set() + }); + } } else { this.#onscreen.set( key, @@ -221,6 +256,7 @@ export class BranchManager { if (k === key) { batch.unskip_effect(branch.effect); } else { + speculative_branches.get(branch.effect)?.batches.delete(batch); batch.skip_effect(branch.effect); } } @@ -228,6 +264,9 @@ export class BranchManager { batch.oncommit(this.#commit); batch.ondiscard(this.#discard); } else { + var offscreen = this.#offscreen.get(key); + if (offscreen) batch.unskip_effect(offscreen.effect); + if (hydrating) { this.anchor = hydrate_node; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index aea176420a..7f098015cf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -42,7 +42,7 @@ import { update } from './sources.js'; import { eager_effect, teardown, unlink_effect } from './effects.js'; -import { defer_effect } from './utils.js'; +import { clear_marked, defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; import { set_signal_status } from './status.js'; import { OBSOLETE } from './deriveds.js'; @@ -73,6 +73,16 @@ export let active_batch = null; */ export let previous_batch = null; +/** + * Fork-created offscreen branches can only be traversed by batches that selected them. + * Dirty descendants are retained here so later selectors can also pick up their updates. + * @type {WeakMap, d: Set, m: Set }>} + */ +export const speculative_branches = new WeakMap(); + +/** @type {WeakSet} */ +export const speculative_selectors = new WeakSet(); + /** @type {Effect | null} */ let last_scheduled_effect = null; @@ -425,10 +435,22 @@ export class Batch { */ unskip_effect(effect) { var tracked = this.#skipped_branches?.get(effect); + var speculative = speculative_branches.get(effect); + + if ( + speculative !== undefined && + !Array.from(speculative.batches, (batch) => batch.resolved()).includes(this) + ) { + speculative.batches.add(this); + revalidate_branch(effect, this); + tracked = { + d: [...(tracked?.d ?? []), ...speculative.d], + m: [...(tracked?.m ?? []), ...speculative.m] + }; + } + if (tracked) { - /** @type {Map} */ (this.#skipped_branches).delete( - effect - ); + this.#skipped_branches?.delete(effect); for (var e of tracked.d) { set_signal_status(e, DIRTY); @@ -983,6 +1005,31 @@ export class Batch { (flags & INERT) !== 0 || this.#skipped_branches?.has(effect) === true; + var speculative = !skip && is_branch ? speculative_branches.get(effect) : undefined; + + if (speculative !== undefined) { + var batches = Array.from(speculative.batches, (batch) => batch.resolved()); + + if (!batches.includes(this)) { + // Do not even dirty-check descendants in a world where they don't exist. + // Keep their updates for the batches that can eventually commit this branch. + var tracked = { d: [], m: [] }; + reset_branch(effect, tracked); + + // Another fork's writes only matter if that fork is committed. + if (!this.is_fork) { + for (const e of tracked.d) speculative.d.add(e); + for (const e of tracked.m) speculative.m.add(e); + + for (const batch of batches) { + batch.transfer_effects(new Set(tracked.d), new Set(tracked.m)); + } + } + + skip = true; + } + } + if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; @@ -993,11 +1040,17 @@ export class Batch { } else { var dirty = is_dirty(effect); + // Async invalidations are consumed once checked, not replayed when promises settle. + if ((flags & ASYNC) !== 0) { + this.#maybe_dirty_effects?.delete(effect); + } + if (dirty) { if ((flags & BLOCK_EFFECT) !== 0) { (this.#maybe_dirty_effects ??= new Set()).add(effect); } update_effect(effect); + this.#dirty_effects?.delete(effect); } else if ((flags & MAYBE_DIRTY) !== 0) { this.record_effect(effect); } @@ -1625,21 +1678,30 @@ export function eager(fn) { /** * 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) + * fork's own speculative writes), excluding writes superseded by `committing` * @param {Reaction} reaction * @param {Batch} fork + * @param {Batch | null} [committing] * @returns {boolean} */ -function depends_on_fork_values(reaction, fork) { +export function depends_on_fork_values(reaction, fork, committing = null) { 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 ( + fork.current.has(dep) && + !(committing !== null && fork.id < committing.id && committing.current.has(dep)) + ) { + return true; + } - if ((dep.f & DERIVED) !== 0 && depends_on_fork_values(/** @type {Derived} */ (dep), fork)) { + if ( + (dep.f & DERIVED) !== 0 && + depends_on_fork_values(/** @type {Derived} */ (dep), fork, committing) + ) { return true; } } @@ -1742,6 +1804,7 @@ function reset_branch(effect, tracked) { } set_signal_status(effect, CLEAN); + clear_marked(effect.deps); var e = effect.first; while (e !== null) { @@ -1750,6 +1813,25 @@ function reset_branch(effect, tracked) { } } +/** + * A branch adopted from a fork may contain clean selectors that only ran in + * the fork's world. Recheck them before publishing any nested branches. + * @param {Effect} effect + * @param {Batch} batch + */ +function revalidate_branch(effect, batch) { + for (var e = effect.first; e !== null; e = e.next) { + if (speculative_branches.has(e)) continue; + + if (speculative_selectors.has(e)) { + set_signal_status(e, DIRTY); + batch.schedule(e); + } + + revalidate_branch(e, batch); + } +} + /** * Mark an entire effect tree clean following an error * @param {Effect} effect diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js index 0d27cb8b84..39d6d9be05 100644 --- a/packages/svelte/src/internal/client/reactivity/utils.js +++ b/packages/svelte/src/internal/client/reactivity/utils.js @@ -5,7 +5,7 @@ import { set_signal_status } from './status.js'; /** * @param {Value[] | null} deps */ -function clear_marked(deps) { +export function clear_marked(deps) { if (deps === null) return; for (const dep of deps) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js new file mode 100644 index 0000000000..40ee9af704 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, instance }) { + const [fork_button, update, resolve, discard] = target.querySelectorAll('button'); + + fork_button.click(); + await tick(); + assert.equal(instance.get_calls(), 1); + assert.htmlEqual(target.innerHTML, buttons); + + // Transfer an invalidation into the fork while its async work is pending. + update.click(); + await tick(); + assert.equal(instance.get_calls(), 1); // can also be 2 at this point already, would also be ok + + try { + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 2); + + // Completing the replacement must not replay the same invalidation. + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 2); + } finally { + discard.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte new file mode 100644 index 0000000000..814980d99b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte @@ -0,0 +1,42 @@ + + + + + + + +{#if show} + + {#snippet pending()}loading{/snippet} +

{await load(searchParams.value)}

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js new file mode 100644 index 0000000000..e9f204d71d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, increment, commit, merge, resolve] = target.querySelectorAll('button'); + + preload.click(); + flushSync(() => increment.click()); + assert.deepEqual(logs, [0]); + + commit.click(); + assert.deepEqual(logs, [0, 1]); + + flushSync(() => merge.click()); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, buttons); + + // Resolve the obsolete request for 0, then the existing request for 1. + resolve.click(); + resolve.click(); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, `${buttons}

1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte new file mode 100644 index 0000000000..1166cc0a03 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + +{#if show && gate > 0} +

{await load(count)}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js new file mode 100644 index 0000000000..8b50ff2e19 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js @@ -0,0 +1,45 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, increment, commit, reveal, discard, preload_second, commit_second, reset] = + target.querySelectorAll('button'); + + for (const mode of ['commit', 'reveal', 'second-fork']) { + preload.click(); + flushSync(() => increment.click()); + assert.htmlEqual(target.innerHTML, buttons); + + if (mode === 'commit') { + commit.click(); + await tick(); + } else if (mode === 'reveal') { + flushSync(() => reveal.click()); + discard.click(); + } else { + preload_second.click(); + discard.click(); + commit_second.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, `${buttons}

1 2

`); + flushSync(() => increment.click()); + assert.htmlEqual(target.innerHTML, `${buttons}

2 4

`); + flushSync(() => reset.click()); + assert.htmlEqual(target.innerHTML, buttons); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte new file mode 100644 index 0000000000..a0e98c1c4b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + + +{#if show} +

{count} {doubled}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js new file mode 100644 index 0000000000..1c16112872 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, reveal, hide, commit] = target.querySelectorAll('button'); + + preload.click(); + reveal.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}0

constant

keyed

boundary

` + ); + + hide.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}0`); + + // The remaining fork write must not resurrect its obsolete branch selection. + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte new file mode 100644 index 0000000000..13247dda7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte @@ -0,0 +1,22 @@ + + + + + + + +{other} + +{#if show} + {#if true}

constant

{/if} + {#key 1}

keyed

{/key} + +

boundary

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js new file mode 100644 index 0000000000..acf299a06a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js @@ -0,0 +1,73 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, reveal, reveal_and_navigate, navigate, resolve, commit, discard, reset] = + target.querySelectorAll('button'); + + for (const mode of ['separate', 'together', 'pending']) { + for (const finish of [commit, discard]) { + preload.click(); + + if (mode !== 'pending') { + resolve.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, ['load']); + + if (mode === 'together') { + reveal_and_navigate.click(); + } else { + reveal.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + navigate.click(); + } + + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + assert.deepEqual(logs, ['load']); + + if (mode === 'pending') { + resolve.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + } + + finish.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}
${finish === commit ? 'pending

2

' : ''}
` + ); + assert.deepEqual(logs, ['load']); + + if (finish === commit) { + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, ['load']); + } + + reset.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + logs.length = 0; + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte new file mode 100644 index 0000000000..6d24027f05 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + +{#if outer} +
+ {#if inner} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + + {/if} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js new file mode 100644 index 0000000000..40a9c5e63d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, navigate, commit, discard] = target.querySelectorAll('button'); + + preload.click(); + // Let the async child resolve, without committing its fork. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + // A real-world update must not evaluate the speculative child in the real world. + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + discard.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + preload.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}pending

2

`); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte new file mode 100644 index 0000000000..43e9600df9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +{#if show} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + +{/if}