UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

781 lines (684 loc) 20 kB
import { CircularDependencyError, type Guard } from './errors' import { isRecord } from './util' /* === Internal Types === */ type SourceFields<T extends {}> = { value: T sinks: Edge | null sinksTail: Edge | null stop?: Cleanup | undefined } type OptionsFields<T extends {}> = { equals: (a: T, b: T) => boolean guard?: Guard<T> | undefined } type SinkFields = { fn: unknown flags: number sources: Edge | null sourcesTail: Edge | null } type OwnerFields = { cleanup: Cleanup | Cleanup[] | null } type AsyncFields = { controller: AbortController | undefined error: Error | undefined } type StateNode<T extends {}> = SourceFields<T> & OptionsFields<T> type MemoNode<T extends {}> = SourceFields<T> & OptionsFields<T> & SinkFields & { fn: MemoCallback<T> error: Error | undefined } type TaskNode<T extends {}> = SourceFields<T> & OptionsFields<T> & SinkFields & AsyncFields & { fn: (prev: T, abort: AbortSignal) => Promise<T> pendingNode: StateNode<boolean> } type EffectNode = SinkFields & OwnerFields & { fn: EffectCallback } type Scope = OwnerFields type SourceNode = SourceFields<unknown & {}> type SinkNode = MemoNode<unknown & {}> | TaskNode<unknown & {}> | EffectNode type OwnerNode = EffectNode | Scope type Edge = { source: SourceNode sink: SinkNode nextSource: Edge | null prevSink: Edge | null nextSink: Edge | null } /* === Public API Types === */ type Signal<T extends {}> = { get(): T } /** * A cleanup function that can be called to dispose of resources. */ type Cleanup = () => void // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type type MaybeCleanup = Cleanup | undefined | void /** * Options for configuring signal behavior. * * @template T - The type of value in the signal */ type SignalOptions<T extends {}> = { /** * Optional type guard to validate values. * If provided, will throw an error if an invalid value is set. */ guard?: Guard<T> /** * Optional custom equality function. * Used to determine if a new value is different from the old value. * Defaults to reference equality (===). */ equals?: (a: T, b: T) => boolean } type ComputedOptions<T extends {}> = SignalOptions<T> & { /** * Optional initial value. * Useful for reducer patterns so that calculations start with a value of correct type. */ value?: T /** * Optional callback invoked when the signal is first watched by an effect. * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation. * Must return a cleanup function that is called when the signal is no longer watched. * * This enables lazy resource activation for computed signals that need to * react to external events (e.g. DOM mutations, timers) in addition to * tracked signal dependencies. */ watched?: (invalidate: () => void) => Cleanup } /** * Options for configuring scope behavior. */ type ScopeOptions = { /** * When `true`, the scope is not registered on the current parent owner. * The returned `dispose` function becomes the sole mechanism for tearing down the scope. * * Use this for scopes with an external lifecycle authority (e.g. a web component * whose `disconnectedCallback` is the teardown point) — without it, a scope created * inside a re-runnable effect would be silently disposed on the next effect re-run. */ root?: boolean } /** * A callback function for memos that computes a value based on the previous value. * * @template T - The type of value computed * @param prev - The previous computed value * @returns The new computed value */ type MemoCallback<T extends {}> = (prev: T | undefined) => T /** * A callback function for tasks that asynchronously computes a value. * * @template T - The type of value computed * @param prev - The previous computed value * @param signal - An AbortSignal that will be triggered if the task is aborted * @returns A promise that resolves to the new computed value */ type TaskCallback<T extends {}> = ( prev: T | undefined, signal: AbortSignal, ) => Promise<T> /** * A callback function for effects that can perform side effects. * * @returns An optional cleanup function that will be called before the effect re-runs or is disposed */ type EffectCallback = () => MaybeCleanup /* === Constants === */ const TYPE_STATE = 'State' const TYPE_MEMO = 'Memo' const TYPE_TASK = 'Task' const TYPE_SENSOR = 'Sensor' const TYPE_LIST = 'List' const TYPE_COLLECTION = 'Collection' const TYPE_STORE = 'Store' const TYPE_SLOT = 'Slot' const FLAG_CLEAN = 0 const FLAG_CHECK = 1 << 0 const FLAG_DIRTY = 1 << 1 const FLAG_RUNNING = 1 << 2 const FLAG_RELINK = 1 << 3 /* === Module State === */ // activeSink, activeOwner, and batchDepth are exported as mutable `let` bindings. // Importers read the live value via ESM live binding semantics — this only works in // native ESM and bundlers that preserve live bindings (Rollup, esbuild ESM mode). // A pre-bundled CJS output would snapshot these at import time, silently breaking // dependency tracking and batching. The library is ESM-only by design (see REQUIREMENTS.md). let activeSink: SinkNode | null = null let activeOwner: OwnerNode | null = null const queuedEffects: EffectNode[] = [] let batchDepth = 0 let flushing = false /* === Utility Functions === */ /** * Default strict equality (`===`) — identical to the implicit default for all signals. * Pass explicitly to make the equality strategy visible when composing or overriding signal options. */ const DEFAULT_EQUALITY = <T extends {}>(a: T, b: T): boolean => a === b /** * Equality function that always returns false, causing propagation on every update. * Use with `createSensor` for observing mutable objects where the reference stays the same * but internal state changes (e.g., DOM elements observed via MutationObserver). * * @example * ```ts * const el = createSensor<HTMLElement>((set) => { * const node = document.getElementById('box')!; * set(node); * const obs = new MutationObserver(() => set(node)); * obs.observe(node, { attributes: true }); * return () => obs.disconnect(); * }, { value: node, equals: SKIP_EQUALITY }); * ``` */ const SKIP_EQUALITY = (_a?: unknown, _b?: unknown): boolean => false const deepEqual = (a: unknown, b: unknown): boolean => { if (Object.is(a, b)) return true if (typeof a !== typeof b) return false if ( a == null || typeof a !== 'object' || b == null || typeof b !== 'object' ) return false const aIsArray = Array.isArray(a) if (aIsArray !== Array.isArray(b)) return false if (aIsArray) { const aa = a as unknown[] const ba = b as unknown[] if (aa.length !== ba.length) return false for (let i = 0; i < aa.length; i++) if (!deepEqual(aa[i], ba[i])) return false return true } if (isRecord(a) && isRecord(b)) { const aKeys = Object.keys(a) if (aKeys.length !== Object.keys(b).length) return false for (const key of aKeys) { if (!(key in b)) return false if (!deepEqual(a[key], b[key])) return false } return true } return false } /** * Deep structural equality check for plain objects and arrays. * Use when a signal holds an object or array and you want to avoid unnecessary * downstream propagation when the value re-evaluates to a structurally identical result. * * @example * ```ts * const point = createState({ x: 0, y: 0 }, { equals: DEEP_EQUALITY }); * point.set({ x: 0, y: 0 }); // no propagation — structurally equal * ``` */ const DEEP_EQUALITY = <T extends {}>(a: T, b: T): boolean => deepEqual(a, b) /** * @deprecated Use {@link DEEP_EQUALITY} instead. */ const isEqual = DEEP_EQUALITY /* === Link Management === */ function isValidEdge(checkEdge: Edge, node: SinkNode): boolean { const sourcesTail = node.sourcesTail if (sourcesTail) { let edge = node.sources while (edge) { if (edge === checkEdge) return true if (edge === sourcesTail) break edge = edge.nextSource } } return false } function link(source: SourceNode, sink: SinkNode): void { const prevSource = sink.sourcesTail if (prevSource?.source === source) return let nextSource: Edge | null = null const isRecomputing = sink.flags & FLAG_RUNNING if (isRecomputing) { nextSource = prevSource ? prevSource.nextSource : sink.sources if (nextSource?.source === source) { sink.sourcesTail = nextSource return } } const prevSink = source.sinksTail if ( prevSink?.sink === sink && (!isRecomputing || isValidEdge(prevSink, sink)) ) return const newEdge = { source, sink, nextSource, prevSink, nextSink: null } sink.sourcesTail = source.sinksTail = newEdge if (prevSource) prevSource.nextSource = newEdge else sink.sources = newEdge if (prevSink) prevSink.nextSink = newEdge else source.sinks = newEdge } function unlink(edge: Edge): Edge | null { const { source, nextSource, nextSink, prevSink } = edge if (nextSink) nextSink.prevSink = prevSink else source.sinksTail = prevSink if (prevSink) prevSink.nextSink = nextSink else source.sinks = nextSink if (!source.sinks) { if (source.stop) { source.stop() source.stop = undefined } // Cascade: if the source is also a sink (e.g. MemoNode, derived collection), // trim its own sources so upstream watched callbacks can clean up. // Mark FLAG_DIRTY so the next refresh() re-runs the computation and // re-establishes source links — without this the node is FLAG_CLEAN with // no sources, causing stale reads and silent propagation loss on reconnect. if ('sources' in source && source.sources) { const sinkNode = source as SinkNode sinkNode.sourcesTail = null trimSources(sinkNode) sinkNode.flags |= FLAG_DIRTY } } return nextSource } function trimSources(node: SinkNode): void { const tail = node.sourcesTail let source = tail ? tail.nextSource : node.sources while (source) source = unlink(source) if (tail) tail.nextSource = null else node.sources = null } /* === Propagation === */ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void { const flags = node.flags if ('sinks' in node) { if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return node.flags = flags | newFlag // Abort in-flight work when sources change if ('controller' in node && node.controller) { node.controller.abort() node.controller = undefined } // Propagate Check to sinks for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink, FLAG_CHECK) } else { if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return // Enqueue effect for later execution const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK) node.flags = newFlag if (!wasQueued) queuedEffects.push(node as EffectNode) } } /* === State Management === */ function setState<T extends {}>(node: StateNode<T>, next: T): void { if (node.equals(node.value, next)) return node.value = next for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) if (batchDepth === 0) flush() } /* === Cleanup Management === */ function registerCleanup(owner: OwnerNode, fn: Cleanup): void { if (!owner.cleanup) owner.cleanup = fn else if (Array.isArray(owner.cleanup)) owner.cleanup.push(fn) else owner.cleanup = [owner.cleanup, fn] } function runCleanup(owner: OwnerNode): void { if (!owner.cleanup) return if (Array.isArray(owner.cleanup)) // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated Cleanup[] for (let i = 0; i < owner.cleanup.length; i++) owner.cleanup[i]!() else owner.cleanup() owner.cleanup = null } /* === Recomputation === */ function recomputeMemo(node: MemoNode<unknown & {}>): void { const prevWatcher = activeSink activeSink = node node.sourcesTail = null node.flags = FLAG_RUNNING let changed = false try { const next = node.fn(node.value) if (node.error || !node.equals(next, node.value)) { node.value = next node.error = undefined changed = true } } catch (err: unknown) { changed = true node.error = err instanceof Error ? err : new Error(String(err)) } finally { activeSink = prevWatcher trimSources(node) } if (changed) { for (let e = node.sinks; e; e = e.nextSink) if (e.sink.flags & FLAG_CHECK) e.sink.flags |= FLAG_DIRTY } node.flags = FLAG_CLEAN } function recomputeTask(node: TaskNode<unknown & {}>): void { node.controller?.abort() const controller = new AbortController() node.controller = controller node.error = undefined const prevWatcher = activeSink activeSink = node node.sourcesTail = null node.flags = FLAG_RUNNING let promise: Promise<unknown & {}> try { promise = node.fn(node.value, controller.signal) } catch (err) { node.controller = undefined node.error = err instanceof Error ? err : new Error(String(err)) return } finally { activeSink = prevWatcher trimSources(node) } setState(node.pendingNode, true) promise.then( next => { if (controller.signal.aborted) return node.controller = undefined batch(() => { if (node.error || !node.equals(next, node.value)) { node.value = next node.error = undefined for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) } setState(node.pendingNode, false) }) }, (err: unknown) => { if (controller.signal.aborted) return node.controller = undefined const error = err instanceof Error ? err : new Error(String(err)) batch(() => { if ( !node.error || error.name !== node.error.name || error.message !== node.error.message ) { // We don't clear old value on errors node.error = error for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) } setState(node.pendingNode, false) }) }, ) node.flags = FLAG_CLEAN } function runEffect(node: EffectNode): void { runCleanup(node) const prevContext = activeSink const prevOwner = activeOwner activeSink = activeOwner = node node.sourcesTail = null node.flags = FLAG_RUNNING try { const out = node.fn() if (typeof out === 'function') registerCleanup(node, out) } finally { activeSink = prevContext activeOwner = prevOwner trimSources(node) } node.flags = FLAG_CLEAN } function refresh(node: SinkNode): void { if (node.flags & FLAG_CHECK) { for (let e = node.sources; e; e = e.nextSource) { if ('fn' in e.source) refresh(e.source as SinkNode) if (node.flags & FLAG_DIRTY) break } } if (node.flags & FLAG_RUNNING) { throw new CircularDependencyError( 'controller' in node ? TYPE_TASK : 'value' in node ? TYPE_MEMO : 'Effect', ) } if (node.flags & FLAG_DIRTY) { if ('controller' in node) recomputeTask(node) else if ('value' in node) recomputeMemo(node) else runEffect(node) } else { node.flags = FLAG_CLEAN } } /* === Batching === */ function flush(): void { if (flushing) return flushing = true try { for (let i = 0; i < queuedEffects.length; i++) { // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated EffectNode[] const effect = queuedEffects[i]! if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) refresh(effect) } queuedEffects.length = 0 } finally { flushing = false } } /** * Batches multiple signal updates together. * Effects will not run until the batch completes. * Batches can be nested; effects run when the outermost batch completes. * * @param fn - The function to execute within the batch * * @example * ```ts * const count = createState(0); * const double = createMemo(() => count.get() * 2); * * batch(() => { * count.set(1); * count.set(2); * count.set(3); * // Effects run only once at the end with count = 3 * }); * ``` */ function batch(fn: () => void): void { batchDepth++ try { fn() } finally { batchDepth-- if (batchDepth === 0) flush() } } /** * Runs a callback without tracking dependencies. * Any signal reads inside the callback will not create edges to the current active sink. * * @param fn - The function to execute without tracking * @returns The return value of the function * * @example * ```ts * const count = createState(0); * const label = createState('Count'); * * createEffect(() => { * // Only re-runs when count changes, not when label changes * const name = untrack(() => label.get()); * console.log(`${name}: ${count.get()}`); * }); * ``` */ function untrack<T>(fn: () => T): T { const prev = activeSink activeSink = null try { return fn() } finally { activeSink = prev } } /* === Scope Management === */ /** * Creates a new ownership scope for managing cleanup of nested effects and resources. * All effects created within the scope will be automatically disposed when the scope is disposed. * Scopes can be nested — disposing a parent scope disposes all child scopes. * * By default, if the scope is created inside another owner (an effect or a parent scope), * its disposal is automatically registered on that owner. Pass `{ root: true }` to suppress * this registration, making the returned `dispose` the sole teardown mechanism — required * when an external lifecycle authority (such as a web component's `disconnectedCallback`) * is responsible for cleanup. * * @param fn - The function to execute within the scope, may return a cleanup function * @param options - Optional scope configuration * @returns A dispose function that cleans up the scope * * @example Standard (owned) scope: * ```ts * const dispose = createScope(() => { * const count = createState(0); * createEffect(() => { console.log(count.get()); }); * return () => console.log('Scope disposed'); * }); * dispose(); * ``` * * @example Root scope for a web component: * ```ts * class MyElement extends HTMLElement { * #dispose?: () => void; * * connectedCallback() { * this.#dispose = createScope(() => { * createEffect(() => { this.textContent = label.get(); }); * }, { root: true }); * } * * disconnectedCallback() { * this.#dispose?.(); * } * } * ``` */ function createScope(fn: () => MaybeCleanup, options?: ScopeOptions): Cleanup { const prevOwner = activeOwner const scope: Scope = { cleanup: null } activeOwner = scope const dispose = () => runCleanup(scope) try { const out = fn() if (typeof out === 'function') registerCleanup(scope, out) return dispose } finally { activeOwner = prevOwner if (!options?.root && prevOwner) registerCleanup(prevOwner, dispose) } } /** * Runs a callback without any active owner. * Any scopes or effects created inside the callback will not be registered as * children of the current active owner (e.g. a re-runnable effect). Use this * when a component or resource manages its own lifecycle independently of the * reactive graph. * * @since 0.18.5 * @param fn - The function to execute without an active owner * @returns The return value of `fn` */ function unown<T>(fn: () => T): T { const prev = activeOwner activeOwner = null try { return fn() } finally { activeOwner = prev } } function makeSubscribe(node: SourceNode, onWatch?: () => Cleanup): () => void { return onWatch ? () => { if (activeSink) { if (!node.sinks) node.stop = onWatch() link(node, activeSink) } } : () => { if (activeSink) link(node, activeSink) } } export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type ScopeOptions, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, DEEP_EQUALITY, isEqual, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, }