UNPKG

rvx

Version:

A signal based rendering library

668 lines (626 loc) 15.7 kB
import { Context } from "./context.js"; import { NOOP } from "./internals/noop.js"; import { ACCESS_STACK, AccessHook, NotifyHook, TRACKING_STACK, useStack } from "./internals/stacks.js"; import { isolate } from "./isolate.js"; import { capture, teardown, TeardownHook } from "./lifecycle.js"; /** * During a {@link batch}, notify hooks are added to this set instead of being called. * * Outside of a batch, this is undefined. */ let BATCH: Set<NotifyHook> | undefined; /** * Internal function to call the specified hook. */ const notify = (fn: NotifyHook) => fn(); /** * Internal function to add the specified hook to the current batch. */ const queueBatch = (fn: NotifyHook) => BATCH!.add(fn); /** * Represents the source that a signal has been derived from. * * When deriving a signal, the source should be passed via the signal constructor or shorthand. * This has no impact on how a signal behaves, but allows other APIs to locate metadata about a signal's source. * * @example * ```js * function trim(source: Signal<string>) { * const input = $(source.value, source); * ... * return input; * } * ``` */ export type SignalSource = Signal<unknown> | undefined; /** * Represents a value that changes over time. */ export class Signal<T> { /** * The current value. */ #value: T; /** * A set of hooks that are called in iteration order by this signal to notify it's observers. * * + This is cleared before hooks are called. * + Observers get a permanent reference to this set to manually add or remove themselves. */ #hooks = new Set<NotifyHook>(); /** * The {@link SignalSource source} this signal has been derived from. */ #source: SignalSource; /** * The {@link SignalSource source root}. */ #root: Signal<unknown>; /** * Create a new signal. * * @param value The initial value. * @param source The {@link SignalSource source} this signal has been derived from. */ constructor(value: T, source?: SignalSource) { this.#value = value; this.#source = source; this.#root = source ? source.#root : this; } /** * Reactively access the current value. */ get value(): T { this.access(); return this.#value; } /** * Set the current value. * * If the new value is the same as the previous one, no observers are notified. * * @example * ```tsx * import { $, watch } from "rvx"; * * const count = $(0); * * watch(count, count => { * console.log("Count:", count); * }); * * count.value++; * ``` */ set value(value: T) { if (!Object.is(this.#value, value)) { this.#value = value; this.notify(); } } /** * The {@link SignalSource source}, this signal has been derived from. */ get source(): SignalSource { return this.#source; } /** * The root {@link SignalSource source}, this signal has been derived from or this signal itself if it hasn't been derived. */ get root(): Signal<unknown> { return this.#root; } /** * Update the current value in place. * * @param fn A function to update the value. If false is returned, observers are not notified. * * @example * ```tsx * import { $, watch } from "rvx"; * * const items = $([]); * * watch(items, items => { * console.log("Items:", items); * }); * * items.update(items => { * items.push("foo"); * items.push("bar"); * }); * ``` */ update(fn: (value: T) => void | boolean): void { if (fn(this.#value) !== false) { this.notify(); } } /** * Check if this signal has any active observers. */ get active(): boolean { return this.#hooks.size > 0; } /** * Manually access this signal. */ access(): void { if (TRACKING_STACK[TRACKING_STACK.length - 1]) { const length = ACCESS_STACK.length; if (length > 0) { ACCESS_STACK[length - 1]?.(this.#hooks); } } } /** * Manually notify observers. * * During batches, notifications are deferred. */ notify(): void { const hooks = this.#hooks; if (hooks.size === 0) { return; } if (BATCH === undefined) { const record = Array.from(hooks); hooks.clear(); record.forEach(notify); } else { hooks.forEach(queueBatch); /* Hooks are not cleared during batches to prevent breaking other observers if an error is thrown during the batch. Calls are deduplicated within the batch. */ } } /** * Pass this signal to a function and get it's result. * * @example * ```tsx * const value = $(42); * * <TextInput value={ * value * .pipe(parseInt) * .pipe(trim) * } /> * ``` */ pipe<A extends any[], R>(fn: (self: this, ...args: A) => R, ...args: A): R { return fn(this, ...args); } } /** * Create a new signal. * * @param value The initial value. * @param source The {@link SignalSource source} this signal has been derived from. * @returns The signal. */ export function $(): Signal<void>; export function $<T>(value: T, source?: SignalSource): Signal<T>; export function $(value?: unknown, source?: SignalSource): Signal<unknown> { return new Signal(value, source); } /** * A value, signal or function to get a value. * * @example * ```tsx * import { $, watch } from "rvx"; * * const message = $("Example"); * * // Not reactive: * watch(message.value, message => { * console.log("A:", message); * }); * * // Reactive: * watch(message, message => { * console.log("B:", message); * }); * * // Reactive: * watch(() => message.value, message => { * console.log("C:", message); * }); * * message.value = "Hello World!"; * ``` */ export type Expression<T> = T | Signal<T> | (() => T); /** * Utility to get the result type of an expression. */ export type ExpressionResult<T> = T extends Expression<infer R> ? R : never; /** * Utility type for expressions that should never be static values. * * This can be used instead of the {@link Expression} type in places where accepting static values doesn't make sense. */ export type Reactive<T> = Signal<T> | (() => T); /** * Internal utility to unfold potential recursion into a sequence. */ const _unfold = (hook: NotifyHook): NotifyHook => { let depth = 0; return () => { if (depth < 2) { depth++; } if (depth === 1) { try { while (depth > 0) { hook(); depth--; } } finally { depth = 0; } } }; }; interface Observer { /** * The access hook that can be pushed to the {@link ACCESS_STACK} to track signal accesses using this observer. */ a: AccessHook; /** * Detach this observer from all currently accessed signals. */ c: () => void; } /** * Internal utility to create an observer for tracking signal accesses. * * @param hook The notify hook to add to all accessed signals. */ const _observer = (hook: NotifyHook): Observer => { /** Array of the hook sets of currently accessed signals. This can contain duplicates. */ const signals: Set<NotifyHook>[] = []; return { c: () => { for (let i = 0; i < signals.length; i++) { signals[i].delete(hook); } signals.length = 0; }, a: (hooks: Set<NotifyHook>): void => { signals.push(hooks); hooks.add(hook); }, }; }; /** * Internal utility to call a function while tracking signal accesses. * * @param frame The access hook. * @param fn The function to call. * @returns The function's return value. */ const _access = <T>(frame: AccessHook | undefined, fn: () => T): T => { try { ACCESS_STACK.push(frame); TRACKING_STACK.push(true); return fn(); } finally { ACCESS_STACK.pop(); TRACKING_STACK.pop(); } }; /** * Watch an expression until the current lifecycle is disposed. * * + Both the expression and effect are called at least once immediately. * + Lifecycle hooks from the expression or effect are called before a signal update is processed or when the current lifecycle is disposed. * * @param expr The expression to watch. * @param effect An optional function to call with each expression result without tracking signal accesses. * * @example * ```tsx * import { $, watch } from "rvx"; * * const count = $(0); * * // Capture teardown hooks registered by "watch": * const dispose = capture(() => { * // Start watching: * watch(count, count => { * console.log("Count:", count); * }); * }); * * count.value = 1; * * // Stop watching: * dispose(); * * count.value = 2; * ``` */ export function watch(expr: () => void): void; export function watch<T>(expr: Expression<T>, effect: (value: T) => void): void; export function watch<T>(expr: Expression<T>, effect?: (value: T) => void): void { const isSignal = expr instanceof Signal; if (isSignal || typeof expr === "function") { let value: T; let disposed = false; let dispose: TeardownHook = NOOP; const runExpr = isSignal ? () => (expr as Signal<T>).value : (expr as () => T); const entry = _unfold(Context.wrap(() => { if (disposed) { // This covers an edge case where this observer is notified during a batch and then disposed immediately. return; } clear(); isolate(dispose); dispose = capture(() => { value = _access(access, runExpr); if (effect) { _access(undefined, () => effect(value)); } }); })); const { c: clear, a: access } = _observer(entry); teardown(() => { disposed = true; clear(); dispose(); }); entry(); } else { effect!(expr); } } /** * Watch an expression until the current lifecycle is disposed. * * @param expr The expression to watch. * @param effect A function to call with each subsequent expression result without tracking signal accesses. * @returns The first expression result. */ export function watchUpdates<T>(expr: Expression<T>, effect: (value: T) => void): T { let first: T; let update = false; watch(expr, value => { if (update) { effect(value); } else { first = value; update = true; } }); return first!; } function dispatch(batch: Set<NotifyHook>): void { while (batch.size > 0) { try { batch.forEach(notify => { /* Notify hooks are deleted individually to ensure the correct behavior if calling the hooks adds itself to the batch again due to an immediate side effect. */ batch.delete(notify); notify(); }); } finally { dispatch(batch); } } } /** * Defer signal updates until a function finishes. * * + When nesting batches, updates are processed after the most outer batch has completed. * + When updates cause immediate side effects, these side effects will run as part of the batch. * * @param fn The function to run. * @returns The function's return value. * * @example * The example below outputs `5` and `9` once. Without batching the output would be `5, 7, 9`. * ```tsx * import { batch, $, watch } from "rvx"; * * const a = $(2); * const b = $(3); * * watch(() => a.value + b.value, value => { * console.log("Sum:", value); * }); * * batch(() => { * a.value = 4; * b.value = 5; * }); * ``` */ export function batch<T>(fn: () => T): T { if (BATCH === undefined) { const batch = new Set<NotifyHook>(); let value: T; try { BATCH = batch; value = fn(); dispatch(batch); } finally { BATCH = undefined; } return value; } return fn(); } /** * {@link watch Watch} an expression and get a function to reactively access it's result. * * @param expr The expression to watch. * @returns A function to reactively access the latest result. * * @example * ```tsx * import { $, memo, watch } from "rvx"; * * const count = $(42); * * const computed = memo(() => someExpensiveComputation(count.value)); * * watch(computed, count => { * console.log("Count:", count); * }); * ``` */ export function memo<T>(expr: Expression<T>): () => T { const signal = $<T>(undefined!); watch(() => signal.value = get(expr)); return () => signal.value; } /** * Run a function while not tracking signal accesses. * * This is the opposite of {@link track}. * * @param fn The function to run. * @returns The function's return value. * * @example * ```tsx * import { $, untrack, watch } from "rvx"; * * const a = $(2); * const b = $(3); * * watch(() => a.value + untrack(() => b.value), sum => { * console.log("Sum:", sum); * }); * * a.value = 4; * b.value = 5; * ``` */ export function untrack<T>(fn: () => T): T { return useStack(TRACKING_STACK, false, fn); } /** * Run a function while tracking signal accesses. This is the default behavior. * * This is the opposite of {@link untrack}. * * @param fn The function to run. * @returns The function's return value. */ export function track<T>(fn: () => T): T { return useStack(TRACKING_STACK, true, fn); } /** * Check if signal accesses are currently tracked. */ export function isTracking(): boolean { return TRACKING_STACK[TRACKING_STACK.length - 1] && ACCESS_STACK[ACCESS_STACK.length - 1] !== undefined; } /** * Run a function while tracking signal accesses to invoke the trigger callback when updated. * * See {@link trigger}. */ export interface TriggerPipe { <T>(fn: Expression<T>): T; } /** * Create an expression evaluator pipe that calls a function once when any accessed signals from the latest evaluated expression are updated. * * + When the lifecycle at which the pipe was created is disposed, the callback function will not be called anymore. * + It is guaranteed that the function is called before any other observers like {@link watch} or {@link effect} are notified. * + If pipes are nested, the callback for the most inner one is called first. * * @param callback The callback to invoke when a signal is updated. * @returns The pipe to evaluate expressions. */ export function trigger(callback: () => void): TriggerPipe { const hookFn = Context.wrap(() => { clear(); isolate(callback); }); const { c: clear, a: access } = _observer(hookFn); teardown(clear); return <T>(expr: Expression<T>) => { clear(); try { const outerLength = ACCESS_STACK.length; if (outerLength > 0) { const outer = ACCESS_STACK[outerLength - 1]; ACCESS_STACK.push(hooks => { /* Tracking accesses using this observer before any outer ones also guarantees the order in which observers are notified because: + Hooks in Signal.#hooks are called in iteration order. + Set iteration order matches the order in which observers add their hooks. */ access(hooks); outer?.(hooks); }); } else { ACCESS_STACK.push(access); } return get(expr); } finally { ACCESS_STACK.pop(); } }; } /** * Manually evaluate an expression in the current context. * * This can be used to access reactive and non reactive inputs. * * @param expr The expression to evaluate. * @returns The expression result. * * @example * ```tsx * import { $, get } from "rvx"; * * const count = $(42); * * get(42) // 42 * get(count) // 42 * get(() => 42) // 42 * ``` */ export function get<T>(expr: Expression<T>): T { if (expr instanceof Signal) { return expr.value; } if (typeof expr === "function") { return (expr as () => T)(); } return expr; } export type MapFn<I, O> = (input: I) => O; /** * Map an expression value while preserving if the expression is static or not. * * @example * ```tsx * import { $, map, get } from "rvx"; * * const count = $(42); * const doubleCount = map(count, value => value * 2); * * get(doubleCount) // 84 * ``` */ export function map<I, O>(input: Expression<I>, mapFn: MapFn<I, O>): Expression<O> { if (input instanceof Signal) { return () => mapFn(input.value); } if (typeof input === "function") { return () => mapFn((input as () => I)()); } return mapFn(input); }