pull/18726/merge
Simon H 2 days ago committed by GitHub
commit 0454b356db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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;
@ -302,6 +312,15 @@ export class Batch {
*/ */
is_eager = false; is_eager = false;
/**
* `true` once this batch has committed (some of) its UI from that point on
* it can no longer entangle with other pending batches on new reads, because
* what is on screen was rendered with this batch's own world (a batch can
* stay live after committing, e.g. while a boundary shows its pending
* snippet until the async work inside it settles)
*/
committed = false;
/** /**
* If this batch was merged into another one (because their reactivity graphs * If this batch was merged into another one (because their reactivity graphs
* turned out to overlap), this points to the batch it was merged into. Stale * turned out to overlap), this points to the batch it was merged into. Stale
@ -321,16 +340,6 @@ export class Batch {
*/ */
fork_effects = null; fork_effects = null;
/**
* Reactions that observed the pre-write world of this batch via its active
* overlay while it was pending, mapped to the values they saw. When this
* batch commits, readers whose observed values differ from the committed
* ones re-run with the real values.
* Lazily initialised for perf reasons
* @type {Map<Reaction, Map<Value, any>> | null}
*/
stale_readers = null;
/** /**
* `true` while this batch is flushing its effects and is provably terminal * `true` while this batch is flushing its effects and is provably terminal
* solitary, with no pending async work and nothing scheduled. Such a batch * solitary, with no pending async work and nothing scheduled. Such a batch
@ -426,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);
@ -711,14 +732,6 @@ export class Batch {
this.#scheduled.push(...other.#scheduled); this.#scheduled.push(...other.#scheduled);
other.#scheduled = []; other.#scheduled = [];
// TODO could a newer value have been observed by this and other is older?
this.stale_readers = transfer_map(
this.stale_readers,
other.stale_readers,
(observed, seen) => /** @type {Map<Value, any>} */ (transfer_map(observed, seen))
);
other.stale_readers = null;
if (other.waiting !== null) { if (other.waiting !== null) {
var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() }); var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() });
@ -732,6 +745,7 @@ export class Batch {
} }
this.restarts = Math.max(this.restarts, other.restarts); this.restarts = Math.max(this.restarts, other.restarts);
this.committed ||= other.committed;
// `other`'s settled() promise resolves when this batch settles // `other`'s settled() promise resolves when this batch settles
if (other.#deferred !== null) { if (other.#deferred !== null) {
@ -922,6 +936,10 @@ export class Batch {
this.#dirty_effects = null; this.#dirty_effects = null;
this.#maybe_dirty_effects = null; this.#maybe_dirty_effects = null;
// this batch's UI is about to hit the DOM — new reads can no longer
// entangle it with other pending batches
this.committed = true;
// append/remove branches // append/remove branches
if (this.#commit_callbacks !== null) { if (this.#commit_callbacks !== null) {
for (const fn of this.#commit_callbacks) fn(this); for (const fn of this.#commit_callbacks) fn(this);
@ -987,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;
@ -997,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);
} }
@ -1193,51 +1242,6 @@ export class Batch {
}); });
} }
} }
if (this.stale_readers === null) return;
var readers = this.stale_readers;
this.stale_readers = null;
var batch = Batch.ensure();
for (const [reader, seen] of readers) {
var flags = reader.f;
if ((flags & (DESTROYED | INERT | DIRTY)) !== 0) continue;
// Only re-run readers that are actually affected by the commit: a
// reader observed specific values through this batch's overlay. If
// each of those matches the committed value (the write was reverted,
// or a derived recomputed to an equal value), or the reader no
// longer depends on it, the reader's world didn't change
var status = CLEAN;
for (const [signal, value] of seen) {
if (reader.deps === null || !includes.call(reader.deps, signal)) continue;
if ((signal.f & (DIRTY | MAYBE_DIRTY)) !== 0) {
// a derived that hasn't been revalidated with the committed
// values yet — the reader's own validation will recompute it
// (with equality applying) via `is_dirty`
status = MAYBE_DIRTY;
} else if (signal.v !== value) {
status = DIRTY;
break;
}
}
if (status === CLEAN) continue;
set_signal_status(reader, status);
if ((flags & DERIVED) !== 0) {
// invalidate anything that depends on the derived
mark_reactions(/** @type {Derived} */ (reader), MAYBE_DIRTY, null);
} else {
batch.schedule(/** @type {Effect} */ (reader));
}
}
} }
/** /**
@ -1674,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;
} }
} }
@ -1735,10 +1748,8 @@ function mark_committed_reactions(value, batch, marked, status) {
var owner = effect.batch && effect.batch.resolved(); var owner = effect.batch && effect.batch.resolved();
var superseded = batch.fork_effects?.get(effect); var superseded = batch.fork_effects?.get(effect);
var stale =
batch.stale_readers?.has(effect) === true || owner?.stale_readers?.has(effect) === true;
if (superseded === undefined || stale) { if (superseded === undefined) {
if ((reaction.f & DIRTY) === 0) { if ((reaction.f & DIRTY) === 0) {
set_signal_status(reaction, status); set_signal_status(reaction, status);
} }
@ -1793,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) {
@ -1801,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) {

@ -50,6 +50,7 @@ import {
active_batch, active_batch,
Batch, Batch,
claimed_by_other, claimed_by_other,
collected_effects,
current_batch, current_batch,
flushSync, flushSync,
previous_batch, previous_batch,
@ -571,6 +572,14 @@ export function get(signal) {
var flags = signal.f; var flags = signal.f;
var is_derived = (flags & DERIVED) !== 0; var is_derived = (flags & DERIVED) !== 0;
/**
* Whether a read outside the init/update cycle (i.e. after an `await`) added
* `signal` to the reaction's deps for the first time. During the init/update
* cycle this stays `false` first-time reads are detected by checking
* `deps` instead (new deps accumulate in `new_deps` in that case)
*/
var first_read = false;
captured_signals?.add(signal); captured_signals?.add(signal);
// Register the dependency on the current reaction signal. // Register the dependency on the current reaction signal.
@ -608,6 +617,7 @@ export function get(signal) {
active_reaction.deps ??= []; active_reaction.deps ??= [];
if (!includes.call(active_reaction.deps, signal)) { if (!includes.call(active_reaction.deps, signal)) {
active_reaction.deps.push(signal); active_reaction.deps.push(signal);
first_read = true;
} }
var reactions = signal.reactions; var reactions = signal.reactions;
@ -708,7 +718,6 @@ export function get(signal) {
// have their status reset (the owning batch relies on both), and their // have their status reset (the owning batch relies on both), and their
// value in this world follows from the active overlay // value in this world follows from the active overlay
/** @type {Batch | null} */ /** @type {Batch | null} */
// eslint-disable-next-line no-useless-assignment
var owner = null; var owner = null;
if ( if (
@ -716,13 +725,20 @@ export function get(signal) {
active_batch.values !== null && active_batch.values !== null &&
(owner = claimed_by_other(derived)) !== null (owner = claimed_by_other(derived)) !== null
) { ) {
if (is_unseen_read(derived, first_read) && entangle(derived)) {
// a read with no history can entangle the two batches instead, so
// that they commit together — the derived is now part of this
// batch's world and behaves normally (below)
owner = null;
} else if (!active_batch.values.has(derived)) {
// the world-local value is memoized in the active overlay (and invalidated // the world-local value is memoized in the active overlay (and invalidated
// there when dependencies change). Reads are registered with the owner // there when dependencies change). Reads are registered with the owner
// batch — when it commits, the reader re-runs with the real values // batch — when it commits, the reader re-runs with the real values
if (!active_batch.values.has(derived)) {
active_batch.values.set(derived, [execute_derived(derived), owner]); active_batch.values.set(derived, [execute_derived(derived), owner]);
} }
} else { }
if (owner === null) {
// connect disconnected deriveds if we are reading them inside an effect, // connect disconnected deriveds if we are reading them inside an effect,
// or inside another derived that is already connected // or inside another derived that is already connected
var should_connect = var should_connect =
@ -759,17 +775,53 @@ export function get(signal) {
// we saw turns out to differ from the committed one) // we saw turns out to differ from the committed one)
var override_owner = override[1]; var override_owner = override[1];
if (override_owner !== null && active_reaction !== null && !untracking) { if (override_owner !== null) {
override_owner = override_owner.resolved(); if (active_reaction === null) {
// reads outside a reaction during a flush happen in one-shot init
// code (e.g. a component initialising inside a newly-created
// branch). They have no dependency history and no re-run
// mechanism — entangle the batches if possible, so that both
// worlds commit together, and read the latest value
if ((signal.f & DERIVED) === 0) {
entangle(signal);
var readers = (override_owner.stale_readers ??= new Map()); if ((signal.f & ERROR_VALUE) !== 0) {
var seen = readers.get(active_reaction); throw signal.v;
}
if (seen === undefined) { return signal.v;
readers.set(active_reaction, (seen = new Map()));
} }
} else if (!untracking) {
var override_value = override[0];
seen.set(signal, override[0]); if (
(signal.f & DERIVED) === 0 &&
((active_reaction.f & REACTION_IS_UPDATING) !== 0
? active_reaction.deps === null || !includes.call(active_reaction.deps, signal)
: first_read)
) {
// the reaction never depended on this signal before the owner's write —
// the pre-write world never contained this combination of values.
// Entangle the batches if possible, so that both worlds commit
// together and this value is simply the batch's own write...
var entangled = entangle(signal);
if ((signal.f & ERROR_VALUE) !== 0) {
throw signal.v;
}
if (entangled) {
return signal.v;
}
// ...otherwise, read the latest value — the owner batch will
// re-run us when it commits, if the value we saw turns out
// to differ from the committed one
override_value = signal.v;
}
return override_value;
}
} }
return override[0]; return override[0];
@ -783,6 +835,52 @@ export function get(signal) {
return signal.v; return signal.v;
} }
/**
* Whether the current read of `signal` which is owned by another live batch
* has no history: the reader neither depended on the signal in a previous run,
* nor observed a value for it while the owner batch was pending. (Reads outside
* a reaction never have history.)
* @param {Value} signal
* @param {boolean} first_read whether a post-`await` read just added `signal` to the reaction's deps
* @returns {boolean}
*/
function is_unseen_read(signal, first_read) {
if (active_reaction === null) return true;
if (untracking) return false;
if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) {
if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) {
return false;
}
} else if (!first_read) {
return false;
}
return true;
}
/**
* Attempt to entangle the active batch with the batch that owns `signal`,
* merging their worlds so that both commit together. This is only possible
* while none of the batch's UI has been committed during effect tree
* traversal, or in an async continuation of a still-pending batch. Returns
* true if the signal now belongs to the active batch's own world.
* @param {Value} signal
* @returns {boolean} Returns false if the signal was not merged because it has to wait on another batch to commit first
*/
function entangle(signal) {
var batch = /** @type {Batch} */ (active_batch).resolved();
if (collected_effects === null && batch.committed) {
// too late — the batch's UI is (at least partially) committed already.
// Readers fall back to observing the latest value, and are re-run when
// the owner commits (if the value they saw turns out to be stale)
return false;
}
return !batch.claim(signal);
}
/** /**
* (Re)connect a disconnected derived, so that it is notified * (Re)connect a disconnected derived, so that it is notified
* of changes in `mark_reactions` * of changes in `mark_reactions`

@ -17,17 +17,17 @@ export default test({
await tick(); await tick();
assert.deepEqual(logs, []); assert.deepEqual(logs, []);
// an independent batch runs the effect, which reads `x` through the // an independent batch runs the effect, which newly depends on `x` —
// pending batch's overlay (seeing the held-back value 0) // it reads the latest value (1) rather than the held-back one (0)
y.click(); y.click();
await tick(); await tick();
assert.deepEqual(logs, ['effect 0 1']); assert.deepEqual(logs, ['effect 1 1']);
// the pending batch settles and commits x === 1 — the effect saw a // the pending batch settles and commits x === 1 — exactly the value
// stale value and must re-run with the real one // the effect already saw, so it should not re-run
shift.click(); shift.click();
await tick(); await tick();
assert.deepEqual(logs, ['effect 0 1', 'effect 1 1']); assert.deepEqual(logs, ['effect 1 1']);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
'<p>1</p><button>x</button><button>y</button><button>shift</button>' '<p>1</p><button>x</button><button>y</button><button>shift</button>'

@ -13,22 +13,22 @@ export default test({
await tick(); await tick();
assert.deepEqual(logs, ['effect _ 0']); assert.deepEqual(logs, ['effect _ 0']);
// the effect reads `x` through the pending batch's overlay // the effect newly depends on `x` — it reads the latest value (1)
// (seeing the held-back value 0) // rather than the held-back one (0)
y.click(); y.click();
await tick(); await tick();
assert.deepEqual(logs, ['effect _ 0', 'effect 0 1']); assert.deepEqual(logs, ['effect _ 0', 'effect 1 1']);
// the effect re-runs and no longer depends on `x` at all // the effect re-runs and no longer depends on `x` at all
y.click(); y.click();
await tick(); await tick();
assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); assert.deepEqual(logs, ['effect _ 0', 'effect 1 1', 'effect _ 2']);
// the pending batch settles and commits x = 1 — the effect no longer // the pending batch settles and commits x = 1 — the effect no longer
// depends on `x`, so it should not re-run // depends on `x`, so it should not re-run
shift.click(); shift.click();
await tick(); await tick();
assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); assert.deepEqual(logs, ['effect _ 0', 'effect 1 1', 'effect _ 2']);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
'<p>1</p><button>x</button><button>y</button><button>shift</button>' '<p>1</p><button>x</button><button>y</button><button>shift</button>'

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

@ -0,0 +1,61 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>up</button>
<button>down</button>
<button>show1</button>
<button>show2</button>
<button>shift a</button>
<button>shift t</button>
`;
export default test({
async test({ assert, target }) {
await tick();
const [up, down, show1, show2, shift_a, shift_t] = target.querySelectorAll('button');
// batch B: reveals boundary 1 -> commits with pending snippet, stays live
show1.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>loading 1...</p>`);
// batch C: reveals boundary 2 -> commits with pending snippet, stays live
show2.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>loading 1...</p> <p>loading 2...</p>`);
// batch A: writes a=1; the async-a effect is owned by C, so A merges with
// C and stays pending. B remains separate
up.click();
await tick();
// B's continuation first-reads `a`, which is overlaid by the pending
// merged batch. B has already committed its UI, so it cannot entangle —
// it reads the latest value (1) instead
shift_t.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>late read: 1</p> <p>loading 2...</p>`);
// revert `a` to 0 inside the pending batch — its eventual commit leaves
// `a` unchanged. The write re-runs the late reader (it acquired `a` as a
// dependency), so it re-awaits a fresh deferred
down.click();
await tick();
// resolve the pending batch's async-a runs -> it commits
shift_a.click();
await tick();
shift_a.click();
await tick();
shift_a.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>late read: 1</p> <p>async a: 0</p>`);
// resolve the late reader's re-run -> it converges on the committed value
shift_t.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>late read: 0</p> <p>async a: 0</p>`);
}
});

@ -0,0 +1,49 @@
<script>
let a = $state(0);
let show1 = $state(false);
let show2 = $state(false);
let deferreds = [];
function push(key, v) {
const d = Promise.withResolvers();
deferreds.push({ key, v, d });
return d.promise;
}
function shift(key, override) {
const i = deferreds.findIndex((d) => d.key === key);
if (i === -1) return;
const [{ v, d }] = deferreds.splice(i, 1);
d.resolve(override ?? v);
}
</script>
<button onclick={() => a++}>up</button>
<button onclick={() => a--}>down</button>
<button onclick={() => (show1 = true)}>show1</button>
<button onclick={() => (show2 = true)}>show2</button>
<button onclick={() => shift('a')}>shift a</button>
<button onclick={() => shift('t', 1)}>shift t</button>
{#if show1}
<svelte:boundary>
<!-- no state deps initially; reads `a` for the first time only when
the awaited value (controlled by the test) says so -->
<p>late read: {(await push('t', 0)) > 0 ? a : -1}</p>
{#snippet pending()}
<p>loading 1...</p>
{/snippet}
</svelte:boundary>
{/if}
{#if show2}
<svelte:boundary>
<p>async a: {await push('a', a)}</p>
{#snippet pending()}
<p>loading 2...</p>
{/snippet}
</svelte:boundary>
{/if}

@ -10,26 +10,24 @@ export default test({
y.click(); y.click();
await tick(); await tick();
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<button>x</button> <button>x</button>
<button>y++</button> <button>y++</button>
<button>resolve</button> <button>resolve</button>
world `
` // if this does not show world - that would also be ok
); );
resolve.click(); resolve.click();
await tick(); await tick();
assert.deepEqual(logs, [ // both branches commit together, fully consistent. The init-time
'universe', // console.logs ran eagerly (with the latest value), the $effects
'world', // were deferred until the commit
'$effect: world', assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
'$effect: universe',
'$effect: universe'
]);
// assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -10,6 +10,9 @@ export default test({
y.click(); y.click();
await tick(); await tick();
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -17,17 +20,12 @@ export default test({
<button>y++</button> <button>y++</button>
<button>resolve</button> <button>resolve</button>
<hr> <hr>
world `
"world"
world
world
world
"world"
` // if this does not show world "world" world world world "world" - then this would also be ok
); );
resolve.click(); resolve.click();
await tick(); await tick();
// both branches commit together, fully consistent
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -22,6 +22,10 @@ export default test({
resolve.click(); resolve.click();
await tick(); await tick();
// the new branch's async expression read `x`, which the pending batch had
// written, as a new dependency — the two batches entangled, so even though
// the new branch's own async work has completed, it is held back until the
// first branch's async work completes too
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -29,19 +33,12 @@ export default test({
<button>y++</button> <button>y++</button>
<button>resolve</button> <button>resolve</button>
<hr> <hr>
world `
"world"
world
world
world
"world"
` // if this does not show world "world" world world world "world" - then this would also be ok
); );
resolve.click(); resolve.click();
await tick(); await tick();
resolve.click(); // both branches commit together, fully consistent
await tick();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -0,0 +1,7 @@
<script>
let { x } = $props();
console.log(x.x);
$effect(() => console.log('$effect: '+ x.x))
</script>
{x.x}

@ -0,0 +1,44 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [x, y, resolve] = target.querySelectorAll('button');
x.click();
await tick();
assert.deepEqual(logs, ['universe']);
y.click();
await tick();
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes. Its $effect is deferred, but the
// init-time console.log necessarily runs eagerly (with the latest value)
assert.deepEqual(logs, ['universe', 'universe']);
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
`
);
resolve.click();
await tick();
// both branches commit together, fully consistent
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
universe
universe
universe
`
);
}
});

@ -0,0 +1,28 @@
<script>
import Child from './Child.svelte';
let x = $state();
let y = $state(0);
let deferred = [];
function delay(s) {
const d = Promise.withResolvers();
deferred.push(() => d.resolve(s))
return d.promise;
}
</script>
<button onclick={() => x = {x:'universe'}}>x</button>
<button onclick={() => y++}>y++</button>
<button onclick={() => deferred.shift()()}>resolve</button>
{#if x?.x === 'universe'}
{await delay(x.x)}
<Child {x} />
{/if}
{#if y > 0}
<Child {x} />
{/if}

@ -0,0 +1,51 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
x.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>WORLD</h1>
`
);
y.click();
await tick();
// the new branch reads `upper` — a derived owned by the pending batch — as
// a new dependency. The two batches entangle, so the new branch is held
// back until the async work completes (rather than rendering with the
// derived's pre-write value, 'WORLD')
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>WORLD</h1>
`
);
resolve.click();
await tick();
// both branches commit together, fully consistent
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>UNIVERSE</h1>
universe
<p>UNIVERSE</p>
`
);
}
});

@ -0,0 +1,29 @@
<script>
let x = $state({ x: 'world' });
let y = $state(0);
let deferred = [];
const upper = $derived(x.x.toUpperCase());
function delay(s) {
const d = Promise.withResolvers();
deferred.push(() => d.resolve(s));
return d.promise;
}
</script>
<button onclick={() => (x = { x: 'universe' })}>x</button>
<button onclick={() => y++}>y++</button>
<button onclick={() => deferred.shift()()}>resolve</button>
<h1>{upper}</h1>
{#if x.x === 'universe'}
{await delay(x.x)}
{/if}
{#if y > 0}
<p>{upper}</p>
{/if}

@ -11,26 +11,24 @@ export default test({
y.click(); y.click();
await tick(); await tick();
assert.deepEqual(logs, ['universe', 'world', '$effect: world']); // the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes. Its $effect is deferred, but the
// init-time console.log necessarily runs eagerly (with the latest value)
assert.deepEqual(logs, ['universe', 'universe']);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<button>x</button> <button>x</button>
<button>y++</button> <button>y++</button>
<button>resolve</button> <button>resolve</button>
world
` `
); );
resolve.click(); resolve.click();
await tick(); await tick();
assert.deepEqual(logs, [ // both branches commit together, fully consistent
'universe', assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
'world',
'$effect: world',
'$effect: universe',
'$effect: universe'
]);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -0,0 +1,63 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [a, t, shift_a, shift_t] = target.querySelectorAll('button');
shift_a.click();
shift_t.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 0</p>
<p>late read: -1</p>
`
);
// batch A: writes `a`, stays pending (its promise is unresolved)
a.click();
await tick();
// batch B: writes `t`; resolve its promise so the continuation
// reads `a` for the first time while A is still pending. This
// entangles B with A — both worlds are held back and commit together
t.click();
await tick();
shift_t.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 0</p>
<p>late read: -1</p>
`
);
// commit the merged batch
shift_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 1</p>
<p>late read: 1</p>
`
);
}
});

@ -0,0 +1,35 @@
<script>
let a = $state(0);
let t = $state(0);
let deferreds = [];
function push(key, v) {
const d = Promise.withResolvers();
deferreds.push({ key, v, d });
return d.promise;
}
function shift(key) {
const i = deferreds.findIndex((d) => d.key === key);
if (i === -1) return;
const [{ v, d }] = deferreds.splice(i, 1);
d.resolve(v);
}
</script>
<button onclick={() => a++}>a</button>
<button onclick={() => t++}>t</button>
<button onclick={() => shift('a')}>shift a</button>
<button onclick={() => shift('t')}>shift t</button>
<svelte:boundary>
<p>async a: {await push('a', a)}</p>
<!-- reads `a` after an await, but only once t > 0 -->
<p>late read: {(await push('t', t), t > 0 ? a : -1)}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [update, show, resolve] = target.querySelectorAll('button');
update.click();
await tick();
show.click();
await tick();
// the template effect newly depends on `value`, which the pending batch
// has written — it reads the latest value rather than the pre-write one
assert.htmlEqual(
target.innerHTML,
`
<button>update</button>
<button>show</button>
<button>resolve</button>
<p>1</p>
`
);
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>update</button>
<button>show</button>
<button>resolve</button>
<p>1</p>
`
);
}
});

@ -0,0 +1,17 @@
<script>
let value = $state();
let show = $state(false);
const deferred = Promise.withResolvers();
function wait(value) {
return value === undefined ? '' : deferred.promise;
}
</script>
<button onclick={() => (value = { x: 1 })}>update</button>
<button onclick={() => (show = true)}>show</button>
<button onclick={() => deferred.resolve('')}>resolve</button>
{await wait(value)}
<p>{show ? value.x : ''}</p>
Loading…
Cancel
Save