UNPKG

@zeix/cause-effect

Version:

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

169 lines (152 loc) 5.17 kB
import { ReadonlySignalError, validateSignalValue } from '../errors' import { activeSink, batchDepth, DEFAULT_EQUALITY, FLAG_DIRTY, flush, link, type MemoNode, propagate, refresh, type Signal, type SignalOptions, type SinkNode, TYPE_SLOT, } from '../graph' import { isSignal } from '../signal' import { isSignalOfType } from '../util' /* === Types === */ /** * A descriptor for a derived reactive value with an optional setter. * * @template T - The type of value */ type SlotDescriptor<T extends {}> = { /** Reads the value, tracking dependencies. */ get(): T /** Optional setter to update the source value. */ set?(next: T): void } /** * A signal that delegates its value to a swappable backing signal. * * Slots provide a stable reactive source at a fixed position (e.g. an object property) * while allowing the backing signal to be replaced without breaking subscribers. * The object shape is compatible with `Object.defineProperty()` descriptors: * `get`, `set`, `configurable`, and `enumerable` are used by the property definition; * `replace()` and `current()` are kept on the slot object for integration-layer control. * * Slots are not `MutableSignal`s: they are forwarding layers, not value owners. * `set()` delegates to the backing signal; `update()` is intentionally absent. * * @template T - The type of value held by the delegated signal. */ type Slot<T extends {}> = { readonly [Symbol.toStringTag]: 'Slot' /** Descriptor field: allows the property to be redefined or deleted. */ configurable: true /** Descriptor field: the property shows up during enumeration. */ enumerable: true /** Reads the current value from the delegated signal, tracking dependencies. */ get(): T /** Writes a value to the delegated signal. Throws `ReadonlySignalError` if the delegated signal is read-only. */ set(next: T): void /** Swaps the backing signal, invalidating all downstream subscribers. Narrowing (`U extends T`) is allowed. */ replace<U extends T>(next: Signal<U> | SlotDescriptor<U>): void /** Returns the currently delegated signal. */ current(): Signal<T> | SlotDescriptor<T> } /* === Internal Functions === */ function isSignalOrDescriptor<T extends {}>( value: unknown, ): value is Signal<T> | SlotDescriptor<T> { if (isSignal(value)) return true return ( value !== null && typeof value === 'object' && 'get' in value && typeof value.get === 'function' ) } /* === Exported Functions === */ /** * Creates a slot signal that delegates its value to a swappable backing signal. * * A slot acts as a stable reactive source usable as a property descriptor via * `Object.defineProperty(target, key, slot)`. Subscribers link to the slot itself, * so replacing the backing signal with `replace()` invalidates them without breaking * existing edges. `set()` forwards to the current backing signal if it is writable; * `update()` is absent — a slot is a forwarding layer, not a value owner. * * @since 0.18.3 * @template T - The type of value held by the delegated signal. * @param initialSignal - The initial signal to delegate to. * @param options - Optional configuration for the slot. * @param options.equals - Custom equality function. Defaults to strict equality (`===`). * @param options.guard - Type guard to validate values passed to `set()`. * @returns A `Slot<T>` object usable both as a property descriptor and as a reactive signal. */ function createSlot<T extends {}>( initialSignal: Signal<T> | SlotDescriptor<T>, options?: SignalOptions<T>, ): Slot<T> { validateSignalValue(TYPE_SLOT, initialSignal, isSignalOrDescriptor) let delegated = initialSignal const guard = options?.guard const node: MemoNode<T> = { fn: () => delegated.get(), value: undefined as unknown as T, flags: FLAG_DIRTY, sources: null, sourcesTail: null, sinks: null, sinksTail: null, equals: options?.equals ?? DEFAULT_EQUALITY, error: undefined, } const get = (): T => { if (activeSink) link(node, activeSink) refresh(node as unknown as SinkNode) if (node.error) throw node.error return node.value } const set = (next: T): void => { if (isSlot(delegated)) return void delegated.set(next) if ('set' in delegated && typeof delegated.set === 'function') { validateSignalValue(TYPE_SLOT, next, guard) delegated.set(next) } else { throw new ReadonlySignalError(TYPE_SLOT) } } const replace = <U extends T>( next: Signal<U> | SlotDescriptor<U>, ): void => { validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor) delegated = next node.flags |= FLAG_DIRTY for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) if (batchDepth === 0) flush() } return { [Symbol.toStringTag]: TYPE_SLOT, configurable: true, enumerable: true, get, set, replace, current: () => delegated, } } /** * Checks if a value is a Slot signal. * * @since 0.18.3 * @param value - The value to check * @returns True if the value is a Slot */ function isSlot<T extends {} = unknown & {}>(value: unknown): value is Slot<T> { return isSignalOfType(value, TYPE_SLOT) } export { createSlot, isSlot, type Slot, type SlotDescriptor }