fix: robustify async reactivity system (pt2)

async-another-try-pt-2
Simon Holthausen 22 hours ago
parent e19a7530da
commit 1cdc1709d4
No known key found for this signature in database

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

@ -1,4 +1,4 @@
/** @import { Derived, Reaction, Value } from '#client' */ /** @import { Derived, Reaction, Source, Value } from '#client' */
import { UNINITIALIZED } from '../../../constants.js'; import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js'; import { snapshot } from '../../shared/clone.js';
import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';

@ -7,7 +7,7 @@ import {
pause_effect, pause_effect,
resume_effect resume_effect
} from '../../reactivity/effects.js'; } from '../../reactivity/effects.js';
import { HMR_ANCHOR } from '../../constants.js'; import { FORK_ONLY_BRANCH, HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js'; import { 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';
@ -188,6 +188,7 @@ 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 = should_defer_append();
var first = false;
// Re-evaluating in the surviving batch supersedes selections made before a merge, // Re-evaluating in the surviving batch supersedes selections made before a merge,
// even though those batches originally had newer IDs and still have commit callbacks. // even though those batches originally had newer IDs and still have commit callbacks.
@ -201,16 +202,23 @@ export class BranchManager {
} }
if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) {
first = true;
if (defer) { if (defer) {
var fragment = document.createDocumentFragment(); var fragment = document.createDocumentFragment();
var target = create_text(); var target = create_text();
fragment.append(target); fragment.append(target);
const b = branch(() => fn(target));
this.#offscreen.set(key, { this.#offscreen.set(key, {
effect: branch(() => fn(target)), effect: b,
fragment fragment
}); });
if (batch.is_fork) {
b.f ^= FORK_ONLY_BRANCH;
}
} else { } else {
this.#onscreen.set( this.#onscreen.set(
key, key,
@ -221,6 +229,15 @@ export class BranchManager {
this.#batches.set(batch, key); this.#batches.set(batch, key);
const offscreen = this.#offscreen.get(key);
if (offscreen && offscreen.effect.f & FORK_ONLY_BRANCH) {
if (batch.is_fork) {
batch.unskip_effect(offscreen.effect, !first);
} else {
offscreen.effect.f ^= FORK_ONLY_BRANCH;
}
}
if (defer) { if (defer) {
for (const [k, effect] of this.#onscreen) { for (const [k, effect] of this.#onscreen) {
if (k === key) { if (k === key) {

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

@ -17,7 +17,8 @@ import {
ERROR_VALUE, ERROR_VALUE,
MANAGED_EFFECT, MANAGED_EFFECT,
REACTION_RAN, REACTION_RAN,
DESTROYING DESTROYING,
FORK_ONLY_BRANCH
} from '#client/constants'; } from '#client/constants';
import { async_mode_flag } from '../../flags/index.js'; import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property } from '../../shared/utils.js'; import { deferred, define_property } from '../../shared/utils.js';
@ -26,7 +27,8 @@ import {
get, get,
increment_write_version, increment_write_version,
is_dirty, is_dirty,
update_effect update_effect,
write_version
} from '../runtime.js'; } from '../runtime.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { flush_tasks, queue_micro_task } from '../dom/task.js';
@ -158,6 +160,9 @@ export class Batch {
return (this.#stale_readers ??= new Set()); return (this.#stale_readers ??= new Set());
} }
/** @type {Set<Effect>} */
seen_effects = new Set();
/** @type {Batch | null} */ /** @type {Batch | null} */
prev = null; prev = null;
@ -275,17 +280,18 @@ export class Batch {
#skipped_branches = new Map(); #skipped_branches = new Map();
/** /**
* @type {Set<Effect> | null} * @type {Map<Effect, boolean> | null}
*/ */
#unskipped_branches = null; #unskipped_branches = null;
/** /**
* Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing. * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing.
* `true` indicates that this branch is new to the eyes of this fork but was already created before.
* Lazily initialized for performance reasons. * Lazily initialized for performance reasons.
* @type {Set<Effect>} * @type {Map<Effect, boolean>}
*/ */
get unskipped_branches() { get unskipped_branches() {
return (this.#unskipped_branches ??= new Set()); return (this.#unskipped_branches ??= new Map());
} }
is_fork = false; is_fork = false;
@ -295,15 +301,17 @@ export class Batch {
#decrement_queued = false; #decrement_queued = false;
constructor() { constructor() {
if (last_batch === null) { // Put the new batch before the first forked batch
first_batch = last_batch = this; let batch = first_batch;
} else { while (batch && !batch.is_fork) {
last_batch.next = this; batch = batch.next;
this.prev = last_batch;
} }
last_batch = this; this.insert_before(batch);
this.linked = true; while (batch) {
batch.id = uid++;
batch = batch.next;
}
} }
#is_deferred() { #is_deferred() {
@ -346,8 +354,9 @@ export class Batch {
* Remove an effect from the #skipped_branches map and reschedule * Remove an effect from the #skipped_branches map and reschedule
* any tracked dirty/maybe_dirty child effects * any tracked dirty/maybe_dirty child effects
* @param {Effect} effect * @param {Effect} effect
* @param {boolean} is_fork_init
*/ */
unskip_effect(effect) { unskip_effect(effect, is_fork_init = false) {
var tracked = this.#skipped_branches.get(effect); var tracked = this.#skipped_branches.get(effect);
if (tracked) { if (tracked) {
this.#skipped_branches.delete(effect); this.#skipped_branches.delete(effect);
@ -362,7 +371,7 @@ export class Batch {
this.schedule(e); this.schedule(e);
} }
} }
this.unskipped_branches.add(effect); if (!this.unskipped_branches.has(effect)) this.unskipped_branches.set(effect, is_fork_init);
} }
/** /**
@ -569,14 +578,42 @@ export class Batch {
root.f ^= CLEAN; root.f ^= CLEAN;
var effect = root.first; var effect = root.first;
var all_dirty = null;
while (effect !== null) { while (effect !== null) {
if (all_dirty) {
if (effect.f & CLEAN) effect.f ^= CLEAN;
if ((effect.f & DIRTY) === 0) effect.f |= MAYBE_DIRTY;
}
var flags = effect.f; var flags = effect.f;
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect); var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
if ((flags & FORK_ONLY_BRANCH) !== 0) {
var first_time = this.unskipped_branches.get(effect);
if (first_time === undefined) {
skip = true;
this.skip_effect(effect);
reset_branch(
effect,
/** @type {{d: Effect[], m: Effect[]}} */ (this.#skipped_branches.get(effect))
);
} else if (first_time) {
// We're seeing a fork-only branch for the first time in another fork. We need to traverse
// all effects inside it (they're all marked MAYBE_DIRTY). This is necessary because
// dependencies of the effects inside could've updated in the real world since the last time this branch ran.
// TODO this can overfire, maybe there's a way to detect which sources actually changed.
this.unskipped_branches.set(effect, false);
all_dirty ??= effect;
if (effect.f & CLEAN) effect.f ^= CLEAN;
skip = false;
}
}
if (!skip && effect.fn !== null) { if (!skip && effect.fn !== null) {
if (is_branch) { if (is_branch) {
effect.f ^= CLEAN; effect.f ^= CLEAN;
@ -584,8 +621,11 @@ export class Batch {
effects.push(effect); effects.push(effect);
} else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
render_effects.push(effect); render_effects.push(effect);
} else if (is_dirty(effect)) { } else {
update_effect(effect); this.seen_effects.add(effect);
if (is_dirty(effect)) {
update_effect(effect);
}
} }
var child = effect.first; var child = effect.first;
@ -605,6 +645,8 @@ export class Batch {
} }
effect = effect.parent; effect = effect.parent;
if (effect === all_dirty) all_dirty = null;
} }
} }
} }
@ -621,6 +663,51 @@ export class Batch {
return null; return null;
} }
/**
* Mark all reactive trees leading to block/async effects that (indirectly) depend on `value`
* @param {Value} value
* @param {number} status
* @param {boolean} not_yet - whether to mark effects that have not yet run, as opposed to those that have already run
*/
mark(value, status, not_yet = false) {
var reactions = value.reactions;
if (reactions === null) return false;
let marked = false;
for (const reaction of reactions) {
var flags = reaction.f;
if ((flags & DERIVED) !== 0) {
var derived = /** @type {Derived} */ (reaction);
// deriveds are traversed regardless of their status and only marked
// if something downstream was marked, so that we don't dirty deriveds needlessly
if (this.mark(derived, MAYBE_DIRTY, not_yet)) {
set_signal_status(derived, status);
marked = true;
}
} else {
var effect = /** @type {Effect} */ (reaction);
if (
not_yet
? !this.seen_effects.has(effect) && !this.#dirty_reactions.has(effect)
: (flags & (ASYNC | BLOCK_EFFECT)) === 0 || this.seen_effects.has(effect)
) {
if (this.#dirty_reactions.get(effect) === MAYBE_DIRTY) {
this.#dirty_reactions.delete(effect);
}
set_signal_status(effect, status);
this.schedule(effect);
marked = true;
}
}
}
return marked;
}
/** /**
* @param {Batch} batch * @param {Batch} batch
*/ */
@ -796,12 +883,60 @@ export class Batch {
} }
for (let batch = first_batch; batch !== null && !this.is_eager; batch = batch.next) { for (let batch = first_batch; batch !== null && !this.is_eager; batch = batch.next) {
if (!batch.is_fork && batch.id < this.id && batch.current.has(source)) { if (batch.id < this.id && batch.current.has(source)) {
this.dependent.add(batch); this.dependent.add(batch);
} }
if (batch.is_fork && is_latest_value) {
this.notify_fork(batch, source, is_derived, value);
}
} }
} }
/**
* Tell a fork batch that a source has been updated. Will delete that source from the fork,
* discarding it if it has no other sources left, and rerunning it else with the new value.
* @param {Batch} batch A fork
* @param {Value} source
* @param {boolean} is_derived
* @param {any} value
*/
notify_fork(batch, source, is_derived, value) {
const current = batch.current.get(source);
batch.current.delete(source);
if ([...batch.current.values()].every((value) => value.is_derived)) {
// The real world has overtaken every write of this fork, so it is obsolete. Discard it
// right away (its speculative branches must not be adopted by anyone), and empty
// `current` so that `commit()` can tell this apart from a user-initiated discard
batch.current.clear();
batch.discard();
} else {
if (current && current.v !== value) batch.current.set(source, current);
if (
!is_derived &&
(!current || current.v !== value) &&
((source.f & ASYNC) === 0 ||
// If the fork ran an async effect, its pending/resolved result belongs to the
// fork. Revalidate it when its inputs change, not when another batch resolves
// the same expression with a different view of those inputs.
!batch.#stale_effects?.has(/** @type {Effect} */ (/** @type {Source} */ (source).e)))
) {
batch.current.delete(source);
batch.queue_revalidation(source);
}
}
}
/** @param {Value} source */
queue_revalidation(source) {
queue_micro_task(() => {
if (this.linked && this.mark(source, DIRTY)) {
this.flush();
}
});
}
/** /**
* Activate batch - could be merged into another batch in the meantime, * Activate batch - could be merged into another batch in the meantime,
* in which case that other batch becomes the active batch. * in which case that other batch becomes the active batch.
@ -1051,6 +1186,21 @@ export class Batch {
this.#scheduled.push(effect); this.#scheduled.push(effect);
} }
/** @param {Batch | null} next `null` appends to the end of the list */
insert_before(next) {
this.#unlink();
this.prev = next === null ? last_batch : next.prev;
this.next = next;
if (this.prev === null) first_batch = this;
else this.prev.next = this;
if (next === null) last_batch = this;
else next.prev = this;
this.linked = true;
}
#unlink() { #unlink() {
// #merge calls #unlink, discard later on does it again - prevent // #merge calls #unlink, discard later on does it again - prevent
// running it multiple times to not corrupt the linked list // running it multiple times to not corrupt the linked list
@ -1304,7 +1454,7 @@ export function eager(fn) {
get(version); get(version);
eager_effect(() => { eager_effect(() => {
if (initial) { if (initial && !current_batch?.is_fork) {
// the first time this runs, we create an eager effect // the first time this runs, we create an eager effect
// that will run eagerly whenever the expression changes // that will run eagerly whenever the expression changes
var previous_batch_values = batch_values; var previous_batch_values = batch_values;
@ -1425,6 +1575,15 @@ export function fork(fn) {
return; return;
} }
if (batch.current.size === 0) {
// Nothing to commit: either the fork never wrote anything (e.g. it assigned a value
// that was already current), or the real world has since written to every source
// it did write to and the fork was discarded as obsolete (see `notify_fork`)
committed = true;
batch.discard();
return;
}
if (!batch.linked) { if (!batch.linked) {
e.fork_discarded(); e.fork_discarded();
} }
@ -1433,11 +1592,44 @@ export function fork(fn) {
batch.is_fork = false; batch.is_fork = false;
// apply changes and update write versions so deriveds see the change // Keep IDs in order, then move the batch before all remaining forks
let before = batch;
while (before.prev?.is_fork) {
const prev = before.prev;
const id = batch.id;
batch.id = prev.id;
prev.id = id;
before = prev;
}
if (before !== batch) batch.insert_before(before);
// Apply changes and update write versions so deriveds see the change. Everything still
// in `batch.current` at this point is the latest value: sources that the real world has
// written to in the meantime were removed from the fork via `notify_fork`, while
// async results are kept up to date by revalidating their producers when inputs change.
// We use fresh versions rather than the fork-time `content.wv`, because the real world
// may have run reactions since then whose versions would otherwise outrank them.
for (var [source, content] of batch.current) { for (var [source, content] of batch.current) {
var changed = source.v !== content.v;
source.v = content.v; source.v = content.v;
content.wv = source.wv = increment_write_version();
if (!content.is_derived) {
content.wv = source.wv = increment_write_version();
// dirty those effects the fork did not see yet, e.g. because a later batch created new branches
batch.mark(source, MAYBE_DIRTY, true);
} else if (changed) {
// A derived that was evaluated inside the fork: bump its version too, so that reactions
// which read the (then still old) real value _after_ the fork evaluated it — and which are
// therefore not in `stale_effects` — see a newer dependency version and re-run.
content.wv = source.wv = increment_write_version();
}
}
// All the block/async effects the fork executed are now guaranteed to be up to date
for (const effect of batch.stale_effects.keys()) {
effect.wv = write_version;
} }
batch.stale_effects.clear();
// trigger any `$state.eager(...)` expressions with the new state. // trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we // eager effects don't get scheduled like other effects, so we
@ -1456,17 +1648,31 @@ export function fork(fn) {
flush_eager_effects(); flush_eager_effects();
}); });
// Promote fork-only branches to the real world
for (const e of batch.unskipped_branches.keys()) {
if (e.f & FORK_ONLY_BRANCH) {
e.f ^= FORK_ONLY_BRANCH;
}
}
batch.flush(); batch.flush();
// Other forks might need to rerun now with the updated state.
let next_batch = batch.next;
while (next_batch) {
for (const [source, current] of batch.current) {
if (next_batch.current.has(source)) {
batch.notify_fork(next_batch, source, current.is_derived, current.v);
} else if (!current.is_derived) {
next_batch.queue_revalidation(source);
}
}
next_batch = next_batch.next;
}
await settled; await settled;
}, },
discard: () => { discard: () => {
// cause any MAYBE_DIRTY deriveds to update
// if they depend on things that changed
// inside the discarded fork
for (var source of batch.current.keys()) {
source.wv = increment_write_version();
}
if (!committed && batch.linked) { if (!committed && batch.linked) {
batch.discard(); batch.discard();
} }

@ -121,6 +121,10 @@ export function async_derived(fn, label, location) {
var promise = /** @type {Promise<V>} */ (/** @type {unknown} */ (undefined)); var promise = /** @type {Promise<V>} */ (/** @type {unknown} */ (undefined));
var signal = source(/** @type {V} */ (UNINITIALIZED)); var signal = source(/** @type {V} */ (UNINITIALIZED));
// Besides prod-logic this also helps in DEV to let this be printed
// as a derived when using `$inspect.trace()`
signal.f |= ASYNC;
if (DEV) signal.label = label ?? fn.toString(); if (DEV) signal.label = label ?? fn.toString();
// only suspend in async deriveds created on initialisation // only suspend in async deriveds created on initialisation
@ -129,7 +133,7 @@ export function async_derived(fn, label, location) {
/** @type {Set<ReturnType<typeof deferred<V>>>} */ /** @type {Set<ReturnType<typeof deferred<V>>>} */
var deferreds = new Set(); var deferreds = new Set();
async_effect(() => { signal.e = async_effect(() => {
var effect = /** @type {Effect} */ (active_effect); var effect = /** @type {Effect} */ (active_effect);
if (DEV) { if (DEV) {
@ -269,12 +273,6 @@ export function async_derived(fn, label, location) {
} }
}); });
if (DEV) {
// add a flag that lets this be printed as a derived
// when using `$inspect.trace()`
signal.f |= ASYNC;
}
return new Promise((fulfil) => { return new Promise((fulfil) => {
/** @param {Promise<V>} p */ /** @param {Promise<V>} p */
function next(p) { function next(p) {
@ -404,18 +402,9 @@ export function update_derived(derived) {
var value = execute_derived(derived); var value = execute_derived(derived);
if (!derived.equals(value)) { if (!derived.equals(value)) {
// in a fork, we don't update the underlying value, just `batch_values`. if (current_batch !== null || previous_batch !== null) {
// the underlying value will be updated when the fork is committed. // `capture` decides whether the underlying value is updated (it isn't in a fork,
// otherwise, the next time we get here after a 'real world' state // or if a later batch holds a newer value) and records it in the batch either way.
// change, `derived.equals` may incorrectly return `true`
if (current_batch?.is_fork && derived.deps !== null) {
// bump the write version so that reactions reading the (then still old)
// real value re-run once the fork is discarded — its sources are bumped
// on discard, which must outrank this version to trigger recomputation
derived.wv = increment_write_version();
} else if (current_batch !== null || previous_batch !== null) {
// `capture` decides whether the underlying value is updated (it isn't
// if a later batch holds a newer value) and records it in the batch either way.
// We also write to previous_batch because if it exists, it is a sign that we're // We also write to previous_batch because if it exists, it is a sign that we're
// currently in the process of flushing effects. These updates to deriveds may belong // currently in the process of flushing effects. These updates to deriveds may belong
// to the previous batch, not the new one (which can already exist if an earlier // to the previous batch, not the new one (which can already exist if an earlier
@ -458,6 +447,7 @@ export function update_derived(derived) {
batch_values?.set(derived, value); batch_values?.set(derived, value);
} }
var is_latest_value = var is_latest_value =
!current_batch?.is_fork &&
value === derived.v && value === derived.v &&
!derived.deps?.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v); !derived.deps?.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v);

@ -43,7 +43,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js'; import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js'; import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects } from './batch.js'; import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten } from './async.js'; import { flatten } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js'; import { set_signal_status } from './status.js';
@ -368,7 +368,9 @@ export function legacy_pre_effect_reset() {
* @returns {Effect} * @returns {Effect}
*/ */
export function async_effect(fn) { export function async_effect(fn) {
return create_effect(ASYNC | EFFECT_PRESERVED, fn); const effect = create_effect(ASYNC | EFFECT_PRESERVED, fn);
current_batch?.seen_effects.add(effect);
return effect;
} }
/** /**
@ -415,6 +417,7 @@ export function block(fn, flags = 0) {
if (DEV) { if (DEV) {
effect.dev_stack = dev_stack; effect.dev_stack = dev_stack;
} }
current_batch?.seen_effects.add(effect);
return effect; return effect;
} }

@ -71,14 +71,15 @@ export function set_eager_effects_deferred() {
*/ */
// TODO rename this to `state` throughout the codebase // TODO rename this to `state` throughout the codebase
export function source(v, stack) { export function source(v, stack) {
/** @type {Source<V>} */ /** @type {Source} */
var signal = { var signal = {
f: 0, f: 0,
v, v,
reactions: null, reactions: null,
equals, equals,
rv: 0, rv: 0,
wv: 0 wv: 0,
e: null
}; };
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {

@ -100,7 +100,10 @@ export interface Effect extends Reaction {
dev_stack?: DevStackEntry | null; dev_stack?: DevStackEntry | null;
} }
export type Source<V = unknown> = Value<V>; export interface Source<V = unknown> extends Value<V> {
/** Only set for ASYNC signals - the corresponding effect that writes to this source */
e: Effect | null;
}
export type MaybeSource<T = unknown> = T | Source<T>; export type MaybeSource<T = unknown> = T | Source<T>;

@ -502,9 +502,10 @@ export function update_effect(effect) {
var own_batch = previous_batch ?? current_batch; // can be null inside flush_eager_effects var own_batch = previous_batch ?? current_batch; // can be null inside flush_eager_effects
var is_latest_value = var is_latest_value =
own_batch === null || own_batch === null ||
batch_values === null || (!own_batch.is_fork &&
effect.deps === null || (batch_values === null ||
!effect.deps.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v); effect.deps === null ||
!effect.deps.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v)));
if (is_latest_value) { if (is_latest_value) {
effect.wv = write_version; effect.wv = write_version;

@ -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, would also be ok
try {
resolve.click();
await tick();
assert.equal(instance.get_calls(), 1);
// Completing the replacement must not replay the same invalidation.
resolve.click();
await tick();
assert.equal(instance.get_calls(), 1);
} 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, 1]); // [0] would also be ok; the 1 must be done in the context of the fork (impossible to assert so you gotta check manually)
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,52 @@
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']) {
try {
preload.click();
await tick();
increment.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
if (mode === 'commit') {
commit.click();
await tick();
} else if (mode === 'reveal') {
flushSync(() => reveal.click());
discard.click();
} else {
preload_second.click();
discard.click();
commit_second.click();
await tick();
}
assert.htmlEqual(target.innerHTML, `${buttons}<p>1 2</p>`);
flushSync(() => increment.click());
assert.htmlEqual(target.innerHTML, `${buttons}<p>2 4</p>`);
flushSync(() => reset.click());
assert.htmlEqual(target.innerHTML, buttons);
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
});

@ -0,0 +1,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,21 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `<button>fork</button><button>show</button><button>commit</button>`;
export default test({
async test({ assert, target }) {
const [fork, show, commit] = target.querySelectorAll('button');
fork.click();
await tick();
show.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<span>two</span><p>2</p>`);
}
});

@ -0,0 +1,17 @@
<script>
import { fork } from 'svelte';
let s1 = $state(0);
let show = $state(false);
let d2 = $derived(s1 * 2);
let f;
</script>
<button onclick={() => (f = fork(() => (s1 = 1)))}>fork</button>
<button onclick={() => (show = true)}>show</button>
<button onclick={() => f.commit()}>commit</button>
{#if d2 === 2}<span>two</span>{/if}
{#if show}
<p>{d2}</p>
{/if}

@ -0,0 +1,22 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `<button>preload</button><button>other</button><button>commit</button>`;
// If a fork is auto-discarded it should not throw on user-commit.
export default test({
mode: ['client'],
async test({ assert, target }) {
const [preload, other, commit] = target.querySelectorAll('button');
preload.click();
await tick();
other.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>true 1</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>true 1</p>`);
}
});

@ -0,0 +1,14 @@
<script>
import { fork } from 'svelte';
let open = $state(true);
let other = $state(0);
let pending;
let error = $state('');
</script>
<button onclick={() => { pending ??= fork(() => { open = true; }); }}>preload</button>
<button onclick={() => other++}>other</button>
<button onclick={() => { pending.commit().catch((e) => (error = e.message)); pending = null; }}>commit</button>
<p>{open} {other} {error}</p>

@ -0,0 +1,42 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>x = 5</button>
<button>commit</button>
<button>shift</button>
`;
// A fork writes `x`, then the real world writes `x` as well (with async work still pending).
// The fork's write is overtaken and it has nothing left to commit — `commit()` must resolve
// rather than throw `fork_discarded`, and the real world's value wins
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [fork, x5, commit, shift] = target.querySelectorAll('button');
fork.click(); // speculative: delay(10)
await tick();
x5.click(); // real: delay(5); the fork adopts x = 5 and re-runs: delay(5)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
shift.click(); // the fork's obsolete delay(10)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
shift.click(); // the real delay(5)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>5</p>`);
shift.click(); // the fork's delay(5), rejected when the fork was cleaned up
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>5</p>`);
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let f;
let error = $state('');
const deferred = [];
function delay(value) {
if (value === 0) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => { f = fork(() => { x = 10; }); }}>fork</button>
<button onclick={() => (x = 5)}>x = 5</button>
<button onclick={() => { f.commit().catch((e) => (error = e.message)); }}>commit</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<p>{await delay(x)} {error}</p>

@ -0,0 +1,43 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>fork</button><button>y</button><button>shift</button><button>commit</button>';
export default test({
async test({ assert, target }) {
await tick();
const [forkButton, y, shift, commit] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// speculative world: x becomes 1, async expression runs with x + y = 1
forkButton.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// real world: y becomes 1, async expression runs with x + y = 1
// (computed with the pre-fork x = 0) — this run supersedes the
// fork's validation of the effect
y.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// commit the fork while the real batch is still pending — x = 1 is
// written, and the async expression must eventually re-run with the
// committed value, because its in-flight run used x = 0
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// resolve all in-flight runs (superseded ones are no-ops)
for (let i = 0; i < 4; i += 1) {
shift.click();
await tick();
}
// x = 1, y = 1 — anything else means the effect resolved with a value
// computed from stale inputs and was never re-run
assert.htmlEqual(target.innerHTML, `<p>2</p>${buttons}`);
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<p>{await delay(x + y)}</p>
<button onclick={() => { f = fork(() => x++); }}>fork</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => f.commit()}>commit</button>

@ -0,0 +1,38 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork x = 1</button>
<button>update y and discard</button>
<button>commit y fork and discard</button>
<button>resolve requests</button>
<button>reset</button>
`;
export default test({
mode: ['client'],
async test({ assert, target, logs }) {
await tick();
const [speculate, update, commit, resolve, reset] = target.querySelectorAll('button');
for (const action of [update, commit]) {
speculate.click();
await tick();
logs.length = 0;
// Discard before the queued fork revalidation runs. It must not restart
// the async effect and abort the real world's request, whether the write
// came from a normal update or another fork's commit.
action.click();
await tick();
resolve.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0/1</p>`);
assert.deepEqual(logs, ['0/1']);
reset.click();
await tick();
}
}
});

@ -0,0 +1,48 @@
<script>
import { fork, getAbortSignal } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const resolvers = [];
function load(x, y) {
console.log(`${x}/${y}`);
const signal = getAbortSignal();
if (y === 0) return `${x}/${y}`;
return new Promise((resolve, reject) => {
resolvers.push(() => resolve(`${x}/${y}`));
signal.addEventListener('abort', () => reject(signal.reason));
});
}
function speculate() {
f = fork(() => { x = 1; });
}
function update_and_discard(use_fork) {
if (use_fork) {
fork(() => { y = 1; }).commit();
} else {
y = 1;
}
f.discard();
}
function finish() {
for (const resolve of resolvers.splice(0)) resolve();
}
function reset() {
y = 0;
}
</script>
<button onclick={speculate}>fork x = 1</button>
<button onclick={() => update_and_discard(false)}>update y and discard</button>
<button onclick={() => update_and_discard(true)}>commit y fork and discard</button>
<button onclick={finish}>resolve requests</button>
<button onclick={reset}>reset</button>
<p>{await load(x, y)}</p>

@ -0,0 +1,38 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [x, y, shift, pop, commit] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
logs.length = 0;
x.click();
await tick();
assert.deepEqual(logs, ['called with 1,0']);
logs.length = 0;
y.click();
await tick();
assert.deepEqual(logs, ['called with 0,1', 'called with 1,1']); // if 'called with 1,1' happens a few button clicks later that would also be ok
assert.htmlEqual(p.innerHTML, '0');
logs.length = 0;
pop.click(); // the rerunning fork
await tick();
pop.click(); // the first real world run
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(p.innerHTML, '1');
logs.length = 0;
commit.click();
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(p.innerHTML, '2');
pop.click();
await tick();
}
});

@ -0,0 +1,23 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(_, value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => {f = fork(() => x++)}}>x</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => deferred.pop()?.()}>pop</button>
<button onclick={() => f.commit()}>commit</button>
<p>{await delay(console.log('called with ' + x + ',' + y), x + y)}</p>

@ -0,0 +1,34 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [x, y, shift, pop, commit] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
logs.length = 0;
y.click();
await tick();
assert.deepEqual(logs, ['called with 0,1']);
logs.length = 0;
x.click();
await tick();
assert.deepEqual(logs, ['called with 1,1']);
assert.htmlEqual(p.innerHTML, '0');
logs.length = 0;
shift.click();
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(p.innerHTML, '1');
commit.click();
await tick();
pop.click();
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(p.innerHTML, '2');
}
});

@ -0,0 +1,23 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(_, value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => {f = fork(() => x++)}}>x</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => deferred.pop()?.()}>pop</button>
<button onclick={() => f.commit()}>commit</button>
<p>{await delay(console.log('called with ' + x + ',' + y), x + y)}</p>

@ -0,0 +1,48 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [fork, update, pop, commit] = target.querySelectorAll('button');
const [sum, doubled] = target.querySelectorAll('p');
logs.length = 0;
fork.click();
await tick();
assert.deepEqual(logs, ['sum 1,0']);
logs.length = 0;
// Revalidate twice to also check that retaining async results does not
// prevent the fork from responding to genuine changes to its inputs.
for (const y of [1, 2]) {
update.click();
await tick();
assert.deepEqual(logs, [`sum 0,${y}`, `sum 1,${y}`]);
logs.length = 0;
pop.click(); // resolve the fork before the real world
await tick();
assert.deepEqual(logs, [`double ${y + 1}`]);
logs.length = 0;
pop.click();
await tick();
assert.deepEqual(logs, [`double ${y}`]);
assert.htmlEqual(sum.innerHTML, String(y));
assert.htmlEqual(doubled.innerHTML, String(y * 2));
logs.length = 0;
}
commit.click();
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(sum.innerHTML, '3');
assert.htmlEqual(doubled.innerHTML, '6');
pop.click(); // the superseded first fork run
await tick();
assert.htmlEqual(sum.innerHTML, '3');
assert.htmlEqual(doubled.innerHTML, '6');
}
});

@ -0,0 +1,29 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(x, y) {
console.log(`sum ${x},${y}`);
const value = x + y;
return value ? new Promise((resolve) => deferred.push(() => resolve(value))) : value;
}
async function double(value) {
console.log(`double ${value}`);
return value * 2;
}
let sum = $derived(await delay(x, y));
</script>
<button onclick={() => { f = fork(() => x++); }}>fork</button>
<button onclick={() => y++}>update</button>
<button onclick={() => deferred.pop()?.()}>pop</button>
<button onclick={() => f.commit()}>commit</button>
<p>{sum}</p>
<p>{await double(sum)}</p>

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>resolve</button>
<button>commit</button>
`;
export default test({
async test({ assert, target, logs }) {
await tick();
const [fork_button, resolve, commit] = target.querySelectorAll('button');
assert.deepEqual(logs, [[0, true]]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
fork_button.click();
await tick();
assert.deepEqual(logs, [
[0, true],
[2, true]
]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
// `delayed` changes, but `nonnegative` stays true. The async expression has
// already consumed the fork's `doubled`, so it should not run again.
resolve.click();
await tick();
assert.deepEqual(logs, [
[0, true],
[2, true]
]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
}
});

@ -0,0 +1,27 @@
<script>
import { fork } from 'svelte';
const resolvers = [];
let f;
let count = $state(0);
let doubled = $derived(count * 2);
let delayed = $derived(await delay(count));
let nonnegative = $derived(delayed >= 0);
function delay(value) {
if (value === 0) return value;
return new Promise((resolve) => resolvers.push(() => resolve(value)));
}
function load(value, nonnegative) {
console.log([value, nonnegative]);
return value;
}
</script>
<button onclick={() => (f = fork(() => count++))}>fork</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>
<button onclick={() => f?.commit()}>commit</button>
<p>{await load(doubled, nonnegative)}</p>

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

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

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

@ -0,0 +1,79 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>reveal outer</button>
<button>reveal and navigate</button>
<button>navigate</button>
<button>resolve</button>
<button>commit</button>
<button>discard</button>
<button>reset</button>
`;
export default test({
async test({ assert, target, logs }) {
const [preload, reveal, reveal_and_navigate, navigate, resolve, commit, discard, reset] =
target.querySelectorAll('button');
for (const mode of ['separate', 'together', 'pending']) {
for (const finish of [commit, discard]) {
try {
preload.click();
if (mode !== 'pending') {
resolve.click();
await tick();
}
assert.htmlEqual(target.innerHTML, buttons);
assert.deepEqual(logs, ['load']);
if (mode === 'together') {
reveal_and_navigate.click();
} else {
reveal.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section></section>`);
navigate.click();
}
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section></section>`);
assert.deepEqual(logs, ['load']);
if (mode === 'pending') {
resolve.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section></section>`);
}
finish.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`${buttons}<section>${finish === commit ? 'pending <p>2</p>' : ''}</section>`
);
assert.deepEqual(logs, ['load']);
if (finish === commit) {
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<section><p>2</p></section>`);
assert.deepEqual(logs, ['load']);
}
reset.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
logs.length = 0;
} catch (e) {
/** @type {Error} */ (e).message =
`${mode}/${finish === commit ? 'commit' : 'discard'}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

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

@ -0,0 +1,18 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [change, commit] = target.querySelectorAll('button');
assert.deepEqual(logs, ['hi']);
change.click();
await tick();
assert.deepEqual(logs, ['hi', 'hi']);
commit.click();
await tick();
assert.deepEqual(logs, ['hi', 'hi']);
}
});

@ -0,0 +1,12 @@
<script>
import { fork } from 'svelte';
let x = $state(1);
let y = $derived.by(() => {console.log('hi'); return x % 2});
let f;
</script>
<button onclick={() => { f = fork(() => x = 3)}}>change</button>
<button onclick={() => f.commit()}>commit</button>
{#if y}hi{/if}

@ -0,0 +1,42 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork x = y = 1</button>
<button>set x = y = 1</button>
<button>set z = 1</button>
<button>commit fork</button>
<button>discard fork</button>
`;
export default test({
mode: ['client'],
async test({ assert, target, logs }) {
await tick();
const [speculate, catch_up, update, commit, discard] = target.querySelectorAll('button');
speculate.click();
await tick();
catch_up.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
logs.length = 0;
// All speculative writes are obsolete. Only the real world should react.
update.click();
await tick();
try {
assert.htmlEqual(target.innerHTML, `${buttons}<p>3</p>`);
assert.deepEqual(logs, ['1/1/1']);
// Committing an automatically discarded fork should be a no-op and not throw,
// as the user cannot really know that something got automatically discarded.
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>3</p>`);
assert.deepEqual(logs, ['1/1/1', 'committed']);
} finally {
discard.click();
}
}
});

@ -0,0 +1,43 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let z = $state(0);
let f;
function load(x, y, z) {
console.log(`${x}/${y}/${z}`);
return x + y + z;
}
function speculate() {
f = fork(() => { x = 1; y = 1; });
}
function catch_up() {
x = 1;
y = 1;
}
function update() {
z = 1;
}
function discard() {
f.discard();
}
async function commit() {
await f.commit();
console.log('committed');
}
</script>
<button onclick={speculate}>fork x = y = 1</button>
<button onclick={catch_up}>set x = y = 1</button>
<button onclick={update}>set z = 1</button>
<button onclick={commit}>commit fork</button>
<button onclick={discard}>discard fork</button>
<p>{await load(x, y, z)}</p>

@ -0,0 +1,42 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [forkButton, real, shift, discard] = target.querySelectorAll('button');
assert.deepEqual(logs, ['b 0']);
logs.length = 0;
// speculative world: fork writes b and c, runs the async expression
forkButton.click();
await tick();
assert.deepEqual(logs, ['b 1']);
// real world: b++ re-runs the async expression for real
real.click();
await tick();
assert.deepEqual(logs, ['b 1', 'b 1']);
// resolve the fork's in-flight run — the fork is still speculative,
// nothing should be committed or re-run
shift.click();
await tick();
assert.deepEqual(logs, ['b 1', 'b 1']);
// resolve the real run — the real batch commits b = 1. The fork's world
// value of `b` is also 1 (its own write, now also committed), so the
// fork's async expression sees unchanged inputs and should not re-run
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<p>1 0</p><button>fork</button><button>real</button><button>shift</button><button>discard</button>'
);
assert.deepEqual(logs, ['b 1', 'b 1']);
discard.click();
await tick();
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let b = $state(0);
let c = $state(0);
let f;
const deferred = [];
function delay(_, value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<p>{await delay(console.log(`b ${b}`), b)} {c}</p>
<button onclick={() => { f = fork(() => { b += 1; c += 1; }); }}>fork</button>
<button onclick={() => b++}>real</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => f.discard()}>discard</button>

@ -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,17 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [create, commit] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
create.click();
await tick();
assert.htmlEqual(p.innerHTML, '0:0');
commit.click();
await tick();
assert.htmlEqual(p.innerHTML, '0:0');
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let source = $state(0);
let writable = $derived(source);
let pending;
</script>
<button onclick={() => {
pending = fork(() => {
source = 1;
source = 0;
writable = 1;
writable = 0;
});
}}>fork</button>
<button onclick={() => pending.commit()}>commit</button>
<p>{source}:{writable}</p>

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip: true,
async test({ assert, target }) { async test({ assert, target }) {
await tick(); await tick();
const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button'); const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button');

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip: true, // TODO more combinations pass on https://github.com/sveltejs/svelte/pull/17971
timeout: 20_000, timeout: 20_000,
async test({ assert, target }) { async test({ assert, target }) {
const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] = const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] =

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) { async test({ assert, target }) {
const [x, y, shift, pop, commit] = target.querySelectorAll('button'); const [x, y, shift, pop, commit] = target.querySelectorAll('button');
@ -23,7 +22,7 @@ export default test({
` `
); );
commit.click(); commit.click(); // puts fork (x) behind y so it has to wait on y first
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -37,7 +36,7 @@ export default test({
` `
); );
shift.click(); shift.click(); // ... which is why nothing happens yet on first shift
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -47,13 +46,6 @@ export default test({
<button>shift</button> <button>shift</button>
<button>pop</button> <button>pop</button>
<button>commit</button> <button>commit</button>
universe
universe
"universe"
universe
universe
universe
"universe"
<hr> <hr>
` `
); );

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) { async test({ assert, target }) {
const [x, y, resolve, commit] = target.querySelectorAll('button'); const [x, y, resolve, commit] = target.querySelectorAll('button');

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip: true, // TODO works with fork reconciliation
async test({ assert, target }) { async test({ assert, target }) {
const [x, y, resolve, commit] = target.querySelectorAll('button'); const [x, y, resolve, commit] = target.querySelectorAll('button');
@ -45,12 +44,6 @@ export default test({
<button>resolve</button> <button>resolve</button>
<button>commit</button> <button>commit</button>
<hr> <hr>
world
"world"
world
world
world
"world"
` `
); );

Loading…
Cancel
Save