fork fixes + simplify/consolidate

async-another-try-pt-2
Simon Holthausen 6 days ago
parent 1cdc1709d4
commit de2097595e
No known key found for this signature in database

@ -188,7 +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; var offscreen = this.#offscreen.get(key);
// 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,24 +201,21 @@ export class BranchManager {
} }
} }
if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { if (offscreen !== undefined && (offscreen.effect.f & FORK_ONLY_BRANCH) !== 0) {
first = true; batch.reveal(offscreen.effect);
}
if (fn && !this.#onscreen.has(key) && offscreen === undefined) {
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)); const effect = branch(() => fn(target));
this.#offscreen.set(key, { if (batch.is_fork) effect.f |= FORK_ONLY_BRANCH;
effect: b,
fragment
});
if (batch.is_fork) { this.#offscreen.set(key, { effect, fragment });
b.f ^= FORK_ONLY_BRANCH;
}
} else { } else {
this.#onscreen.set( this.#onscreen.set(
key, key,
@ -229,15 +226,6 @@ 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) {

@ -34,7 +34,14 @@ 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';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { invoke_error_boundary } from '../error-handling.js'; import { invoke_error_boundary } from '../error-handling.js';
import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js'; import {
flush_eager_effects,
invalidate,
old_values,
set_eager_effects,
source,
update
} 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 { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js'; import { UNINITIALIZED } from '../../../constants.js';
@ -86,6 +93,24 @@ export let held_sources = null;
*/ */
export let stale_sources = null; export let stale_sources = null;
/**
* Whether `batch_values` handed `reaction` a value of one of its dependencies that differs
* from the real one, i.e. whether it (partly) ran with its batch's view rather than the latest values.
* Looking one level deep is enough: a derived that was itself computed from such a value
* was not written to the real world either, so it differs as well.
* @param {Reaction} reaction
*/
export function read_batch_local_value(reaction) {
var deps = reaction.deps;
if (batch_values === null || deps === null) return false;
for (var i = 0; i < deps.length; i++) {
if (batch_values.has(deps[i]) && batch_values.get(deps[i]) !== deps[i].v) return true;
}
return false;
}
/** @type {Effect | null} */ /** @type {Effect | null} */
let last_scheduled_effect = null; let last_scheduled_effect = null;
@ -117,7 +142,8 @@ var source_stacks = new Set();
let uid = 1; let uid = 1;
export class Batch { export class Batch {
id = uid++; /** Reflects the position in the queue, i.e. a batch with a lower id is an earlier batch */
id = 0;
/** True as soon as `#process` was called */ /** True as soon as `#process` was called */
started = false; started = false;
@ -151,17 +177,21 @@ export class Batch {
#stale_readers = null; #stale_readers = null;
/** /**
* Reactions that, while running in an earlier batch, read a value that this batch holds * Reactions that ran with values that differ from this batch's view (e.g. while running in an
* a newer version of, and that are therefore scheduled to re-run in this batch. Used to * earlier batch, they read a value that this batch holds a newer version of), and that are
* avoid scheduling the same reaction multiple times when it reads more than one such value. * therefore scheduled to re-run in this batch. Used to avoid scheduling the same reaction
* Lazily initialized for performance reasons. * multiple times. Lazily initialized for performance reasons.
*/ */
get stale_readers() { get stale_readers() {
return (this.#stale_readers ??= new Set()); return (this.#stale_readers ??= new Set());
} }
/** @type {Set<Effect>} */ /**
seen_effects = new Set(); * Block/async effects that were created or traversed in this batch. Only tracked
* for forks (`null` otherwise), because only they need it (see `mark`).
* @type {Set<Effect> | null}
*/
seen_effects = null;
/** @type {Batch | null} */ /** @type {Batch | null} */
prev = null; prev = null;
@ -175,7 +205,7 @@ export class Batch {
#dependent = null; #dependent = null;
/** /**
* Batches that depend on this batch. * Earlier batches that this batch depends on, i.e. that it will be merged into if it resolves first.
* Lazily initialized for performance reasons. * Lazily initialized for performance reasons.
*/ */
get dependent() { get dependent() {
@ -280,18 +310,17 @@ export class Batch {
#skipped_branches = new Map(); #skipped_branches = new Map();
/** /**
* @type {Map<Effect, boolean> | null} * @type {Set<Effect> | 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 {Map<Effect, boolean>} * @type {Set<Effect>}
*/ */
get unskipped_branches() { get unskipped_branches() {
return (this.#unskipped_branches ??= new Map()); return (this.#unskipped_branches ??= new Set());
} }
is_fork = false; is_fork = false;
@ -301,17 +330,29 @@ export class Batch {
#decrement_queued = false; #decrement_queued = false;
constructor() { constructor() {
// Put the new batch before the first forked batch this.enqueue();
let batch = first_batch; }
while (batch && !batch.is_fork) {
batch = batch.next;
}
this.insert_before(batch); /**
while (batch) { * Move this batch behind all other non-fork batches, but before all forks
batch.id = uid++; * (which are always the latest batches), keeping the ids in queue order
batch = batch.next; */
} enqueue() {
var next = first_batch;
while (next !== null && !next.is_fork) next = next.next;
this.#unlink();
this.prev = next === null ? last_batch : next.prev;
this.next = next;
this.linked = true;
if (this.prev === null) first_batch = this;
else this.prev.next = this;
if (next === null) last_batch = this;
else next.prev = this;
for (this.id = uid++; next !== null; next = next.next) next.id = uid++;
} }
#is_deferred() { #is_deferred() {
@ -347,16 +388,15 @@ export class Batch {
if (!this.#skipped_branches.has(effect)) { if (!this.#skipped_branches.has(effect)) {
this.#skipped_branches.set(effect, { d: [], m: [] }); this.#skipped_branches.set(effect, { d: [], m: [] });
} }
this.unskipped_branches.delete(effect); this.#unskipped_branches?.delete(effect);
} }
/** /**
* 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, is_fork_init = false) { unskip_effect(effect) {
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);
@ -371,7 +411,49 @@ export class Batch {
this.schedule(e); this.schedule(e);
} }
} }
if (!this.unskipped_branches.has(effect)) this.unskipped_branches.set(effect, is_fork_init); this.unskipped_branches.add(effect);
}
/**
* Called when this batch selects a branch that so far only exists for forks. Its effects ran
* with the values of the fork that created it, so they need to re-run with the values of this
* batch. If this is a real batch, the branch also becomes part of the real world.
* @param {Effect} branch
*/
reveal(branch) {
if (this.#unskipped_branches?.has(branch)) return; // already revealed by this batch
if (!this.is_fork) branch.f ^= FORK_ONLY_BRANCH;
this.#schedule_branch(branch);
}
/**
* Schedule all effects inside a revealed branch. Effects that ran without dependencies are the
* same in every batch. Branches inside it that only exist for (other) forks are left alone, the
* traversal skips them anyway.
* TODO this can overfire, maybe there's a way to detect which effects saw values that differ.
* @param {Effect} effect
*/
#schedule_branch(effect) {
if (
(effect.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 &&
effect.fn !== null &&
(effect.deps !== null || (effect.f & REACTION_RAN) === 0)
) {
set_signal_status(effect, DIRTY);
this.schedule(effect);
// The effect's state will reflect this batch, so other forks that ran it need to re-run it.
// Not so for async effects, as their result is stored in the fork and remains valid.
if ((effect.f & ASYNC) === 0) {
for (var batch = first_batch; batch !== null; batch = batch.next) {
if (batch !== this && batch.#stale_effects?.has(effect)) batch.rerun(effect);
}
}
}
for (var e = effect.first; e !== null; e = e.next) {
if ((e.f & FORK_ONLY_BRANCH) === 0) this.#schedule_branch(e);
}
} }
/** /**
@ -578,40 +660,22 @@ 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) { // Branches that only exist for forks are skipped unless this batch revealed them
var first_time = this.unskipped_branches.get(effect); if ((flags & FORK_ONLY_BRANCH) !== 0 && !this.#unskipped_branches?.has(effect)) {
skip = true;
if (first_time === undefined) { this.skip_effect(effect);
skip = true; reset_branch(
this.skip_effect(effect); effect,
reset_branch( /** @type {{d: Effect[], m: Effect[]}} */ (this.#skipped_branches.get(effect))
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) {
@ -622,7 +686,7 @@ export class Batch {
} 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 { } else {
this.seen_effects.add(effect); this.seen_effects?.add(effect);
if (is_dirty(effect)) { if (is_dirty(effect)) {
update_effect(effect); update_effect(effect);
} }
@ -645,8 +709,6 @@ export class Batch {
} }
effect = effect.parent; effect = effect.parent;
if (effect === all_dirty) all_dirty = null;
} }
} }
} }
@ -655,7 +717,8 @@ export class Batch {
if (this.is_eager) return null; if (this.is_eager) return null;
for (var batch = this.prev; batch !== null; batch = batch.prev) { for (var batch = this.prev; batch !== null; batch = batch.prev) {
if (!batch.is_fork && this.dependent.has(batch)) { // no need to check for forks, they are always the latest batches
if (this.dependent.has(batch)) {
return batch; return batch;
} }
} }
@ -692,8 +755,8 @@ export class Batch {
if ( if (
not_yet not_yet
? !this.seen_effects.has(effect) && !this.#dirty_reactions.has(effect) ? !this.seen_effects?.has(effect) && !this.#dirty_reactions.has(effect)
: (flags & (ASYNC | BLOCK_EFFECT)) === 0 || this.seen_effects.has(effect) : (flags & (ASYNC | BLOCK_EFFECT)) === 0 || this.seen_effects?.has(effect)
) { ) {
if (this.#dirty_reactions.get(effect) === MAYBE_DIRTY) { if (this.#dirty_reactions.get(effect) === MAYBE_DIRTY) {
this.#dirty_reactions.delete(effect); this.#dirty_reactions.delete(effect);
@ -756,10 +819,10 @@ export class Batch {
for (const [s, v] of batch.#skipped_branches) { for (const [s, v] of batch.#skipped_branches) {
this.#skipped_branches.set(s, v); this.#skipped_branches.set(s, v);
this.unskipped_branches.delete(s); this.#unskipped_branches?.delete(s);
} }
for (const s of batch.unskipped_branches.keys()) { for (const s of batch.unskipped_branches) {
const v = this.#skipped_branches.get(s); const v = this.#skipped_branches.get(s);
if (v) { if (v) {
v.d = v.d.filter((e) => !batch.async_deriveds.has(e)); v.d = v.d.filter((e) => !batch.async_deriveds.has(e));
@ -845,14 +908,7 @@ export class Batch {
// A derived computed from inputs that differ from the real values must stay batch-local. // A derived computed from inputs that differ from the real values must stay batch-local.
// This also happens when committing a later batch hides an earlier batch's pending writes. // This also happens when committing a later batch hides an earlier batch's pending writes.
let is_latest_value = let is_latest_value =
!this.is_fork && !this.is_fork && !(is_derived && read_batch_local_value(/** @type {Derived} */ (source)));
(!is_derived ||
batch_values === null ||
!(
/** @type {Derived} */ (source).deps?.some(
(d) => batch_values?.has(d) && batch_values.get(d) !== d.v
)
));
// A later batch may also own a newer value of the source or one of a derived's dependencies. // A later batch may also own a newer value of the source or one of a derived's dependencies.
// The check above isn't sufficient here: a later batch's write is visible through `batch_values`, // The check above isn't sufficient here: a later batch's write is visible through `batch_values`,
@ -888,46 +944,65 @@ export class Batch {
} }
if (batch.is_fork && is_latest_value) { if (batch.is_fork && is_latest_value) {
this.notify_fork(batch, source, is_derived, value); batch.overtake(source, is_derived, value);
} }
} }
} }
/** /**
* Tell a fork batch that a source has been updated. Will delete that source from the fork, * Tell this fork that the real world has written `value` to `source`. This overtakes the
* discarding it if it has no other sources left, and rerunning it else with the new value. * fork's own write to it, unless that is a value the fork computed itself (a derived or
* @param {Batch} batch A fork * the result of an async effect the fork ran). The fork is revalidated with the new value,
* or discarded if it has nothing (but deriveds) left to commit.
* @param {Value} source * @param {Value} source
* @param {boolean} is_derived * @param {boolean} is_derived
* @param {any} value * @param {any} value
*/ */
notify_fork(batch, source, is_derived, value) { overtake(source, is_derived, value) {
const current = batch.current.get(source); const current = this.current.get(source);
batch.current.delete(source); this.current.delete(source);
if ([...batch.current.values()].every((value) => value.is_derived)) { if ([...this.current.values()].every((value) => value.is_derived)) {
// The real world has overtaken every write of this fork, so it is obsolete. Discard it // 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 // 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 // `current` so that `commit()` can tell this apart from a user-initiated discard
batch.current.clear(); this.current.clear();
batch.discard(); this.discard();
} else { } else if (current === undefined || current.v !== value) {
if (current && current.v !== value) batch.current.set(source, current);
if ( if (
!is_derived && is_derived ||
(!current || current.v !== value) && // If the fork ran an async effect, its pending/resolved result belongs to the
((source.f & ASYNC) === 0 || // fork. Revalidate it when its inputs change, not when another batch resolves
// If the fork ran an async effect, its pending/resolved result belongs to the // the same expression with a different view of those inputs.
// fork. Revalidate it when its inputs change, not when another batch resolves ((source.f & ASYNC) !== 0 &&
// the same expression with a different view of those inputs. this.#stale_effects?.has(/** @type {Effect} */ (/** @type {Source} */ (source).e)))
!batch.#stale_effects?.has(/** @type {Effect} */ (/** @type {Source} */ (source).e)))
) { ) {
batch.current.delete(source); if (current !== undefined) this.current.set(source, current);
batch.queue_revalidation(source); } else {
this.queue_revalidation(source);
} }
} }
} }
/**
* Re-run `reaction` in this batch, because it ran with values that differ from this batch's view.
* If this batch is gone by then, re-run it in the real world instead.
* @param {Reaction} reaction
*/
rerun(reaction) {
// a reaction can be stale in several ways at once — only schedule the re-run once
if (this.stale_readers.has(reaction)) return;
this.stale_readers.add(reaction);
queue_micro_task(() => {
this.stale_readers.delete(reaction);
if (!this.activate().linked) this.deactivate();
var batch = Batch.ensure();
invalidate(reaction);
batch.flush();
});
}
/** @param {Value} source */ /** @param {Value} source */
queue_revalidation(source) { queue_revalidation(source) {
queue_micro_task(() => { queue_micro_task(() => {
@ -948,8 +1023,7 @@ export class Batch {
deactivate() { deactivate() {
current_batch = null; current_batch = null;
batch_values = null; batch_values = wv_values = held_sources = stale_sources = null;
wv_values = null;
} }
flush() { flush() {
@ -969,12 +1043,7 @@ export class Batch {
legacy_updates = null; legacy_updates = null;
is_processing = false; is_processing = false;
current_batch = null; this.deactivate();
batch_values = null;
wv_values = null;
held_sources = null;
stale_sources = null;
old_values.clear(); old_values.clear();
if (DEV) { if (DEV) {
@ -1115,9 +1184,7 @@ export class Batch {
*/ */
apply(include_earlier = false) { apply(include_earlier = false) {
if (!async_mode_flag || (!this.is_fork && this.prev === null && this.next === null)) { if (!async_mode_flag || (!this.is_fork && this.prev === null && this.next === null)) {
batch_values = null; batch_values = wv_values = held_sources = stale_sources = null;
wv_values = null;
stale_sources = null;
return; return;
} }
@ -1133,7 +1200,7 @@ export class Batch {
wv_values.set(source, current.wv); wv_values.set(source, current.wv);
} }
for (const [effect, wv] of this.stale_effects) { for (const [effect, wv] of this.#stale_effects ?? []) {
wv_values.set(effect, wv); wv_values.set(effect, wv);
} }
@ -1186,21 +1253,6 @@ 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
@ -1559,6 +1611,7 @@ export function fork(fn) {
var committed = false; var committed = false;
var batch = Batch.ensure(); var batch = Batch.ensure();
batch.is_fork = true; batch.is_fork = true;
batch.seen_effects = new Set();
batch_values = new Map(); batch_values = new Map();
wv_values = new Map(); wv_values = new Map();
@ -1578,7 +1631,7 @@ export function fork(fn) {
if (batch.current.size === 0) { if (batch.current.size === 0) {
// Nothing to commit: either the fork never wrote anything (e.g. it assigned a value // 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 // 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`) // it did write to and the fork was discarded as obsolete (see `overtake`)
committed = true; committed = true;
batch.discard(); batch.discard();
return; return;
@ -1591,21 +1644,11 @@ export function fork(fn) {
committed = true; committed = true;
batch.is_fork = false; batch.is_fork = false;
batch.enqueue();
// 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 // 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 // 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 // written to in the meantime were removed from the fork via `overtake`, while
// async results are kept up to date by revalidating their producers when inputs change. // 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 // 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. // may have run reactions since then whose versions would otherwise outrank them.
@ -1649,7 +1692,7 @@ export function fork(fn) {
}); });
// Promote fork-only branches to the real world // Promote fork-only branches to the real world
for (const e of batch.unskipped_branches.keys()) { for (const e of batch.unskipped_branches) {
if (e.f & FORK_ONLY_BRANCH) { if (e.f & FORK_ONLY_BRANCH) {
e.f ^= FORK_ONLY_BRANCH; e.f ^= FORK_ONLY_BRANCH;
} }
@ -1657,17 +1700,15 @@ export function fork(fn) {
batch.flush(); batch.flush();
// Other forks might need to rerun now with the updated state. // Committing means the real world writes the fork's values, so tell the other forks about
let next_batch = batch.next; // it like `#capture` does. Batches that were created while flushing (e.g. by an effect
while (next_batch) { // writing to state) are real batches, and must not be treated like that.
for (let b = first_batch; b !== null; b = b.next) {
if (!b.is_fork) continue;
for (const [source, current] of batch.current) { for (const [source, current] of batch.current) {
if (next_batch.current.has(source)) { b.overtake(source, current.is_derived, current.v);
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;

@ -44,7 +44,13 @@ import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { component_context } from '../context.js'; import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js'; import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch, first_batch, previous_batch } from './batch.js'; import {
batch_values,
current_batch,
first_batch,
previous_batch,
read_batch_local_value
} from './batch.js';
import { increment_pending, unset_context } from './async.js'; import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js'; import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js'; import { set_signal_status, update_derived_status } from './status.js';
@ -424,7 +430,7 @@ export function update_derived(derived) {
return; return;
} }
} else if (batch_values?.has(derived) && !derived.equals(batch_values?.get(derived))) { } else if (batch_values?.has(derived) && !derived.equals(batch_values?.get(derived))) {
current_batch?.capture(derived, derived.v); current_batch?.capture(derived, derived.v, true);
} }
// don't mark derived clean if we're reading it inside a // don't mark derived clean if we're reading it inside a
@ -446,12 +452,7 @@ export function update_derived(derived) {
if (effect_tracking() || current_batch?.is_fork) { if (effect_tracking() || current_batch?.is_fork) {
batch_values?.set(derived, value); batch_values?.set(derived, value);
} }
var is_latest_value = if (!current_batch?.is_fork && value === derived.v && !read_batch_local_value(derived)) {
!current_batch?.is_fork &&
value === derived.v &&
!derived.deps?.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v);
if (is_latest_value) {
update_derived_status(derived); update_derived_status(derived);
} else if (derived.v !== UNINITIALIZED) { } else if (derived.v !== UNINITIALIZED) {
set_signal_status(derived, MAYBE_DIRTY); set_signal_status(derived, MAYBE_DIRTY);

@ -369,7 +369,7 @@ export function legacy_pre_effect_reset() {
*/ */
export function async_effect(fn) { export function async_effect(fn) {
const effect = create_effect(ASYNC | EFFECT_PRESERVED, fn); const effect = create_effect(ASYNC | EFFECT_PRESERVED, fn);
current_batch?.seen_effects.add(effect); current_batch?.seen_effects?.add(effect);
return effect; return effect;
} }
@ -417,7 +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); current_batch?.seen_effects?.add(effect);
return effect; return effect;
} }

@ -53,6 +53,7 @@ import {
flushSync, flushSync,
held_sources, held_sources,
previous_batch, previous_batch,
read_batch_local_value,
schedule_effect, schedule_effect,
stale_sources, stale_sources,
wv_values wv_values
@ -63,7 +64,6 @@ import { captured_signals } from './legacy.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, update_derived_status } from './reactivity/status.js'; import { set_signal_status, update_derived_status } from './reactivity/status.js';
import * as w from './warnings.js'; import * as w from './warnings.js';
import { queue_micro_task } from './dom/task.js';
/** /**
* True if updating in an effect context that is reactive (i.e. not branch/root effects) * True if updating in an effect context that is reactive (i.e. not branch/root effects)
@ -495,19 +495,10 @@ export function update_effect(effect) {
var teardown = update_reaction(effect); var teardown = update_reaction(effect);
effect.teardown = typeof teardown === 'function' ? teardown : null; effect.teardown = typeof teardown === 'function' ? teardown : null;
// Did the effect see the latest value of all its dependencies, or (partly) its batch's view // Did the effect see the latest value of all its dependencies, or (partly) its batch's view of them?
// of them, i.e. did `batch_values` hand it a value that differs from the real one? We only
// need to look one level deep: a derived that was itself computed from such a value was not
// written to the real world either, so it differs as well.
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 =
own_batch === null || if (own_batch === null || (!own_batch.is_fork && !read_batch_local_value(effect))) {
(!own_batch.is_fork &&
(batch_values === null ||
effect.deps === null ||
!effect.deps.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v)));
if (is_latest_value) {
effect.wv = write_version; effect.wv = write_version;
} else { } else {
// The effect ran with values that are not the latest ones (it saw its own batch's view). // The effect ran with values that are not the latest ones (it saw its own batch's view).
@ -782,28 +773,17 @@ function get_batch_value(signal, first_time) {
is_updating_effect || is_updating_effect ||
(active_effect !== null && (active_effect.f & ASYNC) !== 0); (active_effect !== null && (active_effect.f & ASYNC) !== 0);
if ( if (reader !== null && reactive) {
reader !== null && if (!current.is_eager) {
reactive && batch.rerun(reader);
// a reaction can read several stale values in one run — only schedule the re-run once } else if (!batch.stale_readers.has(reader)) {
!batch.stale_readers.has(reader) batch.stale_readers.add(reader);
) {
batch.stale_readers.add(reader);
if (current.is_eager) {
// TODO only do this if we can see that the batch doesn't have this already scheduled in (maybe)dirty effects. // TODO only do this if we can see that the batch doesn't have this already scheduled in (maybe)dirty effects.
batch.oncommit(() => { batch.oncommit(() => {
batch.stale_readers.delete(reader); batch.stale_readers.delete(reader);
Batch.ensure(); Batch.ensure();
invalidate(reader); invalidate(reader);
}); });
} else {
queue_micro_task(() => {
batch.stale_readers.delete(reader);
const b = batch.activate();
invalidate(reader);
b.flush();
});
} }
} }
} }

@ -0,0 +1,54 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork a</button>
<button>fork b</button>
<button>reveal</button>
<button>commit a</button>
<button>commit b</button>
<button>reset</button>
`;
// A branch that was created by fork `a` must not show `a`'s values when it is
// revealed by another batch (be it a real one or another fork), and `a` must
// still show its own values when it's committed afterwards
export default test({
async test({ assert, target }) {
const [fork_a, fork_b, reveal, commit_a, commit_b, reset] = target.querySelectorAll('button');
for (const mode of ['real', 'fork']) {
try {
fork_a.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
if (mode === 'real') {
reveal.click();
} else {
fork_b.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
commit_b.click();
}
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>static</b><p>0</p><span>0</span>`);
commit_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`${buttons}<b>static</b><p>1</p><span>1</span><span>2</span>`
);
reset.click();
await tick();
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 items = $state([0]);
let a;
let b;
</script>
<button onclick={() => (a = fork(() => { show = true; count = 1; items = [1, 2]; }))}>fork a</button>
<button onclick={() => (b = fork(() => (show = true)))}>fork b</button>
<button onclick={() => (show = true)}>reveal</button>
<button onclick={() => a.commit()}>commit a</button>
<button onclick={() => b.commit()}>commit b</button>
<button onclick={() => { a?.discard(); b?.discard(); show = false; count = 0; items = [0]; }}>reset</button>
{#if show}
<svelte:boundary><b>static</b></svelte:boundary>
<p>{count}</p>
{#each items as item (item)}<span>{item}</span>{/each}
{/if}

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `<button>fork</button><button>commit</button><button>shift</button>`;
// Committing a fork notifies the remaining forks about its writes. A real batch that was created
// while flushing the commit (here: by an effect writing to the same source) must not be treated
// like a fork (i.e. it must not be discarded as obsolete)
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [fork, commit, shift] = target.querySelectorAll('button');
fork.click();
await tick();
shift.click(); // resolve the fork's delay(1)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click(); // commits x = 1, effect sets x = 2 -> delay(2)
await tick();
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
}
});

@ -0,0 +1,22 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let f;
const deferred = [];
function delay(v) {
if (!v) return v;
return new Promise((r) => deferred.push(() => r(v)));
}
$effect(() => {
if (x === 1) x = 2;
});
</script>
<button onclick={() => (f = fork(() => (x = 1)))}>fork</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<p>{await delay(x)}</p>
Loading…
Cancel
Save