@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
183 lines (168 loc) • 4.82 kB
text/typescript
import {
validateCallback,
validateReadValue,
validateSignalValue,
} from '../errors'
import {
batchDepth,
type ComputedOptions,
DEFAULT_EQUALITY,
FLAG_DIRTY,
flush,
makeSubscribe,
propagate,
refresh,
type SinkNode,
type TaskCallback,
type TaskNode,
TYPE_TASK,
type StateNode,
setState,
} from '../graph'
import { isAsyncFunction, isSignalOfType } from '../util'
/* === Types === */
/**
* An asynchronous reactive computation (colorless async).
* Automatically tracks dependencies and re-executes when they change.
* Provides abort semantics and pending state tracking.
*
* @template T - The type of value resolved by the task
*/
type Task<T extends {}> = {
readonly [Symbol.toStringTag]: 'Task'
/**
* Gets the current value of the task.
* Returns the last resolved value, even while a new computation is pending.
* When called inside another reactive context, creates a dependency.
* @returns The current value
* @throws UnsetSignalValueError If the task value is still unset when read.
*/
get(): T
/**
* Checks if the task is currently executing.
* Used by `match()` to route to the `stale` handler when the task has a retained value.
* @returns True if a computation is in progress
*/
isPending(): boolean
/**
* Aborts the current computation if one is running.
* The task's AbortSignal will be triggered.
*/
abort(): void
}
/* === Exported Functions === */
/**
* Creates an asynchronous reactive computation (colorless async).
* The computation automatically tracks dependencies and re-executes when they change.
* Provides abort semantics - in-flight computations are aborted when dependencies change.
*
* @since 0.18.0
* @template T - The type of value resolved by the task
* @param fn - The async computation function that receives the previous value and an AbortSignal
* @param options - Optional configuration for the task
* @param options.value - Optional initial value for reducer patterns
* @param options.equals - Optional equality function. Defaults to strict equality (`===`)
* @param options.guard - Optional type guard to validate values
* @param options.watched - Optional callback invoked when the task is first watched by an effect.
* Receives an `invalidate` function to mark the task dirty and trigger re-execution.
* Must return a cleanup function called when no effects are watching.
* @returns A Task object with get(), isPending(), and abort() methods
*
* @example
* ```ts
* const userId = createState(1);
* const user = createTask(async (prev, signal) => {
* const response = await fetch(`/api/users/${userId.get()}`, { signal });
* return response.json();
* });
*
* // When userId changes, the previous fetch is aborted
* userId.set(2);
* ```
*
* @example
* ```ts
* // Check pending state
* if (user.isPending()) {
* console.log('Loading...');
* }
* ```
*/
function createTask<T extends {}>(
fn: (prev: T, signal: AbortSignal) => Promise<T>,
options: ComputedOptions<T> & { value: T },
): Task<T>
function createTask<T extends {}>(
fn: TaskCallback<T>,
options?: ComputedOptions<T>,
): Task<T>
function createTask<T extends {}>(
fn: TaskCallback<T>,
options?: ComputedOptions<T>,
): Task<T> {
validateCallback(TYPE_TASK, fn, isAsyncFunction)
if (options?.value !== undefined)
validateSignalValue(TYPE_TASK, options.value, options?.guard)
const pendingNode: StateNode<boolean> = {
value: false,
sinks: null,
sinksTail: null,
equals: DEFAULT_EQUALITY,
}
const node: TaskNode<T> = {
fn,
value: options?.value as T,
sources: null,
sourcesTail: null,
sinks: null,
sinksTail: null,
flags: FLAG_DIRTY,
equals: options?.equals ?? DEFAULT_EQUALITY,
controller: undefined,
error: undefined,
stop: undefined,
pendingNode,
}
const watched = options?.watched
const subscribe = makeSubscribe(
node,
watched
? () =>
watched(() => {
propagate(node as unknown as SinkNode)
if (batchDepth === 0) flush()
})
: undefined,
)
const pendingSubscribe = makeSubscribe(pendingNode)
return {
[Symbol.toStringTag]: TYPE_TASK,
get(): T {
subscribe()
refresh(node as unknown as SinkNode)
if (node.error) throw node.error
validateReadValue(TYPE_TASK, node.value)
return node.value
},
isPending(): boolean {
pendingSubscribe()
return node.pendingNode.value
},
abort(): void {
node.controller?.abort()
node.controller = undefined
setState(node.pendingNode, false)
},
}
}
/**
* Checks if a value is a Task signal.
*
* @since 0.18.0
* @param value - The value to check
* @returns True if the value is a Task
*/
function isTask<T extends {} = unknown & {}>(value: unknown): value is Task<T> {
return isSignalOfType(value, TYPE_TASK)
}
export { createTask, isTask, type Task }