UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management with signals.

207 lines (187 loc) 5.58 kB
import { type Signal, type ComputedCallback, match, UNSET } from './signal' import { CircularDependencyError, isAbortError, isAsyncFunction, isFunction, isObjectOfType, isPromise, toError } from './util' import { type Watcher, flush, notify, subscribe, watch } from './scheduler' import { type TapMatcher, type EffectMatcher, effect } from './effect' /* === Types === */ export type ComputedMatcher<S extends Signal<{}>[], R extends {}> = { signals: S, abort?: AbortSignal ok: (...values: { [K in keyof S]: S[K] extends Signal<infer T> ? T : never }) => R | Promise<R> err?: (...errors: Error[]) => R | Promise<R> nil?: () => R | Promise<R> } export type Computed<T extends {}> = { [Symbol.toStringTag]: 'Computed' get(): T map<U extends {}>(fn: (v: T) => U | Promise<U>): Computed<U> tap(matcher: TapMatcher<T> | ((v: T) => void | (() => void))): () => void } /* === Constants === */ const TYPE_COMPUTED = 'Computed' /* === Private Functions === */ const isEquivalentError = /*#__PURE__*/ ( error1: Error, error2: Error | undefined ): boolean => { if (!error2) return false return error1.name === error2.name && error1.message === error2.message } /* === Computed Factory === */ /** * Create a derived signal from existing signals * * @since 0.9.0 * @param {ComputedMatcher<S, T> | ComputedCallback<T>} matcher - computed matcher or callback * @returns {Computed<T>} - Computed signal */ export const computed = <T extends {}, S extends Signal<{}>[] = []>( matcher: ComputedMatcher<S, T> | ComputedCallback<T>, ): Computed<T> => { const watchers: Set<Watcher> = new Set() const m = isFunction(matcher) ? undefined : { nil: () => UNSET, err: (...errors: Error[]) => { if (errors.length > 1) throw new AggregateError(errors) else throw errors[0] }, ...matcher, } as Required<ComputedMatcher<S, T>> const fn = (m ? m.ok : matcher) as ComputedCallback<T> // Internal state let value: T = UNSET let error: Error | undefined let dirty = true let changed = false let computing = false let controller: AbortController | undefined // Functions to update internal state const ok = (v: T) => { if (!Object.is(v, value)) { value = v dirty = false error = undefined changed = true } } const nil = () => { changed = (UNSET !== value) value = UNSET error = undefined } const err = (e: unknown) => { const newError = toError(e) changed = !isEquivalentError(newError, error) value = UNSET error = newError } const resolve = (v: T) => { computing = false controller = undefined ok(v) if (changed) notify(watchers) } const reject = (e: unknown) => { computing = false controller = undefined err(e) if (changed) notify(watchers) } const abort = () => { computing = false controller = undefined compute() // retry } // Called when notified from sources (push) const mark = (() => { dirty = true controller?.abort('Aborted because source signal changed') if (watchers.size) { if (changed) notify(watchers) } else { mark.cleanups.forEach((fn: () => void) => fn()) mark.cleanups.clear() } }) as Watcher mark.cleanups = new Set() // Called when requested by dependencies (pull) const compute = () => watch(() => { if (computing) throw new CircularDependencyError('computed') changed = false if (isAsyncFunction(fn)) { if (controller) return value // return current value until promise resolves controller = new AbortController() if (m) m.abort = m.abort instanceof AbortSignal ? AbortSignal.any([m.abort, controller.signal]) : controller.signal controller.signal.addEventListener('abort', abort, { once: true }) } let result: T | Promise<T> computing = true try { result = m && m.signals.length ? match<S, T>(m) : fn(controller?.signal) } catch (e) { isAbortError(e) ? nil() : err(e) computing = false return } if (isPromise(result)) result.then(resolve, reject) else if (null == result || UNSET === result) nil() else ok(result) computing = false }, mark) const c: Computed<T> = { [Symbol.toStringTag]: TYPE_COMPUTED, /** * Get the current value of the computed * * @since 0.9.0 * @returns {T} - current value of the computed */ get: (): T => { subscribe(watchers) flush() if (dirty) compute() if (error) throw error return value }, /** * Create a computed signal from the current computed signal * * @since 0.9.0 * @param {((v: T) => U | Promise<U>)} fn - computed callback * @returns {Computed<U>} - computed signal */ map: <U extends {}>(fn: (v: T) => U | Promise<U>): Computed<U> => computed({ signals: [c], ok: fn }), /** * Case matching for the computed signal with effect callbacks * * @since 0.13.0 * @param {TapMatcher<T> | ((v: T) => void | (() => void))} matcher - tap matcher or effect callback * @returns {() => void} - cleanup function for the effect */ tap: (matcher: TapMatcher<T> | ((v: T) => void | (() => void))): () => void => effect({ signals: [c], ...(isFunction(matcher) ? { ok: matcher } : matcher) } as EffectMatcher<[Computed<T>]>) } return c } /* === Helper Functions === */ /** * Check if a value is a computed state * * @since 0.9.0 * @param {unknown} value - value to check * @returns {boolean} - true if value is a computed state, false otherwise */ export const isComputed = /*#__PURE__*/ <T extends {}>(value: unknown): value is Computed<T> => isObjectOfType(value, TYPE_COMPUTED)