UNPKG

svelte

Version:

Cybernetically enhanced web apps

606 lines (495 loc) 14.3 kB
/** @import { Derived, Effect, Source } from '#client' */ import { BLOCK_EFFECT, BRANCH_EFFECT, CLEAN, DESTROYED, DIRTY, EFFECT, ASYNC, INERT, RENDER_EFFECT, ROOT_EFFECT, USER_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; import { get_pending_boundary } from '../dom/blocks/boundary.js'; import { active_effect, is_dirty, is_updating_effect, set_is_updating_effect, set_signal_status, update_effect, write_version } from '../runtime.js'; import * as e from '../errors.js'; import { flush_tasks } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; import { old_values } from './sources.js'; import { unlink_effect } from './effects.js'; import { unset_context } from './async.js'; /** @type {Set<Batch>} */ const batches = new Set(); /** @type {Batch | null} */ export let current_batch = null; /** * When time travelling, we re-evaluate deriveds based on the temporary * values of their dependencies rather than their actual values, and cache * the results in this map rather than on the deriveds themselves * @type {Map<Derived, any> | null} */ export let batch_deriveds = null; /** @type {Set<() => void>} */ export let effect_pending_updates = new Set(); /** @type {Effect[]} */ let queued_root_effects = []; /** @type {Effect | null} */ let last_scheduled_effect = null; let is_flushing = false; export class Batch { /** * 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>} */ #callbacks = new Set(); /** * The number of async effects that are currently in flight */ #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; /** * True if an async effect inside this batch resolved and * its parent branch was already deleted */ #neutered = false; /** * Async effects (created inside `async_derived`) encountered during processing. * These run after the rest of the batch has updated, since they should * always have the latest values * @type {Effect[]} */ #async_effects = []; /** * The same as `#async_effects`, but for effects inside a newly-created * `<svelte:boundary>` — these do not prevent the batch from committing * @type {Effect[]} */ #boundary_async_effects = []; /** * Template effects and `$effect.pre` effects, which run when * a batch is committed * @type {Effect[]} */ #render_effects = []; /** * The same as `#render_effects`, but for `$effect` (which runs after) * @type {Effect[]} */ #effects = []; /** * Block effects, which may need to re-run on subsequent flushes * in order to update internal sources (e.g. each block items) * @type {Effect[]} */ #block_effects = []; /** * 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(); /** * * @param {Effect[]} root_effects */ #process(root_effects) { queued_root_effects = []; /** @type {Map<Source, { v: unknown, wv: number }> | null} */ var current_values = null; // if there are multiple batches, we are 'time travelling' — // we need to undo the changes belonging to any batch // other than the current one if (batches.size > 1) { current_values = new Map(); batch_deriveds = new Map(); for (const [source, current] of this.#current) { current_values.set(source, { v: source.v, wv: source.wv }); source.v = current; } for (const batch of batches) { if (batch === this) continue; for (const [source, previous] of batch.#previous) { if (!current_values.has(source)) { current_values.set(source, { v: source.v, wv: source.wv }); source.v = previous; } } } } for (const root of root_effects) { this.#traverse_effect_tree(root); } // if we didn't start any new async work, and no async work // is outstanding from a previous flush, commit if (this.#async_effects.length === 0 && this.#pending === 0) { var render_effects = this.#render_effects; var effects = this.#effects; this.#render_effects = []; this.#effects = []; this.#block_effects = []; this.#commit(); flush_queued_effects(render_effects); flush_queued_effects(effects); this.#deferred?.resolve(); } else { // otherwise mark effects clean so they get scheduled on the next run for (const e of this.#render_effects) set_signal_status(e, CLEAN); for (const e of this.#effects) set_signal_status(e, CLEAN); for (const e of this.#block_effects) set_signal_status(e, CLEAN); } if (current_values) { for (const [source, { v, wv }] of current_values) { // reset the source to the current value (unless // it got a newer value as a result of effects running) if (source.wv <= wv) { source.v = v; } } batch_deriveds = null; } for (const effect of this.#async_effects) { update_effect(effect); } for (const effect of this.#boundary_async_effects) { update_effect(effect); } this.#async_effects = []; this.#boundary_async_effects = []; } /** * Traverse the effect tree, executing effects or stashing * them for later execution as appropriate * @param {Effect} root */ #traverse_effect_tree(root) { 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 (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; } else if ((flags & EFFECT) !== 0) { this.#effects.push(effect); } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { this.#render_effects.push(effect); } else if (is_dirty(effect)) { if ((flags & ASYNC) !== 0) { var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects; effects.push(effect); } else { if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(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) { effect = parent.next; parent = parent.parent; } } } /** * 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); } this.#current.set(source, source.v); } activate() { current_batch = this; } deactivate() { current_batch = null; for (const update of effect_pending_updates) { effect_pending_updates.delete(update); update(); if (current_batch !== null) { // only do one at a time break; } } } neuter() { this.#neutered = true; } flush() { if (queued_root_effects.length > 0) { this.flush_effects(); } else { this.#commit(); } if (current_batch !== this) { // this can happen if a `flushSync` occurred during `this.flush_effects()`, // which is permitted in legacy mode despite being a terrible idea return; } if (this.#pending === 0) { batches.delete(this); } this.deactivate(); } flush_effects() { var was_updating_effect = is_updating_effect; is_flushing = true; try { var flush_count = 0; set_is_updating_effect(true); while (queued_root_effects.length > 0) { if (flush_count++ > 1000) { if (DEV) { var updates = new Map(); for (const source of this.#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()) { // eslint-disable-next-line no-console console.error(update.error); } } infinite_loop_guard(); } this.#process(queued_root_effects); old_values.clear(); } } finally { is_flushing = false; set_is_updating_effect(was_updating_effect); last_scheduled_effect = null; } } /** * Append and remove branches to/from the DOM */ #commit() { if (!this.#neutered) { for (const fn of this.#callbacks) { fn(); } } this.#callbacks.clear(); } increment() { this.#pending += 1; } decrement() { this.#pending -= 1; if (this.#pending === 0) { for (const e of this.#render_effects) { set_signal_status(e, DIRTY); schedule_effect(e); } for (const e of this.#effects) { set_signal_status(e, DIRTY); schedule_effect(e); } for (const e of this.#block_effects) { set_signal_status(e, DIRTY); schedule_effect(e); } this.#render_effects = []; this.#effects = []; this.flush(); } else { this.deactivate(); } } /** @param {() => void} fn */ add_callback(fn) { this.#callbacks.add(fn); } settled() { return (this.#deferred ??= deferred()).promise; } static ensure(autoflush = true) { if (current_batch === null) { const batch = (current_batch = new Batch()); batches.add(current_batch); if (autoflush) { queueMicrotask(() => { if (current_batch !== batch) { // a flushSync happened in the meantime return; } batch.flush(); }); } } return current_batch; } } /** * 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) { if (async_mode_flag && active_effect !== null) { e.flush_sync_in_effect(); } var result; const batch = Batch.ensure(false); if (fn) { batch.flush_effects(); result = fn(); } while (true) { flush_tasks(); if (queued_root_effects.length === 0) { if (batch === current_batch) { batch.flush(); } // this would be reset in `batch.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); } batch.flush_effects(); } } 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); } } /** * @param {Array<Effect>} effects * @returns {void} */ function flush_queued_effects(effects) { var length = effects.length; if (length === 0) return; for (var i = 0; i < length; i++) { var effect = effects[i]; if ((effect.f & (DESTROYED | INERT)) === 0) { if (is_dirty(effect)) { var wv = write_version; 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_start === null) { if (effect.teardown === 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 state is written in a user effect, abort and re-schedule, lest we run // effects that should be removed as a result of the state change if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { break; } } } } for (; i < length; i += 1) { schedule_effect(effects[i]); } } /** * @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) { return; } if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { if ((flags & CLEAN) === 0) return; effect.f ^= CLEAN; } } queued_root_effects.push(effect); } export function suspend() { var boundary = get_pending_boundary(); var batch = /** @type {Batch} */ (current_batch); var pending = boundary.pending; boundary.update_pending_count(1); if (!pending) batch.increment(); return function unsuspend() { boundary.update_pending_count(-1); if (!pending) batch.decrement(); unset_context(); }; } /** * Forcibly remove all current batches, to prevent cross-talk between tests */ export function clear() { batches.clear(); }