make each blocks fork-aware + fixes around flatten/run seing discarded forks

async-another-try-pt-3
Simon Holthausen 4 days ago
parent 7018c09247
commit 18219ad1ff
No known key found for this signature in database

@ -35,7 +35,14 @@ import {
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
import { BRANCH_EFFECT, COMMENT_NODE, DESTROYED, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import {
BRANCH_EFFECT,
COMMENT_NODE,
DESTROYED,
EFFECT_OFFSCREEN,
FORK_ONLY_BRANCH,
INERT
} from '#client/constants';
import { queue_micro_task } from '../task.js';
import { get } from '../../runtime.js';
import { DEV } from 'esm-env';
@ -262,7 +269,27 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {Batch} batch
*/
function discard(batch) {
state.pending.delete(batch);
pending.delete(batch);
if (batch.is_fork) {
// destroy items (and the fallback) that only existed for this fork, unless another fork needs them
var needed = [...pending.values()];
for (const [key, item] of items) {
if ((item.e.f & FORK_ONLY_BRANCH) !== 0 && !needed.some((keys) => keys.has(key))) {
destroy_effect(item.e);
}
}
if (
fallback !== null &&
(fallback.f & FORK_ONLY_BRANCH) !== 0 &&
!needed.some((keys) => keys.size === 0)
) {
destroy_effect(fallback);
fallback = null;
}
}
}
var effect = block(() => {
@ -320,6 +347,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (item.v) internal_set(item.v, value);
if (item.i) internal_set(item.i, index);
// like branches of other blocks, an item created by a fork has
// to be brought up to date with the batch that adopts it
if ((item.e.f & FORK_ONLY_BRANCH) !== 0) batch.reveal(item.e);
if (defer) {
batch.unskip_effect(item.e);
}
@ -337,6 +368,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (!first_run) {
item.e.f |= EFFECT_OFFSCREEN;
if (batch.is_fork) {
item.e.f |= FORK_ONLY_BRANCH;
batch.unskip_effect(item.e);
}
}
items.set(key, item);
@ -345,12 +381,23 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
keys.add(key);
}
if (length === 0 && fallback_fn && !fallback) {
if (first_run) {
fallback = branch(() => fallback_fn(anchor));
} else {
fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));
fallback.f |= EFFECT_OFFSCREEN;
if (length === 0 && fallback_fn) {
if (!fallback) {
if (first_run) {
fallback = branch(() => fallback_fn(anchor));
} else {
fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));
fallback.f |= EFFECT_OFFSCREEN;
if (batch.is_fork) {
fallback.f |= FORK_ONLY_BRANCH;
batch.unskip_effect(fallback);
}
}
} else if ((fallback.f & FORK_ONLY_BRANCH) !== 0) {
// same as for items created by a fork
batch.reveal(fallback);
batch.unskip_effect(fallback);
}
}

@ -1,5 +1,5 @@
/** @import { Blocker, Effect, Source, Value } from '#client' */
import { DESTROYED, STALE_REACTION } from '#client/constants';
import { DESTROYED, FORK_ONLY_BRANCH, STALE_REACTION } from '#client/constants';
import { DEV } from 'esm-env';
import {
component_context,
@ -15,7 +15,7 @@ import {
set_active_effect,
set_active_reaction
} from '../runtime.js';
import { Batch, current_batch } from './batch.js';
import { Batch, current_batch, first_batch } from './batch.js';
import {
async_derived,
reactivity_loss_tracker,
@ -24,6 +24,7 @@ import {
set_reactivity_loss_tracker
} from './deriveds.js';
import { aborted } from './effects.js';
import { UNINITIALIZED } from '../../../constants.js';
/**
* @param {Blocker[]} blockers
@ -58,7 +59,7 @@ export function flatten(blockers, sync, async, fn) {
var parent = /** @type {Effect} */ (active_effect);
var restore = capture();
var restore = capture(true);
var blocker_promise =
pending.length === 1
? pending[0].promise
@ -74,7 +75,7 @@ export function flatten(blockers, sync, async, fn) {
return;
}
restore();
restore(true, async);
try {
fn([...deriveds, ...async]);
@ -124,8 +125,10 @@ export function run_after_blockers(blockers, fn) {
* Captures the current effect context so that we can restore it after
* some asynchronous work has happened (so that e.g. `await a + b`
* causes `b` to be registered as a dependency).
* @param {boolean} [content] Whether the context is restored to continue creating content (as opposed to
* continuing a reaction's run), which can be adopted by other batches in the meantime (see `resume_batch`)
*/
export function capture() {
export function capture(content = false) {
var previous_effect = /** @type {Effect} */ (active_effect);
var previous_reaction = active_reaction;
var previous_component_context = component_context;
@ -135,7 +138,11 @@ export function capture() {
var previous_dev_stack = dev_stack;
}
return function restore(activate_batch = true) {
/**
* @param {boolean} [activate_batch]
* @param {Source[]} [signals] The async deriveds whose results the content is about to show
*/
return function restore(activate_batch = true, signals = []) {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_component_context);
@ -144,7 +151,13 @@ export function capture() {
// TODO we only need optional chaining here because `{#await ...}` blocks
// are anomalous. Once we retire them we can get rid of it
previous_batch = previous_batch?.activate();
previous_batch?.apply();
var batch =
content && previous_batch?.is_fork
? resume_batch(previous_batch, previous_effect, signals)
: previous_batch;
batch?.apply();
}
if (DEV) {
@ -154,6 +167,40 @@ export function capture() {
};
}
/**
* Returns the batch in which to continue creating content (inside `effect`) that `fork` started creating.
* The content can be adopted by other batches in the meantime (see `Batch#reveal`), in which case the
* fork's world is no longer (the only) one the content lives in:
* - once the real world adopted it, creation continues in the real world (which then depends on/merges
* into the batch that adopted it, if that is still pending). Unless the real world doesn't have results
* for the async expressions yet — that batch is still waiting for them and will re-run the content then
* - if the fork was discarded, creation continues in a fork that adopted it
* Real batches don't need this: content they share is handled by merging them.
* @param {Batch} fork
* @param {Effect} effect
* @param {Source[]} signals
* @returns {Batch}
*/
function resume_batch(fork, effect, signals) {
var branch = /** @type {Effect | null} */ (effect);
while (branch !== null && (branch.f & FORK_ONLY_BRANCH) === 0) branch = branch.parent;
if (branch === null) {
if (signals.some((s) => s.v === UNINITIALIZED)) return fork;
fork.deactivate();
return Batch.ensure();
}
if (!fork.linked) {
for (var b = first_batch; b !== null; b = b.next) {
if (b.is_fork && b.unskipped_branches.has(branch)) return b.activate();
}
}
return fork;
}
/** `true` between a `save` thunk restoring a context and the end of that synchronous segment */
var restored = false;
@ -304,7 +351,7 @@ export function unset_context(deactivate_batch = true) {
* @param {Array<() => void | Promise<void>>} thunks
*/
export function run(thunks) {
const restore = capture();
const restore = capture(true);
const decrement_pending = increment_pending();

@ -428,8 +428,9 @@ export class Batch {
/**
* Schedule all effects inside a revealed branch. Effects that ran without dependencies are the
* same in every batch. Branches inside it that only exist for (other) forks are left alone, the
* traversal skips them anyway.
* same in every batch — except async effects, whose results only exist in the batch that ran
* them. Branches inside it that only exist for (other) forks are left alone, the traversal skips
* them anyway.
* TODO this can overfire, maybe there's a way to detect which effects saw values that differ.
* @param {Effect} effect
*/
@ -437,7 +438,8 @@ export class Batch {
if (
(effect.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 &&
effect.fn !== null &&
(effect.deps !== null || (effect.f & REACTION_RAN) === 0)
// TODO maybe we find a way to instead find the (pending) async effect and set the resulting value on this batch
(effect.deps !== null || (effect.f & (REACTION_RAN | ASYNC)) !== REACTION_RAN)
) {
set_signal_status(effect, DIRTY);
this.schedule(effect);

@ -192,14 +192,18 @@ export function async_derived(fn, label, location) {
var batch = /** @type {Batch} */ (current_batch);
// If an earlier batch has a run of this async effect in flight, the two batches
// are related and this one has to wait for (i.e. merge into) the earlier one
let prev = batch.prev;
while (prev) {
if (prev.async_deriveds.has(effect)) {
batch.dependent.add(prev);
break;
// are related and this one has to wait for (i.e. merge into) the earlier one.
// Same for forks, though the other way around (the fork has to wait for this batch).
let other = first_batch;
while (other) {
if (other !== batch && other.async_deriveds.has(effect)) {
if (other.id < batch.id) {
batch.dependent.add(other);
} else if (other.is_fork) {
other.dependent.add(batch);
}
}
prev = prev.prev;
other = other.next;
}
if (should_suspend) {
@ -282,7 +286,14 @@ export function async_derived(fn, label, location) {
return new Promise((fulfil) => {
/** @param {Promise<V>} p */
function next(p) {
function go() {
/** @param {unknown} error */
function go(error) {
if (p === promise && error === OBSOLETE) {
// The latest run was discarded (i.e. its batch which was a fork): wait for the latest run that's
// still in flight (if any), as it's the one whose result the (adopted) content will show
for (const d of deferreds) promise = d.promise;
}
if (p === promise) {
fulfil(signal);
} else {

@ -514,7 +514,7 @@ export function update_effect(effect) {
var own = /** @type {Batch} */ (own_batch);
var status = (flags & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 ? DIRTY : MAYBE_DIRTY;
own.stale_effects.set(effect, write_version);
for (var batch = own.next; batch !== null; batch = batch.next) {
for (var batch = own.next; batch !== null && effect.deps !== null; batch = batch.next) {
batch.add_dirty_reaction(effect, status);
}
}

@ -0,0 +1,70 @@
import { tick } from 'svelte';
import { test } from '../../test';
/** @param {number} n */
const html = (n) => `<p>c</p><section>${n}|${n}</section>`;
// Branches with async expressions (with and without dependencies) that fork `a` created are revealed
// by another batch (a real one, or fork `b`), before or after `a`'s results are in. The revealing batch
// has to run the async expressions itself, since `a`'s results only exist in `a`. When `a` is discarded
// the other batch's results are shown; when it's committed, its own results are shown (nothing is fetched
// again), even though the other batch's results come in later.
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const [fork_a, show, fork_b, reset, resolve_all, discard_a, commit_a, commit_b] =
target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
for (const revealer of ['real', 'fork']) {
for (const finish of ['discard', 'commit']) {
for (const resolved of [true, false]) {
const mode = `${revealer} reveals, a ${finish}ed, a's results ${resolved ? 'resolved' : 'pending'}`;
try {
fork_a.click();
if (resolved) {
resolve_all.click();
await tick();
}
assert.deepEqual(instance.log.splice(0), ['cc', 'n1']);
if (revealer === 'real') show.click();
else fork_b.click();
await tick();
assert.htmlEqual(output.innerHTML, '');
assert.deepEqual(instance.log.splice(0), ['cc', 'n0']);
if (finish === 'discard') discard_a.click();
else commit_a.click();
await settle();
assert.htmlEqual(
output.innerHTML,
finish === 'commit' ? html(1) : revealer === 'real' ? html(0) : ''
);
commit_b.click();
await settle();
assert.htmlEqual(output.innerHTML, html(finish === 'commit' ? 1 : 0));
assert.deepEqual(instance.log, []);
reset.click();
await settle();
assert.htmlEqual(output.innerHTML, '');
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
}
});

@ -0,0 +1,53 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let n = $state(0);
export const log = [];
const registry = [];
let a;
let b;
function f(tag, value) {
log.push(`${tag}${value}`);
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function set(values) {
if ('show' in values) show = values.show;
if ('n' in values) n = values.n;
}
function resolve_all() {
for (const r of registry.splice(0)) r();
}
function discard_a() {
a?.discard();
a = undefined;
}
function commit_a() {
a?.commit();
a = undefined;
}
function commit_b() {
b?.commit();
b = undefined;
}
</script>
<button onclick={() => (a = fork(() => set({ show: true, n: 1 })))}>fork a</button>
<button onclick={() => set({ show: true })}>show</button>
<button onclick={() => (b = fork(() => set({ show: true })))}>fork b</button>
<button onclick={() => set({ show: false, n: 0 })}>reset</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={discard_a}>discard a</button>
<button onclick={commit_a}>commit a</button>
<button onclick={commit_b}>commit b</button>
<main>
{#if show}<p>{await f('c', 'c')}</p>{/if}
{#if show}<section>{n}|{await f('n', n)}</section>{/if}
</main>

@ -0,0 +1,33 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
async test({ assert, target }) {
const [fork_a, fork_b, resolve_one, resolve_all, discard_a, commit_b] =
target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
fork_a.click();
await tick();
resolve_one.click();
await tick();
fork_b.click();
await tick();
resolve_one.click();
await tick();
discard_a.click();
await tick();
resolve_one.click();
await tick();
commit_b.click();
await tick();
for (let i = 0; i < 5; i++) {
resolve_all.click();
await tick();
}
assert.htmlEqual(output.innerHTML, '<section>s</section>');
}
});

@ -0,0 +1,23 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
const registry = [];
let a;
let b;
function f(value) {
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function resolve_all() { for (const r of registry.splice(0)) r(); }
</script>
<button onclick={() => (a = fork(() => (show = true)))}>fork a</button>
<button onclick={() => (b = fork(() => (show = true)))}>fork b</button>
<button onclick={() => registry.shift()?.()}>resolve one</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => a?.discard()}>discard a</button>
<button onclick={() => b?.commit()}>commit b</button>
<main>{#if show}<section>{await f('s')}</section>{/if}</main>

@ -0,0 +1,6 @@
<script>
let { f, n } = $props();
let x = $derived(await f(n));
</script>
<p>{n}|{x}</p>

@ -0,0 +1,53 @@
import { tick } from 'svelte';
import { test } from '../../test';
// A component with a top-level `await` inside a branch that a fork created is revealed by a real batch
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const [fork_a, show, reset, resolve_all, discard, commit] = target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
for (const finish of ['discard', 'commit']) {
for (const resolved of [true, false]) {
const mode = `${finish}, ${resolved ? 'resolved' : 'pending'}`;
try {
fork_a.click();
await tick();
if (resolved) {
resolve_all.click();
await tick();
}
assert.deepEqual(instance.log.splice(0), [1]);
show.click();
await tick();
assert.deepEqual(instance.log.splice(0), [0]);
if (finish === 'discard') discard.click();
else commit.click();
await settle();
const n = finish === 'commit' ? 1 : 0;
assert.htmlEqual(output.innerHTML, `<p>${n}|${n}</p>`);
assert.deepEqual(instance.log, []);
reset.click();
await settle();
assert.htmlEqual(output.innerHTML, '');
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

@ -0,0 +1,33 @@
<script>
import { fork } from 'svelte';
import Child from './Child.svelte';
let show = $state(false);
let n = $state(0);
export const log = [];
const registry = [];
let a;
function f(value) {
log.push(value);
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function set(values) {
if ('show' in values) show = values.show;
if ('n' in values) n = values.n;
}
function resolve_all() {
for (const r of registry.splice(0)) r();
}
</script>
<button onclick={() => (a = fork(() => set({ show: true, n: 1 })))}>fork</button>
<button onclick={() => set({ show: true })}>show</button>
<button onclick={() => set({ show: false, n: 0 })}>reset</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => a?.discard()}>discard</button>
<button onclick={() => a?.commit()}>commit</button>
<main>{#if show}<Child {f} {n} />{/if}</main>

@ -0,0 +1,53 @@
import { tick } from 'svelte';
import { test } from '../../test';
// The fallback of an each block, created by a fork, is adopted by a real batch: it has to run the
// fallback's async expression itself, since the fork's result only exists in the fork
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const [fork_empty, empty, reset, resolve_all, discard, commit] =
target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
for (const finish of ['discard', 'commit']) {
for (const resolved of [true, false]) {
const mode = `${finish}, ${resolved ? 'resolved' : 'pending'}`;
try {
fork_empty.click();
if (resolved) {
resolve_all.click();
await tick();
}
assert.deepEqual(instance.log.splice(0), ['empty']);
empty.click();
await tick();
assert.deepEqual(instance.log.splice(0), ['empty']);
if (finish === 'discard') discard.click();
else commit.click();
await settle();
assert.htmlEqual(output.innerHTML, '<p>empty</p>');
assert.deepEqual(instance.log, []);
reset.click();
await settle();
assert.htmlEqual(output.innerHTML, '<b>0</b>');
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

@ -0,0 +1,26 @@
<script>
import { fork } from 'svelte';
let list = $state([0]);
export const log = [];
const registry = [];
let current_fork;
function f(value) {
log.push(value);
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function resolve_all() {
for (const r of registry.splice(0)) r();
}
</script>
<button onclick={() => (current_fork = fork(() => (list = [])))}>fork empty</button>
<button onclick={() => (list = [])}>empty</button>
<button onclick={() => (list = [0])}>reset</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => current_fork?.discard()}>discard</button>
<button onclick={() => current_fork?.commit()}>commit</button>
<main>{#each list as x (x)}<b>{x}</b>{:else}<p>{await f('empty')}</p>{/each}</main>

@ -0,0 +1,128 @@
import { tick } from 'svelte';
import { test } from '../../test';
/** @param {number[]} list */
const html = (list) =>
list.map((x) => `<b>${x}|${x}|c</b>`).join('') + list.map((x) => `<s>${x}|${x}</s>`).join('');
// Items of keyed and unkeyed each blocks that were created by a fork (and whose async expressions
// therefore only have results in that fork) are adopted by a real batch. The real batch has to run
// their async expressions itself — with dependencies (unkeyed `x`) or without (keyed `x` is a plain
// value, `'c'` is a constant) — whether the fork is discarded or committed afterwards. When the
// fork is committed, its own results are used: nothing is fetched again.
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const [
reset_list,
fork_list,
real_list,
fork_append_2,
append_2,
append_5,
resolve_all,
discard,
commit
] = target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
const reset = async () => {
reset_list.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1]));
instance.log.length = 0;
};
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1]));
instance.log.length = 0;
for (const finish of ['discard', 'commit']) {
try {
// the fork creates items, the real batch reuses some of them (with equal and different values)
fork_list.click();
assert.deepEqual(
instance.log.sort(),
['cc', 'cc', 'k3', 'k2', 'u0', 'u1', 'u2', 'u3'].sort()
);
instance.log.length = 0;
real_list.click();
if (finish === 'discard') discard.click();
else commit.click();
await settle();
// the real write came last, so it wins either way
assert.htmlEqual(output.innerHTML, html([3, 0, 1]));
await reset();
// the fork's results are already in when the real batch adopts its items with the same values
fork_append_2.click();
resolve_all.click();
await tick();
assert.htmlEqual(output.innerHTML, html([0, 1]));
instance.log.length = 0;
append_2.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1, 2]));
// the fork's results only exist in the fork, so the real batch has to fetch them itself
assert.deepEqual(instance.log.sort(), ['cc', 'k2', 'u2']);
instance.log.length = 0;
if (finish === 'discard') discard.click();
else commit.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1, 2]));
assert.deepEqual(instance.log, []);
await reset();
// the fork is finished while the real batch that adopted its items is still pending
fork_append_2.click();
instance.log.length = 0;
append_2.click();
await tick();
assert.deepEqual(instance.log.sort(), ['cc', 'k2', 'u2']);
instance.log.length = 0;
if (finish === 'discard') discard.click();
else commit.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1, 2]));
assert.deepEqual(instance.log, []);
await reset();
// a real batch adopts an item with a different value after the fork's results are in
// (TODO also commit the fork here, once the fork re-running the unkeyed item's expression
// with the real world's new value no longer keeps the fork's outdated result)
if (finish === 'discard') {
fork_append_2.click();
resolve_all.click();
await tick();
instance.log.length = 0;
append_5.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1, 5]));
// keyed: a new item, unkeyed: the adopted item's input changed (and the fork revalidates it)
assert.deepEqual(instance.log.sort(), ['cc', 'k5', 'u5', 'u5']);
discard.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1, 5]));
await reset();
}
} catch (e) {
/** @type {Error} */ (e).message = `${finish}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
});

@ -0,0 +1,35 @@
<script>
import { fork } from 'svelte';
let list = $state([0, 1]);
export const log = [];
const registry = [];
let current_fork;
function f(tag, value) {
log.push(`${tag}${value}`);
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function resolve_all() {
for (const r of registry.splice(0)) r();
}
</script>
<button onclick={() => (list = [0, 1])}>reset</button>
<button onclick={() => (current_fork = fork(() => (list = [3, 2, 1, 0])))}>fork list</button>
<button onclick={() => (list = [3, 0, 1])}>real list</button>
<button onclick={() => (current_fork = fork(() => (list = [0, 1, 2])))}>fork append 2</button>
<button onclick={() => (list = [0, 1, 2])}>append 2</button>
<button onclick={() => (list = [0, 1, 5])}>append 5</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => current_fork?.discard()}>discard</button>
<button onclick={() => current_fork?.commit()}>commit</button>
<main>
<svelte:boundary>
{#each list as x (x)}<b>{x}|{await f('k', x)}|{await f('c', 'c')}</b>{/each}
{#each list as x}<s>{x}|{await f('u', x)}</s>{/each}
{#snippet pending()}{/snippet}
</svelte:boundary>
</main>

@ -0,0 +1,25 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
async test({ assert, target }) {
const [fork_list, real_list, resolve_all, discard] = target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
resolve_all.click();
await tick();
assert.htmlEqual(output.innerHTML, '<s>0|0</s><s>1|1</s>');
fork_list.click();
real_list.click();
discard.click();
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
assert.htmlEqual(output.innerHTML, '<s>3|3</s><s>0|0</s><s>1|1</s>');
}
});

@ -0,0 +1,25 @@
<script>
import { fork } from 'svelte';
let list = $state([0, 1]);
const registry = [];
let current_fork;
function f(value) {
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function resolve_all() { for (const r of registry.splice(0)) r(); }
</script>
<button onclick={() => (current_fork = fork(() => (list = [3, 2, 1, 0])))}>fork list</button>
<button onclick={() => (list = [3, 0, 1])}>real list</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => current_fork?.discard()}>discard</button>
<main>
<svelte:boundary>
{#each list as x}<s>{x}|{await f(x)}</s>{/each}
{#snippet pending()}{/snippet}
</svelte:boundary>
</main>

@ -0,0 +1,78 @@
import { tick } from 'svelte';
import { test } from '../../test';
/**
* @param {number[]} list
* @param {boolean} show
*/
const html = (list, show) =>
list.map((x) => `<div>${x}|${x}${show ? `<p>${x}</p>` : ''}</div>`).join('');
// A fork creates an each item, and branches inside of it (and inside an existing item) that contain
// async expressions. A real batch adopts the item first and the branches later, or both at once.
export default test({
mode: ['client'],
async test({ assert, target, instance }) {
const [fork_a, list_and_show, list, show, reset, resolve_all, discard, commit] =
target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
await settle();
assert.htmlEqual(output.innerHTML, html([0], false));
instance.log.length = 0;
for (const together of [false, true]) {
for (const finish of ['discard', 'commit']) {
const mode = `${together ? 'together' : 'separately'}, ${finish}`;
try {
fork_a.click();
resolve_all.click();
await tick();
assert.deepEqual(instance.log.splice(0).sort(), ['p0', 'p1', 'x1']);
if (!together) {
// the item is adopted, the branch inside it stays the fork's
list.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1], false));
assert.deepEqual(instance.log.splice(0), ['x1']);
show.click();
} else {
list_and_show.click();
}
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1], true));
assert.deepEqual(
instance.log.splice(0).sort(),
together ? ['p0', 'p1', 'x1'] : ['p0', 'p1']
);
if (finish === 'discard') discard.click();
else commit.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0, 1], true));
assert.deepEqual(instance.log, []);
reset.click();
await settle();
assert.htmlEqual(output.innerHTML, html([0], false));
instance.log.length = 0;
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

@ -0,0 +1,41 @@
<script>
import { fork } from 'svelte';
let list = $state([0]);
let show = $state(false);
export const log = [];
const registry = [];
let a;
function f(tag, value) {
log.push(`${tag}${value}`);
return new Promise((resolve) => registry.push(() => resolve(value)));
}
function set(values) {
if ('list' in values) list = values.list;
if ('show' in values) show = values.show;
}
function resolve_all() {
for (const r of registry.splice(0)) r();
}
</script>
<button onclick={() => (a = fork(() => set({ list: [0, 1], show: true })))}>fork</button>
<button onclick={() => set({ list: [0, 1], show: true })}>list and show</button>
<button onclick={() => set({ list: [0, 1] })}>list</button>
<button onclick={() => set({ show: true })}>show</button>
<button onclick={() => set({ list: [0], show: false })}>reset</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => a?.discard()}>discard</button>
<button onclick={() => a?.commit()}>commit</button>
<main>
<svelte:boundary>
{#each list as x (x)}
<div>{x}|{await f('x', x)}{#if show}<p>{await f('p', x)}</p>{/if}</div>
{/each}
{#snippet pending()}{/snippet}
</svelte:boundary>
</main>
Loading…
Cancel
Save