From 18219ad1ff1a7bd197bb5c3ba3ca93c3f72a4249 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 7 Oct 2026 15:00:52 +0200 Subject: [PATCH] make each blocks fork-aware + fixes around flatten/run seing discarded forks --- .../src/internal/client/dom/blocks/each.js | 63 +++++++-- .../src/internal/client/reactivity/async.js | 63 +++++++-- .../src/internal/client/reactivity/batch.js | 8 +- .../internal/client/reactivity/deriveds.js | 27 ++-- .../svelte/src/internal/client/runtime.js | 2 +- .../_config.js | 70 ++++++++++ .../main.svelte | 53 ++++++++ .../_config.js | 33 +++++ .../main.svelte | 23 ++++ .../Child.svelte | 6 + .../_config.js | 53 ++++++++ .../main.svelte | 33 +++++ .../_config.js | 53 ++++++++ .../main.svelte | 26 ++++ .../_config.js | 128 ++++++++++++++++++ .../main.svelte | 35 +++++ .../async-fork-each-item-adopted/_config.js | 25 ++++ .../async-fork-each-item-adopted/main.svelte | 25 ++++ .../async-fork-nested-adopted/_config.js | 78 +++++++++++ .../async-fork-nested-adopted/main.svelte | 41 ++++++ 20 files changed, 817 insertions(+), 28 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 58d59a6825..35b6430b60 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -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); } } diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 3548e52fcd..5e9374dd9d 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 } 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>} thunks */ export function run(thunks) { - const restore = capture(); + const restore = capture(true); const decrement_pending = increment_pending(); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 6a34e22c82..792f84ad88 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -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); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 9a8952f168..c5b659221e 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -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} 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 { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index b3c3b0d63d..85883e0076 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -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); } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/_config.js new file mode 100644 index 0000000000..c2f3226c76 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/_config.js @@ -0,0 +1,70 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +/** @param {number} n */ +const html = (n) => `

c

${n}|${n}
`; + +// 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; + } + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/main.svelte new file mode 100644 index 0000000000..5032be21f7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted-variants/main.svelte @@ -0,0 +1,53 @@ + + + + + + + + + + + +
+ {#if show}

{await f('c', 'c')}

{/if} + {#if show}
{n}|{await f('n', n)}
{/if} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/_config.js new file mode 100644 index 0000000000..c8005a171e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/_config.js @@ -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, '
s
'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/main.svelte new file mode 100644 index 0000000000..0beb8d21c3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-async-adopted/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + + +
{#if show}
{await f('s')}
{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/Child.svelte new file mode 100644 index 0000000000..89caea20f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/Child.svelte @@ -0,0 +1,6 @@ + + +

{n}|{x}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/_config.js new file mode 100644 index 0000000000..9ee9dcfd69 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/_config.js @@ -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, `

${n}|${n}

`); + 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; + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/main.svelte new file mode 100644 index 0000000000..549b62e4c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-component-adopted/main.svelte @@ -0,0 +1,33 @@ + + + + + + + + + +
{#if show}{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js new file mode 100644 index 0000000000..077d84d50d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js @@ -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, '

empty

'); + assert.deepEqual(instance.log, []); + + reset.click(); + await settle(); + assert.htmlEqual(output.innerHTML, '0'); + } catch (e) { + /** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`; + throw e; + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte new file mode 100644 index 0000000000..8f5bb44965 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte @@ -0,0 +1,26 @@ + + + + + + + + + +
{#each list as x (x)}{x}{:else}

{await f('empty')}

{/each}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/_config.js new file mode 100644 index 0000000000..dafc2da84c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/_config.js @@ -0,0 +1,128 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +/** @param {number[]} list */ +const html = (list) => + list.map((x) => `${x}|${x}|c`).join('') + list.map((x) => `${x}|${x}`).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; + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/main.svelte new file mode 100644 index 0000000000..4d2624e81d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted-variants/main.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + + + +
+ + {#each list as x (x)}{x}|{await f('k', x)}|{await f('c', 'c')}{/each} + {#each list as x}{x}|{await f('u', x)}{/each} + {#snippet pending()}{/snippet} + +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/_config.js new file mode 100644 index 0000000000..61e5b61091 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/_config.js @@ -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, '0|01|1'); + + 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, '3|30|01|1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/main.svelte new file mode 100644 index 0000000000..baebf49fef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-item-adopted/main.svelte @@ -0,0 +1,25 @@ + + + + + + + +
+ + {#each list as x}{x}|{await f(x)}{/each} + {#snippet pending()}{/snippet} + +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/_config.js new file mode 100644 index 0000000000..1dad49b1c0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/_config.js @@ -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) => `
${x}|${x}${show ? `

${x}

` : ''}
`).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; + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/main.svelte new file mode 100644 index 0000000000..1194f1ac6b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-adopted/main.svelte @@ -0,0 +1,41 @@ + + + + + + + + + + + +
+ + {#each list as x (x)} +
{x}|{await f('x', x)}{#if show}

{await f('p', x)}

{/if}
+ {/each} + {#snippet pending()}{/snippet} +
+