prevent branches that only exists for forks being traversed by later real-world batches

entangle-batches-3
Simon Holthausen 8 hours ago
parent 2f4d50c5c6
commit f8276d5fbf
No known key found for this signature in database

@ -1,5 +1,11 @@
/** @import { Effect, TemplateNode } from '#client' */ /** @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 { import {
branch, branch,
destroy_effect, destroy_effect,
@ -7,7 +13,8 @@ import {
pause_effect, pause_effect,
resume_effect resume_effect
} from '../../reactivity/effects.js'; } 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 { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js'; import { create_text, should_defer_append } from '../operations.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
@ -26,6 +33,9 @@ export class BranchManager {
/** @type {Map<Batch, Key>} */ /** @type {Map<Batch, Key>} */
#batches = new Map(); #batches = new Map();
/** @type {Effect | null} */
#effect = null;
/** /**
* Map of keys to effects that are currently rendered in the DOM. * Map of keys to effects that are currently rendered in the DOM.
* These effects are visible and actively part of the document tree. * These effects are visible and actively part of the document tree.
@ -90,6 +100,8 @@ export class BranchManager {
var offscreen = this.#offscreen.get(key); var offscreen = this.#offscreen.get(key);
if (offscreen) { if (offscreen) {
speculative_branches.delete(offscreen.effect);
// effect could have been outro'ed before through a prior batch — resume if necessary // effect could have been outro'ed before through a prior batch — resume if necessary
resume_effect(offscreen.effect); resume_effect(offscreen.effect);
this.#onscreen.set(key, offscreen.effect); this.#onscreen.set(key, offscreen.effect);
@ -111,6 +123,14 @@ export class BranchManager {
} }
for (const [b, k] of this.#batches) { 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); this.#batches.delete(b);
if (b === batch) { if (b === batch) {
@ -171,6 +191,8 @@ export class BranchManager {
const keys = Array.from(this.#batches.values()); const keys = Array.from(this.#batches.values());
for (const [k, branch] of this.#offscreen) { for (const [k, branch] of this.#offscreen) {
speculative_branches.get(branch.effect)?.batches.delete(batch);
if (!keys.includes(k)) { if (!keys.includes(k)) {
destroy_effect(branch.effect); destroy_effect(branch.effect);
this.#offscreen.delete(k); this.#offscreen.delete(k);
@ -185,7 +207,14 @@ export class BranchManager {
*/ */
ensure(key, fn) { ensure(key, fn) {
var batch = /** @type {Batch} */ (current_batch); 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 (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) {
if (defer) { if (defer) {
@ -194,10 +223,16 @@ export class BranchManager {
fragment.append(target); fragment.append(target);
this.#offscreen.set(key, { var effect = branch(() => fn(target));
effect: branch(() => fn(target)), this.#offscreen.set(key, { effect, fragment });
fragment
}); if (batch.is_fork) {
speculative_branches.set(effect, {
batches: new Set([batch]),
d: new Set(),
m: new Set()
});
}
} else { } else {
this.#onscreen.set( this.#onscreen.set(
key, key,
@ -221,6 +256,7 @@ export class BranchManager {
if (k === key) { if (k === key) {
batch.unskip_effect(branch.effect); batch.unskip_effect(branch.effect);
} else { } else {
speculative_branches.get(branch.effect)?.batches.delete(batch);
batch.skip_effect(branch.effect); batch.skip_effect(branch.effect);
} }
} }
@ -228,6 +264,9 @@ export class BranchManager {
batch.oncommit(this.#commit); batch.oncommit(this.#commit);
batch.ondiscard(this.#discard); batch.ondiscard(this.#discard);
} else { } else {
var offscreen = this.#offscreen.get(key);
if (offscreen) batch.unskip_effect(offscreen.effect);
if (hydrating) { if (hydrating) {
this.anchor = hydrate_node; this.anchor = hydrate_node;
} }

@ -42,7 +42,7 @@ import {
update update
} from './sources.js'; } from './sources.js';
import { eager_effect, teardown, unlink_effect } from './effects.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 { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js'; import { set_signal_status } from './status.js';
import { OBSOLETE } from './deriveds.js'; import { OBSOLETE } from './deriveds.js';
@ -73,6 +73,16 @@ export let active_batch = null;
*/ */
export let previous_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<Effect, { batches: Set<Batch>, d: Set<Effect>, m: Set<Effect> }>}
*/
export const speculative_branches = new WeakMap();
/** @type {WeakSet<Effect>} */
export const speculative_selectors = new WeakSet();
/** @type {Effect | null} */ /** @type {Effect | null} */
let last_scheduled_effect = null; let last_scheduled_effect = null;
@ -425,10 +435,22 @@ export class Batch {
*/ */
unskip_effect(effect) { unskip_effect(effect) {
var tracked = this.#skipped_branches?.get(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) { if (tracked) {
/** @type {Map<Effect, { d: Effect[], m: Effect[] }>} */ (this.#skipped_branches).delete( this.#skipped_branches?.delete(effect);
effect
);
for (var e of tracked.d) { for (var e of tracked.d) {
set_signal_status(e, DIRTY); set_signal_status(e, DIRTY);
@ -983,6 +1005,31 @@ export class Batch {
(flags & INERT) !== 0 || (flags & INERT) !== 0 ||
this.#skipped_branches?.has(effect) === true; 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 (!skip && effect.fn !== null) {
if (is_branch) { if (is_branch) {
effect.f ^= CLEAN; effect.f ^= CLEAN;
@ -993,11 +1040,17 @@ export class Batch {
} else { } else {
var dirty = is_dirty(effect); 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 (dirty) {
if ((flags & BLOCK_EFFECT) !== 0) { if ((flags & BLOCK_EFFECT) !== 0) {
(this.#maybe_dirty_effects ??= new Set()).add(effect); (this.#maybe_dirty_effects ??= new Set()).add(effect);
} }
update_effect(effect); update_effect(effect);
this.#dirty_effects?.delete(effect);
} else if ((flags & MAYBE_DIRTY) !== 0) { } else if ((flags & MAYBE_DIRTY) !== 0) {
this.record_effect(effect); this.record_effect(effect);
} }
@ -1625,21 +1678,30 @@ export function eager(fn) {
/** /**
* Whether `reaction` depends directly or through deriveds on a signal * 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 * 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 {Reaction} reaction
* @param {Batch} fork * @param {Batch} fork
* @param {Batch | null} [committing]
* @returns {boolean} * @returns {boolean}
*/ */
function depends_on_fork_values(reaction, fork) { export function depends_on_fork_values(reaction, fork, committing = null) {
var deps = reaction.deps; var deps = reaction.deps;
if (deps === null) return false; if (deps === null) return false;
for (var i = 0; i < deps.length; i++) { for (var i = 0; i < deps.length; i++) {
var dep = deps[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; return true;
} }
} }
@ -1742,6 +1804,7 @@ function reset_branch(effect, tracked) {
} }
set_signal_status(effect, CLEAN); set_signal_status(effect, CLEAN);
clear_marked(effect.deps);
var e = effect.first; var e = effect.first;
while (e !== null) { 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 * Mark an entire effect tree clean following an error
* @param {Effect} effect * @param {Effect} effect

@ -5,7 +5,7 @@ import { set_signal_status } from './status.js';
/** /**
* @param {Value[] | null} deps * @param {Value[] | null} deps
*/ */
function clear_marked(deps) { export function clear_marked(deps) {
if (deps === null) return; if (deps === null) return;
for (const dep of deps) { for (const dep of deps) {

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>update</button>
<button>resolve</button>
<button>discard</button>
`;
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);
}
});

@ -0,0 +1,42 @@
<script>
import { fork } from 'svelte';
let sharedState = $state(0);
let show = $state(false);
let f;
let calls = 0;
const resolvers = [];
// Only evaluated in the fork, with a fresh object on each evaluation.
const searchParams = $derived({ value: sharedState });
export function get_calls() {
return calls;
}
function load(value) {
calls += 1;
return new Promise((resolve) => resolvers.push(() => resolve(value)));
}
</script>
<button
onclick={() => {
f = fork(() => {
sharedState = 1;
show = true;
});
}}
>fork</button
>
<button onclick={() => sharedState++}>update</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>
<button onclick={() => f?.discard()}>discard</button>
{#if show}
<svelte:boundary>
{#snippet pending()}loading{/snippet}
<p>{await load(searchParams.value)}</p>
</svelte:boundary>
{/if}

@ -0,0 +1,35 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>increment</button>
<button>commit</button>
<button>merge</button>
<button>resolve</button>
`;
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}<p>1</p>`);
}
});

@ -0,0 +1,25 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let count = $state(0);
let gate = $state(1);
let f;
const deferred = [];
function load(value) {
console.log(value);
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => (f = fork(() => (show = true)))}>preload</button>
<button onclick={() => count++}>increment</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => gate++}>merge</button>
<button onclick={() => deferred.shift()?.()}>resolve</button>
{#if show && gate > 0}
<p>{await load(count)}</p>
{/if}

@ -0,0 +1,45 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>increment</button>
<button>commit</button>
<button>reveal</button>
<button>discard</button>
<button>preload second</button>
<button>commit second</button>
<button>reset</button>
`;
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}<p>1 2</p>`);
flushSync(() => increment.click());
assert.htmlEqual(target.innerHTML, `${buttons}<p>2 4</p>`);
flushSync(() => reset.click());
assert.htmlEqual(target.innerHTML, buttons);
}
}
});

@ -0,0 +1,22 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let count = $state(0);
let doubled = $derived(count * 2);
let f;
let other;
</script>
<button onclick={() => (f = fork(() => (show = true)))}>preload</button>
<button onclick={() => count++}>increment</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => (show = true)}>reveal</button>
<button onclick={() => f.discard()}>discard</button>
<button onclick={() => (other = fork(() => (show = true)))}>preload second</button>
<button onclick={() => other.commit()}>commit second</button>
<button onclick={() => { show = false; count = 0; }}>reset</button>
{#if show}
<p>{count} {doubled}</p>
{/if}

@ -0,0 +1,32 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>reveal</button>
<button>hide</button>
<button>commit</button>
`;
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}<b>0</b><p>constant</p><p>keyed</p><p>boundary</p>`
);
hide.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>0</b>`);
// The remaining fork write must not resurrect its obsolete branch selection.
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>1</b>`);
}
});

@ -0,0 +1,22 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let other = $state(0);
let f;
</script>
<button onclick={() => (f = fork(() => { show = true; other = 1; }))}>preload</button>
<button onclick={() => (show = true)}>reveal</button>
<button onclick={() => (show = false)}>hide</button>
<button onclick={() => f.commit()}>commit</button>
<b>{other}</b>
{#if show}
{#if true}<p>constant</p>{/if}
{#key 1}<p>keyed</p>{/key}
<svelte:boundary onerror={console.error}>
<p>boundary</p>
</svelte:boundary>
{/if}

@ -0,0 +1,8 @@
<script>
let { total, navigating } = $props();
const pages = $derived(Math.ceil(total / 28));
const pending = $derived(navigating && pages > 1);
</script>
{#if pending}pending{/if}
<p>{pages}</p>

@ -0,0 +1,73 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>reveal outer</button>
<button>reveal and navigate</button>
<button>navigate</button>
<button>resolve</button>
<button>commit</button>
<button>discard</button>
<button>reset</button>
`;
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}<section></section>`);
navigate.click();
}
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section></section>`);
assert.deepEqual(logs, ['load']);
if (mode === 'pending') {
resolve.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section></section>`);
}
finish.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`${buttons}<section>${finish === commit ? 'pending <p>2</p>' : ''}</section>`
);
assert.deepEqual(logs, ['load']);
if (finish === commit) {
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section><p>2</p></section>`);
assert.deepEqual(logs, ['load']);
}
reset.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
logs.length = 0;
}
}
}
});

@ -0,0 +1,36 @@
<script>
import { fork } from 'svelte';
import Child from './Child.svelte';
let outer = $state(false);
let inner = $state(false);
let navigating = $state(false);
let f;
const deferred = [];
function load() {
console.log('load');
return new Promise((resolve) => deferred.push(() => resolve(42)));
}
</script>
<button onclick={() => (f = fork(() => { outer = true; inner = true; }))}>preload</button>
<button onclick={() => (outer = true)}>reveal outer</button>
<button onclick={() => { outer = true; navigating = true; }}>reveal and navigate</button>
<button onclick={() => (navigating = !navigating)}>navigate</button>
<button onclick={() => deferred.shift()?.()}>resolve</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => f.discard()}>discard</button>
<button onclick={() => { outer = false; inner = false; navigating = false; }}>reset</button>
{#if outer}
<section>
{#if inner}
<svelte:boundary onerror={(error) => console.log(error.message)}>
{#snippet pending()}loading{/snippet}
<Child total={await load()} {navigating} />
</svelte:boundary>
{/if}
</section>
{/if}

@ -0,0 +1,8 @@
<script>
let { total, navigating } = $props();
const pages = $derived(Math.ceil(total / 28));
const pending = $derived(navigating && pages > 1);
</script>
{#if pending}pending{/if}
<p>{pages}</p>

@ -0,0 +1,50 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>navigate</button>
<button>commit</button>
<button>discard</button>
`;
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 <p>2</p>`);
assert.deepEqual(logs, []);
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
assert.deepEqual(logs, []);
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
import Child from './Child.svelte';
let show = $state(false);
let navigating = $state(false);
let f;
</script>
<button onclick={() => (f = fork(() => (show = true)))}>preload</button>
<button onclick={() => (navigating = !navigating)}>navigate</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => f.discard()}>discard</button>
{#if show}
<svelte:boundary onerror={(error) => console.log(error.message)}>
{#snippet pending()}loading{/snippet}
<Child total={await Promise.resolve(42)} {navigating} />
</svelte:boundary>
{/if}
Loading…
Cancel
Save