svelte
Version:
Cybernetically enhanced web apps
1,024 lines (842 loc) • 24.8 kB
JavaScript
/** @import { Fork } from 'svelte' */
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
import {
BLOCK_EFFECT,
BRANCH_EFFECT,
CLEAN,
DESTROYED,
DIRTY,
EFFECT,
ASYNC,
INERT,
RENDER_EFFECT,
ROOT_EFFECT,
MAYBE_DIRTY,
DERIVED,
BOUNDARY_EFFECT,
EAGER_EFFECT,
HEAD_EFFECT,
ERROR_VALUE,
WAS_MARKED,
MANAGED_EFFECT
} from '#client/constants';
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property } from '../../shared/utils.js';
import {
active_effect,
get,
is_dirty,
is_updating_effect,
set_is_updating_effect,
set_signal_status,
update_effect
} from '../runtime.js';
import * as e from '../errors.js';
import { flush_tasks, queue_micro_task } from '../dom/task.js';
import { DEV } from 'esm-env';
import { invoke_error_boundary } from '../error-handling.js';
import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js';
import { eager_effect, unlink_effect } from './effects.js';
/**
* @typedef {{
* parent: EffectTarget | null;
* effect: Effect | null;
* effects: Effect[];
* render_effects: Effect[];
* }} EffectTarget
*/
/** @type {Set<Batch>} */
const batches = new Set();
/** @type {Batch | null} */
export let current_batch = null;
/**
* This is needed to avoid overwriting inputs in non-async mode
* TODO 6.0 remove this, as non-async mode will go away
* @type {Batch | null}
*/
export let previous_batch = null;
/**
* When time travelling (i.e. working in one batch, while other batches
* still have ongoing work), we ignore the real values of affected
* signals in favour of their values within the batch
* @type {Map<Value, any> | null}
*/
export let batch_values = null;
// TODO this should really be a property of `batch`
/** @type {Effect[]} */
let queued_root_effects = [];
/** @type {Effect | null} */
let last_scheduled_effect = null;
let is_flushing = false;
export let is_flushing_sync = false;
export class Batch {
committed = false;
/**
* The current values of any sources that are updated in this batch
* They keys of this map are identical to `this.#previous`
* @type {Map<Source, any>}
*/
current = new Map();
/**
* The values of any sources that are updated in this batch _before_ those updates took place.
* They keys of this map are identical to `this.#current`
* @type {Map<Source, any>}
*/
previous = new Map();
/**
* When the batch is committed (and the DOM is updated), we need to remove old branches
* and append new ones by calling the functions added inside (if/each/key/etc) blocks
* @type {Set<() => void>}
*/
#commit_callbacks = new Set();
/**
* If a fork is discarded, we need to destroy any effects that are no longer needed
* @type {Set<(batch: Batch) => void>}
*/
#discard_callbacks = new Set();
/**
* The number of async effects that are currently in flight
*/
#pending = 0;
/**
* The number of async effects that are currently in flight, _not_ inside a pending boundary
*/
#blocking_pending = 0;
/**
* A deferred that resolves when the batch is committed, used with `settled()`
* TODO replace with Promise.withResolvers once supported widely enough
* @type {{ promise: Promise<void>, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null}
*/
#deferred = null;
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set<Effect>}
*/
#dirty_effects = new Set();
/**
* Deferred effects that are MAYBE_DIRTY
* @type {Set<Effect>}
*/
#maybe_dirty_effects = new Set();
/**
* A set of branches that still exist, but will be destroyed when this batch
* is committed — we skip over these during `process`
* @type {Set<Effect>}
*/
skipped_effects = new Set();
is_fork = false;
is_deferred() {
return this.is_fork || this.#blocking_pending > 0;
}
/**
*
* @param {Effect[]} root_effects
*/
process(root_effects) {
queued_root_effects = [];
previous_batch = null;
this.apply();
/** @type {EffectTarget} */
var target = {
parent: null,
effect: null,
effects: [],
render_effects: []
};
for (const root of root_effects) {
this.#traverse_effect_tree(root, target);
// Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects,
// which means queued_root_effects now may be filled again.
// Helpful for debugging reactivity loss that has to do with branches being skipped:
// log_inconsistent_branches(root);
}
if (!this.is_fork) {
this.#resolve();
}
if (this.is_deferred()) {
this.#defer_effects(target.effects);
this.#defer_effects(target.render_effects);
} else {
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this;
current_batch = null;
flush_queued_effects(target.render_effects);
flush_queued_effects(target.effects);
previous_batch = null;
this.#deferred?.resolve();
}
batch_values = null;
}
/**
* Traverse the effect tree, executing effects or stashing
* them for later execution as appropriate
* @param {Effect} root
* @param {EffectTarget} target
*/
#traverse_effect_tree(root, target) {
root.f ^= CLEAN;
var effect = root.first;
while (effect !== null) {
var flags = effect.f;
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect);
if ((effect.f & BOUNDARY_EFFECT) !== 0 && effect.b?.is_pending()) {
target = {
parent: target,
effect,
effects: [],
render_effects: []
};
}
if (!skip && effect.fn !== null) {
if (is_branch) {
effect.f ^= CLEAN;
} else if ((flags & EFFECT) !== 0) {
target.effects.push(effect);
} else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
target.render_effects.push(effect);
} else if (is_dirty(effect)) {
if ((effect.f & BLOCK_EFFECT) !== 0) this.#dirty_effects.add(effect);
update_effect(effect);
}
var child = effect.first;
if (child !== null) {
effect = child;
continue;
}
}
var parent = effect.parent;
effect = effect.next;
while (effect === null && parent !== null) {
if (parent === target.effect) {
// TODO rather than traversing into pending boundaries and deferring the effects,
// could we just attach the effects _to_ the pending boundary and schedule them
// once the boundary is ready?
this.#defer_effects(target.effects);
this.#defer_effects(target.render_effects);
target = /** @type {EffectTarget} */ (target.parent);
}
effect = parent.next;
parent = parent.parent;
}
}
}
/**
* @param {Effect[]} effects
*/
#defer_effects(effects) {
for (const e of effects) {
if ((e.f & DIRTY) !== 0) {
this.#dirty_effects.add(e);
} else if ((e.f & MAYBE_DIRTY) !== 0) {
this.#maybe_dirty_effects.add(e);
}
// Since we're not executing these effects now, we need to clear any WAS_MARKED flags
// so that other batches can correctly reach these effects during their own traversal
this.#clear_marked(e.deps);
// mark as clean so they get scheduled if they depend on pending async state
set_signal_status(e, CLEAN);
}
}
/**
* @param {Value[] | null} deps
*/
#clear_marked(deps) {
if (deps === null) return;
for (const dep of deps) {
if ((dep.f & DERIVED) === 0 || (dep.f & WAS_MARKED) === 0) {
continue;
}
dep.f ^= WAS_MARKED;
this.#clear_marked(/** @type {Derived} */ (dep).deps);
}
}
/**
* Associate a change to a given source with the current
* batch, noting its previous and current values
* @param {Source} source
* @param {any} value
*/
capture(source, value) {
if (!this.previous.has(source)) {
this.previous.set(source, value);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
this.current.set(source, source.v);
batch_values?.set(source, source.v);
}
}
activate() {
current_batch = this;
this.apply();
}
deactivate() {
// If we're not the current batch, don't deactivate,
// else we could create zombie batches that are never flushed
if (current_batch !== this) return;
current_batch = null;
batch_values = null;
}
flush() {
this.activate();
if (queued_root_effects.length > 0) {
flush_effects();
if (current_batch !== null && current_batch !== this) {
// this can happen if a new batch was created during `flush_effects()`
return;
}
} else if (this.#pending === 0) {
this.process([]); // TODO this feels awkward
}
this.deactivate();
}
discard() {
for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear();
}
#resolve() {
if (this.#blocking_pending === 0) {
// append/remove branches
for (const fn of this.#commit_callbacks) fn();
this.#commit_callbacks.clear();
}
if (this.#pending === 0) {
this.#commit();
}
}
#commit() {
// If there are other pending batches, they now need to be 'rebased' —
// in other words, we re-run block/async effects with the newly
// committed state, unless the batch in question has a more
// recent value for a given source
if (batches.size > 1) {
this.previous.clear();
var previous_batch_values = batch_values;
var is_earlier = true;
/** @type {EffectTarget} */
var dummy_target = {
parent: null,
effect: null,
effects: [],
render_effects: []
};
for (const batch of batches) {
if (batch === this) {
is_earlier = false;
continue;
}
/** @type {Source[]} */
const sources = [];
for (const [source, value] of this.current) {
if (batch.current.has(source)) {
if (is_earlier && value !== batch.current.get(source)) {
// bring the value up to date
batch.current.set(source, value);
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
continue;
}
}
sources.push(source);
}
if (sources.length === 0) {
continue;
}
// Re-run async/block effects that depend on distinct values changed in both batches
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
if (others.length > 0) {
// Avoid running queued root effects on the wrong branch
var prev_queued_root_effects = queued_root_effects;
queued_root_effects = [];
/** @type {Set<Value>} */
const marked = new Set();
/** @type {Map<Reaction, boolean>} */
const checked = new Map();
for (const source of sources) {
mark_effects(source, others, marked, checked);
}
if (queued_root_effects.length > 0) {
current_batch = batch;
batch.apply();
for (const root of queued_root_effects) {
batch.#traverse_effect_tree(root, dummy_target);
}
// TODO do we need to do anything with `target`? defer block effects?
batch.deactivate();
}
queued_root_effects = prev_queued_root_effects;
}
}
current_batch = null;
batch_values = previous_batch_values;
}
this.committed = true;
batches.delete(this);
}
/**
*
* @param {boolean} blocking
*/
increment(blocking) {
this.#pending += 1;
if (blocking) this.#blocking_pending += 1;
}
/**
*
* @param {boolean} blocking
*/
decrement(blocking) {
this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1;
this.revive();
}
revive() {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
schedule_effect(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
}
this.flush();
}
/** @param {() => void} fn */
oncommit(fn) {
this.#commit_callbacks.add(fn);
}
/** @param {(batch: Batch) => void} fn */
ondiscard(fn) {
this.#discard_callbacks.add(fn);
}
settled() {
return (this.#deferred ??= deferred()).promise;
}
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
batches.add(current_batch);
if (!is_flushing_sync) {
Batch.enqueue(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
return;
}
batch.flush();
});
}
}
return current_batch;
}
/** @param {() => void} task */
static enqueue(task) {
queue_micro_task(task);
}
apply() {
if (!async_mode_flag || (!this.is_fork && batches.size === 1)) return;
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
batch_values = new Map(this.current);
// ...and undo changes belonging to other batches
for (const batch of batches) {
if (batch === this) continue;
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
batch_values.set(source, previous);
}
}
}
}
}
/**
* Synchronously flush any pending updates.
* Returns void if no callback is provided, otherwise returns the result of calling the callback.
* @template [T=void]
* @param {(() => T) | undefined} [fn]
* @returns {T}
*/
export function flushSync(fn) {
var was_flushing_sync = is_flushing_sync;
is_flushing_sync = true;
try {
var result;
if (fn) {
if (current_batch !== null) {
flush_effects();
}
result = fn();
}
while (true) {
flush_tasks();
if (queued_root_effects.length === 0) {
current_batch?.flush();
// we need to check again, in case we just updated an `$effect.pending()`
if (queued_root_effects.length === 0) {
// this would be reset in `flush_effects()` but since we are early returning here,
// we need to reset it here as well in case the first time there's 0 queued root effects
last_scheduled_effect = null;
return /** @type {T} */ (result);
}
}
flush_effects();
}
} finally {
is_flushing_sync = was_flushing_sync;
}
}
function flush_effects() {
var was_updating_effect = is_updating_effect;
is_flushing = true;
var source_stacks = DEV ? new Set() : null;
try {
var flush_count = 0;
set_is_updating_effect(true);
while (queued_root_effects.length > 0) {
var batch = Batch.ensure();
if (flush_count++ > 1000) {
if (DEV) {
var updates = new Map();
for (const source of batch.current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);
if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}
entry.count += update.count;
}
}
for (const update of updates.values()) {
if (update.error) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}
}
infinite_loop_guard();
}
batch.process(queued_root_effects);
old_values.clear();
if (DEV) {
for (const source of batch.current.keys()) {
/** @type {Set<Source>} */ (source_stacks).add(source);
}
}
}
} finally {
is_flushing = false;
set_is_updating_effect(was_updating_effect);
last_scheduled_effect = null;
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
source.updated = null;
}
}
}
}
function infinite_loop_guard() {
try {
e.effect_update_depth_exceeded();
} catch (error) {
if (DEV) {
// stack contains no useful information, replace it
define_property(error, 'stack', { value: '' });
}
// Best effort: invoke the boundary nearest the most recent
// effect and hope that it's relevant to the infinite loop
invoke_error_boundary(error, last_scheduled_effect);
}
}
/** @type {Set<Effect> | null} */
export let eager_block_effects = null;
/**
* @param {Array<Effect>} effects
* @returns {void}
*/
function flush_queued_effects(effects) {
var length = effects.length;
if (length === 0) return;
var i = 0;
while (i < length) {
var effect = effects[i++];
if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) {
eager_block_effects = new Set();
update_effect(effect);
// Effects with no dependencies or teardown do not get added to the effect tree.
// Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we
// don't know if we need to keep them until they are executed. Doing the check
// here (rather than in `update_effect`) allows us to skip the work for
// immediate effects.
if (effect.deps === null && effect.first === null && effect.nodes === null) {
// if there's no teardown or abort controller we completely unlink
// the effect from the graph
if (effect.teardown === null && effect.ac === null) {
// remove this effect from the graph
unlink_effect(effect);
} else {
// keep the effect in the graph, but free up some memory
effect.fn = null;
}
}
// If update_effect() has a flushSync() in it, we may have flushed another flush_queued_effects(),
// which already handled this logic and did set eager_block_effects to null.
if (eager_block_effects?.size > 0) {
old_values.clear();
for (const e of eager_block_effects) {
// Skip eager effects that have already been unmounted
if ((e.f & (DESTROYED | INERT)) !== 0) continue;
// Run effects in order from ancestor to descendant, else we could run into nullpointers
/** @type {Effect[]} */
const ordered_effects = [e];
let ancestor = e.parent;
while (ancestor !== null) {
if (eager_block_effects.has(ancestor)) {
eager_block_effects.delete(ancestor);
ordered_effects.push(ancestor);
}
ancestor = ancestor.parent;
}
for (let j = ordered_effects.length - 1; j >= 0; j--) {
const e = ordered_effects[j];
// Skip eager effects that have already been unmounted
if ((e.f & (DESTROYED | INERT)) !== 0) continue;
update_effect(e);
}
}
eager_block_effects.clear();
}
}
}
eager_block_effects = null;
}
/**
* This is similar to `mark_reactions`, but it only marks async/block effects
* depending on `value` and at least one of the other `sources`, so that
* these effects can re-run after another batch has been committed
* @param {Value} value
* @param {Source[]} sources
* @param {Set<Value>} marked
* @param {Map<Reaction, boolean>} checked
*/
function mark_effects(value, sources, marked, checked) {
if (marked.has(value)) return;
marked.add(value);
if (value.reactions !== null) {
for (const reaction of value.reactions) {
const flags = reaction.f;
if ((flags & DERIVED) !== 0) {
mark_effects(/** @type {Derived} */ (reaction), sources, marked, checked);
} else if (
(flags & (ASYNC | BLOCK_EFFECT)) !== 0 &&
(flags & DIRTY) === 0 &&
depends_on(reaction, sources, checked)
) {
set_signal_status(reaction, DIRTY);
schedule_effect(/** @type {Effect} */ (reaction));
}
}
}
}
/**
* When committing a fork, we need to trigger eager effects so that
* any `$state.eager(...)` expressions update immediately. This
* function allows us to discover them
* @param {Value} value
* @param {Set<Effect>} effects
*/
function mark_eager_effects(value, effects) {
if (value.reactions === null) return;
for (const reaction of value.reactions) {
const flags = reaction.f;
if ((flags & DERIVED) !== 0) {
mark_eager_effects(/** @type {Derived} */ (reaction), effects);
} else if ((flags & EAGER_EFFECT) !== 0) {
set_signal_status(reaction, DIRTY);
effects.add(/** @type {Effect} */ (reaction));
}
}
}
/**
* @param {Reaction} reaction
* @param {Source[]} sources
* @param {Map<Reaction, boolean>} checked
*/
function depends_on(reaction, sources, checked) {
const depends = checked.get(reaction);
if (depends !== undefined) return depends;
if (reaction.deps !== null) {
for (const dep of reaction.deps) {
if (sources.includes(dep)) {
return true;
}
if ((dep.f & DERIVED) !== 0 && depends_on(/** @type {Derived} */ (dep), sources, checked)) {
checked.set(/** @type {Derived} */ (dep), true);
return true;
}
}
}
checked.set(reaction, false);
return false;
}
/**
* @param {Effect} signal
* @returns {void}
*/
export function schedule_effect(signal) {
var effect = (last_scheduled_effect = signal);
while (effect.parent !== null) {
effect = effect.parent;
var flags = effect.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, bail out or we'll cause a second flush
if (
is_flushing &&
effect === active_effect &&
(flags & BLOCK_EFFECT) !== 0 &&
(flags & HEAD_EFFECT) === 0
) {
return;
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) return;
effect.f ^= CLEAN;
}
}
queued_root_effects.push(effect);
}
/** @type {Source<number>[]} */
let eager_versions = [];
function eager_flush() {
try {
flushSync(() => {
for (const version of eager_versions) {
update(version);
}
});
} finally {
eager_versions = [];
}
}
/**
* Implementation of `$state.eager(fn())`
* @template T
* @param {() => T} fn
* @returns {T}
*/
export function eager(fn) {
var version = source(0);
var initial = true;
var value = /** @type {T} */ (undefined);
get(version);
eager_effect(() => {
if (initial) {
// the first time this runs, we create an eager effect
// that will run eagerly whenever the expression changes
var previous_batch_values = batch_values;
try {
batch_values = null;
value = fn();
} finally {
batch_values = previous_batch_values;
}
return;
}
// the second time this effect runs, it's to schedule a
// `version` update. since this will recreate the effect,
// we don't need to evaluate the expression here
if (eager_versions.length === 0) {
queue_micro_task(eager_flush);
}
eager_versions.push(version);
});
initial = false;
return value;
}
/**
* Creates a 'fork', in which state changes are evaluated but not applied to the DOM.
* This is useful for speculatively loading data (for example) when you suspect that
* the user is about to take some action.
*
* Frameworks like SvelteKit can use this to preload data when the user touches or
* hovers over a link, making any subsequent navigation feel instantaneous.
*
* The `fn` parameter is a synchronous function that modifies some state. The
* state changes will be reverted after the fork is initialised, then reapplied
* if and when the fork is eventually committed.
*
* When it becomes clear that a fork will _not_ be committed (e.g. because the
* user navigated elsewhere), it must be discarded to avoid leaking memory.
*
* @param {() => void} fn
* @returns {Fork}
* @since 5.42
*/
export function fork(fn) {
if (!async_mode_flag) {
e.experimental_async_required('fork');
}
if (current_batch !== null) {
e.fork_timing();
}
var batch = Batch.ensure();
batch.is_fork = true;
batch_values = new Map();
var committed = false;
var settled = batch.settled();
flushSync(fn);
batch_values = null;
// revert state changes
for (var [source, value] of batch.previous) {
source.v = value;
}
return {
commit: async () => {
if (committed) {
await settled;
return;
}
if (!batches.has(batch)) {
e.fork_discarded();
}
committed = true;
batch.is_fork = false;
// apply changes
for (var [source, value] of batch.current) {
source.v = value;
}
// trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we
// can't just encounter them during traversal, we need to
// proactively flush them
// TODO maybe there's a better implementation?
flushSync(() => {
/** @type {Set<Effect>} */
var eager_effects = new Set();
for (var source of batch.current.keys()) {
mark_eager_effects(source, eager_effects);
}
set_eager_effects(eager_effects);
flush_eager_effects();
});
batch.revive();
await settled;
},
discard: () => {
if (!committed && batches.has(batch)) {
batches.delete(batch);
batch.discard();
}
}
};
}
/**
* Forcibly remove all current batches, to prevent cross-talk between tests
*/
export function clear() {
batches.clear();
}