@zeix/cause-effect
Version:
Cause & Effect - reactive state management with signals.
179 lines (162 loc) • 4.05 kB
text/typescript
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,
}