UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management with signals.

179 lines (162 loc) 4.05 kB
import { CircularDependencyError, isFunction, isObjectOfType, toError, } from './util' import { type Watcher, watch, subscribe, notify, flush, observe, } from './scheduler' import { UNSET } from './signal' /* === Types === */ type Computed<T extends {}> = { [Symbol.toStringTag]: 'Computed' get(): T } type ComputedCallback<T extends {} & { then?: void }> = | ((abort: AbortSignal) => Promise<T>) | (() => T) /* === Constants === */ const TYPE_COMPUTED = 'Computed' /* === Functions === */ /** * Create a derived signal from existing signals * * @since 0.9.0 * @param {ComputedCallback<T>} fn - computation callback function * @returns {Computed<T>} - Computed signal */ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => { const watchers: Set<Watcher> = new Set() // Internal state let value: T = UNSET let error: Error | undefined let controller: AbortController | undefined let dirty = true let changed = false let computing = false // Functions to update internal state const ok = (v: T) => { if (!Object.is(v, value)) { value = v changed = true } error = undefined dirty = false } const nil = () => { changed = UNSET !== value value = UNSET error = undefined } const err = (e: unknown) => { const newError = toError(e) changed = !error || newError.name !== error.name || newError.message !== error.message value = UNSET error = newError } const settle = <T>(settleFn: (arg: T) => void) => (arg: T) => { computing = false controller = undefined settleFn(arg) if (changed) notify(watchers) } // Own watcher: called when notified from sources (push) const mark = watch(() => { dirty = true controller?.abort('Aborted because source signal changed') if (watchers.size) notify(watchers) else mark.cleanup() }) // Called when requested by dependencies (pull) const compute = () => observe(() => { if (computing) throw new CircularDependencyError('computed') changed = false if (isFunction(fn) && fn.constructor.name === 'AsyncFunction') { if (controller) return value // return current value until promise resolves controller = new AbortController() controller.signal.addEventListener( 'abort', () => { computing = false controller = undefined compute() // retry }, { once: true, }, ) } let result: T | Promise<T> computing = true try { result = controller ? fn(controller.signal) : (fn as () => T)() } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') nil() else err(e) computing = false return } if (result instanceof Promise) result.then(settle(ok), settle(err)) 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 }, } return c } /** * 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 */ const isComputed = /*#__PURE__*/ <T extends {}>( value: unknown, ): value is Computed<T> => isObjectOfType(value, TYPE_COMPUTED) /** * Check if the provided value is a callback that may be used as input for toSignal() to derive a computed state * * @since 0.12.0 * @param {unknown} value - value to check * @returns {boolean} - true if value is a callback or callbacks object, false otherwise */ const isComputedCallback = /*#__PURE__*/ <T extends {}>( value: unknown, ): value is ComputedCallback<T> => isFunction(value) && value.length < 2 /* === Exports === */ export { type Computed, type ComputedCallback, TYPE_COMPUTED, computed, isComputed, isComputedCallback, }