fucking hell forks are hard

async-another-try
Simon Holthausen 3 days ago
parent a7c16935a5
commit 2cfc5819d0
No known key found for this signature in database

@ -54,6 +54,8 @@ export const EFFECT_OFFSCREEN = 1 << 25;
// Flags used for async
export const REACTION_IS_UPDATING = 1 << 21;
export const ASYNC = 1 << 22;
/** Set on branch effects that only exist for fork batches */
export const FORK_ONLY_BRANCH = 1 << 23;
export const ERROR_VALUE = 1 << 23;

@ -7,7 +7,7 @@ import {
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
import { HMR_ANCHOR } from '../../constants.js';
import { FORK_ONLY_BRANCH, HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
import { DEV } from 'esm-env';
@ -188,18 +188,26 @@ export class BranchManager {
ensure(key, fn) {
var batch = /** @type {Batch} */ (current_batch);
var defer = should_defer_append();
var first = false;
if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) {
first = true;
if (defer) {
var fragment = document.createDocumentFragment();
var target = create_text();
fragment.append(target);
const b = branch(() => fn(target));
this.#offscreen.set(key, {
effect: branch(() => fn(target)),
effect: b,
fragment
});
if (batch.is_fork) {
b.f ^= FORK_ONLY_BRANCH;
}
} else {
this.#onscreen.set(
key,
@ -210,6 +218,15 @@ export class BranchManager {
this.#batches.set(batch, key);
const offscreen = this.#offscreen.get(key);
if (offscreen && offscreen.effect.f & FORK_ONLY_BRANCH) {
if (batch.is_fork) {
batch.unskip_effect(offscreen.effect, undefined, !first);
} else {
offscreen.effect.f ^= FORK_ONLY_BRANCH;
}
}
if (defer) {
for (const [k, effect] of this.#onscreen) {
if (k === key) {

@ -3,7 +3,7 @@ import { hydrate_node, hydrating, reset, set_hydrate_node } from './hydration.js
import { DEV } from 'esm-env';
import { init_array_prototype_warnings } from '../dev/equality.js';
import { get_descriptor, is_extensible } from '../../shared/utils.js';
import { active_effect } from '../runtime.js';
import { active_effect, new_deps, skipped_deps } from '../runtime.js';
import { async_mode_flag } from '../../flags/index.js';
import {
ATTRIBUTES_CACHE,
@ -13,7 +13,7 @@ import {
TEXT_CACHE,
TEXT_NODE
} from '#client/constants';
import { eager_block_effects } from '../reactivity/batch.js';
import { current_batch, eager_block_effects } from '../reactivity/batch.js';
import { NAMESPACE_HTML } from '../../../constants.js';
// export these for reference in the compiled code, making global name deduplication unnecessary
@ -249,7 +249,12 @@ export function should_defer_append() {
if (eager_block_effects !== null) return false;
var flags = /** @type {Effect} */ (active_effect).f;
return (flags & REACTION_RAN) !== 0;
var ran = (flags & REACTION_RAN) !== 0;
if (ran || !current_batch?.is_fork) return ran;
// In a fork we generally want to defer the append, unless this is the first run
// and that run is terminal, i.e. there are no deps so the e.g. if block can never
// rerun, which means it can never end up in commit callbacks for other batches.
return new_deps !== null || skipped_deps !== 0;
}
/**

@ -17,7 +17,8 @@ import {
ERROR_VALUE,
MANAGED_EFFECT,
REACTION_RAN,
DESTROYING
DESTROYING,
FORK_ONLY_BRANCH
} from '#client/constants';
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
@ -246,9 +247,10 @@ export class Batch {
/**
* Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing
* @type {Set<Effect>}
* true indicates that this branch is new to the eyes of this fork but was already created before.
* @type {Map<Effect, boolean>}
*/
#unskipped_branches = new Set();
#unskipped_branches = new Map();
is_fork = false;
@ -284,10 +286,9 @@ export class Batch {
} else {
last_batch.next = this;
this.prev = last_batch;
last_batch = this;
}
}
last_batch = this;
}
#is_deferred() {
@ -330,8 +331,9 @@ export class Batch {
* any tracked dirty/maybe_dirty child effects
* @param {Effect} effect
* @param {(e: Effect) => void} callback
* @param {boolean} is_fork_init
*/
unskip_effect(effect, callback = (e) => this.schedule(e)) {
unskip_effect(effect, callback = (e) => this.schedule(e), is_fork_init = false) {
var tracked = this.#skipped_branches.get(effect);
if (tracked) {
this.#skipped_branches.delete(effect);
@ -346,7 +348,7 @@ export class Batch {
callback(e);
}
}
this.#unskipped_branches.add(effect);
if (!this.#unskipped_branches.has(effect)) this.#unskipped_branches.set(effect, is_fork_init);
}
/**
@ -434,6 +436,14 @@ export class Batch {
set_signal_status(d, MAYBE_DIRTY);
}
if (!this.is_fork) {
for (const e of this.#unskipped_branches.keys()) {
if (e.f & FORK_ONLY_BRANCH) {
e.f ^= FORK_ONLY_BRANCH;
}
}
}
// An earlier batch might have created new branches which contain effects that we need
// to mark as dirty to also execute them.
// TODO does this make the similar logic in fork.commit below obsolete?
@ -585,14 +595,41 @@ export class Batch {
root.f ^= CLEAN;
var effect = root.first;
var all_dirty = null;
while (effect !== null) {
if (all_dirty) {
if (effect.f & CLEAN) effect.f ^= CLEAN;
if ((effect.f & DIRTY) === 0) effect.f |= MAYBE_DIRTY;
}
var flags = effect.f;
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
if ((flags & FORK_ONLY_BRANCH) !== 0) {
var first_time = this.#unskipped_branches.get(effect);
if (first_time === undefined) {
skip = true;
this.skip_effect(effect);
reset_branch(
effect,
/** @type {{d: Effect[], m: Effect[]}} */ (this.#skipped_branches.get(effect))
);
} else if (first_time) {
// We're seeing a fork-only branch for the first time in another fork. We need to traverse
// all effects inside it (they're all marked MAYBE_DIRTY). This is necessary because
// dependencies of the effects inside could've updated since the last time this branch ran.
this.#unskipped_branches.set(effect, false);
all_dirty ??= effect;
if (effect.f & CLEAN) effect.f ^= CLEAN;
skip = false;
}
}
if (!skip && effect.fn !== null) {
if (is_branch) {
effect.f ^= CLEAN;
@ -625,6 +662,8 @@ export class Batch {
}
effect = effect.parent;
if (effect === all_dirty) all_dirty = null;
}
}
}
@ -698,7 +737,7 @@ export class Batch {
? !this.seen_effects.has(effect) &&
!this.#dirty_effects.has(effect) &&
!this.#maybe_dirty_effects.has(effect)
: flags & (ASYNC | BLOCK_EFFECT) && this.seen_effects.has(effect)
: (flags & (ASYNC | BLOCK_EFFECT)) === 0 || this.seen_effects.has(effect)
) {
this.#maybe_dirty_effects.delete(effect);
set_signal_status(effect, status);
@ -744,7 +783,7 @@ export class Batch {
this.#unskipped_branches.delete(s);
}
for (const s of batch.#unskipped_branches) {
for (const s of batch.#unskipped_branches.keys()) {
const v = this.#skipped_branches.get(s);
// TODO i do wonder at this point if it's less code / easier / more robust to do what mark() below does
// instead and just rerun all the block effects. Though it will certainly overrun some blocks, potentially
@ -1882,7 +1921,6 @@ export function fork(fn) {
// for (const run of batch.on_fork_commit.values()) {
// run();
// }
batch.flush();
await settled;
},

@ -448,7 +448,6 @@ export function update_derived(derived) {
!derived.equals(/** @type {any[]} */ (batch_values?.get(derived))[0])
) {
current_batch?.capture(derived, derived.v);
// TODO also bump wv_values?
}
// don't mark derived clean if we're reading it inside a

@ -278,7 +278,6 @@ export function internal_set(source, value, updated_during_traversal = null) {
!source.equals(/** @type {any[]} */ (batch_values?.get(source))[0])
) {
current_batch?.capture(source, source.v);
// TODO also bump wv_values?
}
return value;

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO fix
async test({ assert, target, logs }) {
await tick();

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO fix
async test({ assert, target, logs }) {
await tick();

@ -20,17 +20,17 @@ export default test({
// Transfer an invalidation into the fork while its async work is pending.
update.click();
await tick();
assert.equal(instance.get_calls(), 2); // can also be 1 at this point already, would also be ok
assert.equal(instance.get_calls(), 1); // can also be 2 at this point, would also be ok
try {
resolve.click();
await tick();
assert.equal(instance.get_calls(), 2);
assert.equal(instance.get_calls(), 1);
// Completing the replacement must not replay the same invalidation.
resolve.click();
await tick();
assert.equal(instance.get_calls(), 2);
assert.equal(instance.get_calls(), 1);
} finally {
discard.click();
await tick();

@ -18,28 +18,35 @@ export default test({
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();
try {
preload.click();
await tick();
} else if (mode === 'reveal') {
flushSync(() => reveal.click());
discard.click();
} else {
preload_second.click();
discard.click();
commit_second.click();
increment.click();
await tick();
}
assert.htmlEqual(target.innerHTML, buttons);
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);
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);
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
});

@ -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,79 @@
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]) {
try {
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;
} catch (e) {
/** @type {Error} */ (e).message =
`${mode}/${finish === commit ? 'commit' : 'discard'}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

@ -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