diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..0e8dda9 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,525 @@ +import { createScheduler, type Scheduler } from './scheduler'; +import { FLAGS, HANDLERS, SCOPE, TASKS } from './symbols'; +import type { + Callable, + Computation, + ComputedSignalOptions, + Dispose, + MaybeDispose, + Scope, + DisposeScope, +} from './types'; + +let i = 0, + currentScope: Scope | null = null, + currentObserver: Computation | null = null, + currentObservers: Computation[] | null = null, + currentObserversIndex = 0, + effects: Computation[] = []; + +const SCHEDULER = createScheduler(), + NOOP = () => {}; + +export const FLAG_DIRTY = 1 << 0; +export const FLAG_SCOPED = 1 << 1; +export const FLAG_INIT = 1 << 2; +export const FLAG_DISPOSED = 1 << 3; + +SCHEDULER.onFlush(function flushEffects() { + if (!effects.length) return; + + let effect: Computation; + + for (let i = 0; i < effects.length; i++) { + effect = effects[i]; + // If parent scope is dirty it means that this effect will be disposed of so we skip. + if (!isZombie(effect)) read.call(effect); + } + + effects = []; +}); + +// These are used only for debugging to determine how a cycle occurred. +let callStack: Computation[]; +let computeStack: Computation[]; + +if (__DEV__) { + callStack = []; + computeStack = []; + SCHEDULER.onFlush(() => { + callStack = []; + }); +} + +/** + * Creates a computation root which is given a `dispose()` function to dispose of all inner + * computations. + * + * @see {@link https://github.com/maverick-js/signals#root} + */ +export function root(init: (dispose: DisposeScope) => T): T { + const scope = createScope(); + return compute( + scope, + !init.length ? (init as () => T) : init.bind(null, dispose.bind(scope)), + null, + ); +} + +/** + * Returns the current value stored inside the given compute function without triggering any + * dependencies. Use `untrack` if you want to also disable scope tracking. + * + * @see {@link https://github.com/maverick-js/signals#peek} + */ +export function peek(compute: () => T): T { + const prevObserver = currentObserver; + + currentObserver = null; + const result = compute(); + currentObserver = prevObserver; + + return result; +} + +/** + * Returns the current value inside a signal whilst disabling both scope _and_ observer + * tracking. Use `peek` if only observer tracking should be disabled. + * + * @see {@link https://github.com/maverick-js/signals#untrack} + */ +export function untrack(compute: () => T): T { + const prevScope = currentScope, + prevObserver = currentObserver; + + currentScope = null; + currentObserver = null; + const result = compute(); + currentScope = prevScope; + currentObserver = prevObserver; + + return result; +} + +/** + * By default, signal updates are batched on the microtask queue which is an async process. You can + * flush the queue synchronously to get the latest updates by calling `tick()`. + * + * @see {@link https://github.com/maverick-js/signals#tick} + */ +export function tick(): void { + SCHEDULER.flushSync(); +} + +/** + * Returns the currently executing parent scope. + * + * @see {@link https://github.com/maverick-js/signals#getscope} + */ +export function getScope(): Scope | null { + return currentScope; +} + +/** + * Returns the global scheduler. + * + * @see {@link https://github.com/maverick-js/signals#getscheduler} + */ +export function getScheduler(): Scheduler { + return SCHEDULER; +} + +/** + * Runs the given function in the given scope so context and error handling continue to work. + * + * @see {@link https://github.com/maverick-js/signals#scoped} + */ +export function scoped(run: Callable, scope: Scope | null): void { + try { + compute(scope, run, null); + } catch (error) { + handleError(scope, error); + } +} + +/** + * Attempts to get a context value for the given key. It will start from the parent scope and + * walk up the computation tree trying to find a context record and matching key. If no value can + * be found `undefined` will be returned. + * + * @see {@link https://github.com/maverick-js/signals#getcontext} + */ +export function getContext(key: string | symbol): T | undefined { + return lookup(currentScope, key); +} + +/** + * Attempts to set a context value on the parent scope with the given key. This will be a no-op if + * no parent is defined. + * + * @see {@link https://github.com/maverick-js/signals#setcontext} + */ +export function setContext(key: string | symbol, value: T) { + if (currentScope) (currentScope._context ??= {})[key] = value; +} + +/** + * Runs the given function when an error is thrown in a child scope. If the error is thrown again + * inside the error handler, it will trigger the next available parent scope handler. + * + * @see {@link https://github.com/maverick-js/signals#onerror} + */ +export function onError(handler: (error: T) => void): void { + if (!currentScope) return; + const context = (currentScope._context ??= {}); + if (!context[HANDLERS]) context[HANDLERS] = []; + (context[HANDLERS] as any[]).push(handler); +} + +/** + * Runs the given function when the parent scope computation is being disposed. + * + * @see {@link https://github.com/maverick-js/signals#ondispose} + */ +export function onDispose(dispose: MaybeDispose): Dispose { + if (!dispose || !currentScope) return dispose || NOOP; + + const node = currentScope; + + if (!node._disposal) { + node._disposal = dispose; + } else if (Array.isArray(node._disposal)) { + node._disposal.push(dispose); + } else { + node._disposal = [node._disposal, dispose]; + } + + return function removeDispose() { + if (isDisposed(node)) return; + dispose.call(null); + if (isFunction(node._disposal)) { + node._disposal = null; + } else if (Array.isArray(node._disposal)) { + node._disposal.splice(node._disposal.indexOf(dispose), 1); + } + }; +} + +const scopes = new Set(); + +export function dispose(this: Scope, self = true) { + if (isDisposed(this)) return; + + let current = (self ? this : this._nextSibling) as Computation | null, + head = self ? this._prevSibling : this; + + if (current) { + scopes.add(this); + do { + if (current._disposal) emptyDisposal(current); + if (current._sources) removeSourceObservers(current, 0); + current[SCOPE] = null; + current._sources = null; + current._observers = null; + current._prevSibling = null; + current._context = null; + current[FLAGS] |= FLAG_DISPOSED; + scopes.add(current); + current = current._nextSibling as Computation | null; + if (current) current._prevSibling!._nextSibling = null; + } while (current && scopes.has(current[SCOPE])); + } + + if (head) head._nextSibling = current; + if (current) current._prevSibling = head; + scopes.clear(); +} + +function emptyDisposal(scope: Computation) { + try { + if (Array.isArray(scope._disposal)) { + for (i = 0; i < (scope._disposal as Dispose[]).length; i++) { + const callable = scope._disposal![i]; + callable.call(callable); + } + } else { + scope._disposal!.call(scope._disposal); + } + + scope._disposal = null; + } catch (error) { + handleError(scope, error); + } +} + +export function compute( + scope: Scope | null, + compute: Callable, + observer: Computation | null, +): Result { + const prevScope = currentScope, + prevObserver = currentObserver; + + if (__DEV__ && currentObserver) computeStack.push(currentObserver); + currentScope = scope; + currentObserver = observer; + + try { + return compute.call(scope); + } finally { + if (__DEV__ && currentObserver) computeStack.pop(); + currentScope = prevScope; + currentObserver = prevObserver; + } +} + +function lookup(scope: Scope | null, key: string | symbol): any { + if (!scope) return; + + let current: Scope | null = scope, + value; + + while (current) { + value = current._context?.[key]; + if (value !== undefined) return value; + current = current[SCOPE]; + } +} + +function handleError(scope: Scope | null, error: unknown, depth?: number) { + const handlers = lookup(scope, HANDLERS); + + if (!handlers) throw error; + + try { + const coercedError = error instanceof Error ? error : Error(JSON.stringify(error)); + for (const handler of handlers) handler(coercedError); + } catch (error) { + handleError(scope![SCOPE], error); + } +} + +export function read(this: Computation): any { + if (isDisposed(this)) return this._value; + + if (__DEV__ && this._compute && computeStack.includes(this)) { + const calls = callStack.map((c) => c.id ?? '?').join(' --> '); + throw Error(`cyclic dependency detected\n\n${calls}\n`); + } + + if (__DEV__) callStack.push(this); + + if (currentObserver) { + if ( + !currentObservers && + currentObserver._sources && + currentObserver._sources[currentObserversIndex] == this + ) { + currentObserversIndex++; + } else if (!currentObservers) currentObservers = [this]; + else currentObservers.push(this); + } + + if (this._compute && isDirty(this)) { + let prevObservers = currentObservers, + prevObserversIndex = currentObserversIndex; + + currentObservers = null as Computation[] | null; + currentObserversIndex = 0; + + try { + const scoped = isScoped(this); + + if (scoped) { + if (this._nextSibling && this._nextSibling[SCOPE] === this) dispose.call(this, false); + if (this._disposal) emptyDisposal(this); + if (this._context && this._context[HANDLERS]) (this._context[HANDLERS] as any[]) = []; + } + + const result = compute(scoped ? this : currentScope, this._compute, this); + + if (currentObservers) { + if (this._sources) removeSourceObservers(this, currentObserversIndex); + + if (this._sources && currentObserversIndex > 0) { + this._sources.length = currentObserversIndex + currentObservers.length; + for (i = 0; i < currentObservers.length; i++) { + this._sources[currentObserversIndex + i] = currentObservers[i]; + } + } else { + this._sources = currentObservers; + } + + let source: Computation; + for (i = currentObserversIndex; i < this._sources.length; i++) { + source = this._sources[i]; + if (!source._observers) source._observers = [this]; + else source._observers.push(this); + } + } else if (this._sources && currentObserversIndex < this._sources.length) { + removeSourceObservers(this, currentObserversIndex); + this._sources.length = currentObserversIndex; + } + + if (!scoped && isInit(this)) { + write.call(this, result); + } else { + this._value = result; + } + } catch (error) { + if (__DEV__ && !__TEST__ && !isInit(this) && typeof this._value === 'undefined') { + console.error( + `computed \`${this.id}\` threw error during first run, this can be fatal.` + + '\n\nSolutions:\n\n' + + '1. Set the `initial` option to silence this error', + '\n2. Or, use an `effect` if the return value is not being used', + '\n\n', + error, + ); + } + + handleError(this, error); + return this._value; + } + + currentObservers = prevObservers; + currentObserversIndex = prevObserversIndex; + + this[FLAGS] |= FLAG_INIT; + this[FLAGS] &= ~FLAG_DIRTY; + } + + return this._value; +} + +export function write(this: Computation, newValue: any): any { + const value = !isFunction(this._value) && isFunction(newValue) ? newValue(this._value) : newValue; + + if (isDisposed(this) || !this._changed(this._value, value)) return this._value; + + this._value = value; + + if (!this._observers || !this._observers.length) return this._value; + + const tasks = SCHEDULER[TASKS](); + + for (i = 0; i < this._observers!.length; i++) { + const observer = this._observers![i]; + if (observer._compute) { + observer[FLAGS] |= FLAG_DIRTY; + if (isScoped(observer)) { + effects.push(observer); + } else { + tasks.push(observer); + } + } + } + + SCHEDULER.flush(); + + return this._value; +} + +export function createScope(): Scope { + const scope: Scope = { + [SCOPE]: currentScope, + [FLAGS]: FLAG_SCOPED, + _nextSibling: null, + _prevSibling: currentScope, + _context: null, + _compute: null, + _disposal: null, + }; + + if (currentScope) { + if (currentScope._nextSibling) currentScope._nextSibling._prevSibling = scope; + scope._nextSibling = currentScope!._nextSibling; + currentScope!._nextSibling = scope; + } + + return scope; +} + +export function createComputation( + initialValue: T, + compute: (() => T) | null, + options?: ComputedSignalOptions, +) { + const node: Computation = { + [FLAGS]: FLAG_DIRTY, + [SCOPE]: currentScope, + _prevSibling: currentScope, + _nextSibling: null, + _sources: null, + _observers: null, + _context: null, + _disposal: null, + _value: initialValue, + _compute: compute, + _changed: isNotEqual, + call: read, + }; + + if (__DEV__) node.id = options?.id ?? (node._compute ? 'computed' : 'signal'); + + if (currentScope) { + if (currentScope._nextSibling) currentScope._nextSibling._prevSibling = node; + node._nextSibling = currentScope._nextSibling; + currentScope._nextSibling = node; + } + + if (options) { + if (options.scoped) node[FLAGS] |= FLAG_SCOPED; + if (options.dirty) node._changed = options.dirty; + } + + return node; +} + +function removeSourceObservers(node: Computation, index: number) { + let source: Computation, swap: number; + for (let i = index; i < node._sources!.length; i++) { + source = node._sources![i]; + if (source._observers) { + swap = source._observers.indexOf(node); + source._observers[swap] = source._observers[source._observers.length - 1]; + source._observers.pop(); + } + } +} + +export function isNotEqual(a: unknown, b: unknown) { + return a !== b; +} + +export function isFunction(value: unknown): value is Function { + return typeof value === 'function'; +} + +export function isDisposed(node: Scope) { + return node[FLAGS] & FLAG_DISPOSED; +} + +export function isDirty(node: Scope) { + return node[FLAGS] & FLAG_DIRTY; +} + +export function isInit(node: Scope) { + return node[FLAGS] & FLAG_INIT; +} + +export function isScoped(node: Scope) { + return node[FLAGS] & FLAG_SCOPED; +} + +export function isZombie(node: Scope) { + let scope = node[SCOPE]; + + while (scope) { + // We're looking for a dirty parent effect scope. + if (scope._compute && isDirty(scope)) return true; + scope = scope[SCOPE]; + } + + return false; +} diff --git a/src/index.ts b/src/index.ts index 7663c3c..797e42d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,18 @@ +export { + getContext, + getScheduler, + getScope, + onDispose, + onError, + peek, + root, + scoped, + setContext, + tick, + untrack, + isFunction, + isNotEqual, +} from './core'; export * from './signals'; export * from './map'; export * from './types'; diff --git a/src/map.ts b/src/map.ts index 8340c7d..16258e7 100644 --- a/src/map.ts +++ b/src/map.ts @@ -1,7 +1,7 @@ // Adapted from: https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/array.ts#L153 -import { computed, signal, onDispose, untrack, RootScope, compute } from './signals'; -import type { Dispose, Maybe, ReadSignal, Scope } from './types'; +import { compute, createComputation, createScope, dispose, read, scoped, write } from './core'; +import type { Computation, Maybe, ReadSignal, Scope } from './types'; /** * Reactive map helper that caches each item by index to reduce unnecessary mapping on updates. @@ -17,56 +17,61 @@ export function computedMap( map: (value: ReadSignal, index: number) => MappedItem, options?: { id?: string }, ): ReadSignal { - let items: Item[] = [], - mapped: MappedItem[] = [], - disposal: Dispose[] = [], - signals: ((v: any) => void)[] = [], - i: number, - len = 0; - - onDispose(() => emptyDisposal(disposal)); - - return computed( - () => { - const newItems = list() || []; - return untrack(() => { - if (newItems.length === 0) { - if (len !== 0) { - emptyDisposal(disposal); - disposal = []; - items = []; - mapped = []; - len = 0; - signals = []; - } - - return mapped; - } + return read.bind( + createComputation( + [], + updateMap.bind({ + _scope: createScope(), + _len: 0, + _list: list, + _items: [], + _map: map, + _mappings: [], + _nodes: [], + }), + options, + ), + ); +} - for (i = 0; i < newItems.length; i++) { - if (i < items.length && items[i] !== newItems[i]) { - signals[i](newItems[i]); - } else if (i >= items.length) { - mapped[i] = compute(new RootScope(), mapper, null); - } - } +function updateMap(this: MapData): any[] { + let i = 0, + newItems = this._list() || [], + mapper = () => this._map(read.bind(this._nodes[i]), i); + + scoped(() => { + if (newItems.length === 0) { + if (this._len !== 0) { + dispose.call(this._scope, false); + this._items = []; + this._mappings = []; + this._len = 0; + this._nodes = []; + } - for (; i < items.length; i++) disposal[i].call(disposal[i]); + return; + } + + for (i = 0; i < newItems.length; i++) { + if (i < this._items.length && this._items[i] !== newItems[i]) { + write.call(this._nodes[i], newItems[i]); + } else if (i >= this._items.length) { + this._mappings[i] = compute( + (this._nodes[i] = createComputation(newItems[i], null)), + mapper, + null, + ); + } + } - len = signals.length = disposal.length = newItems.length; - items = newItems.slice(0); - return (mapped = mapped.slice(0, len)); - }); + for (; i < this._items.length; i++) dispose.call(this._nodes[i]); - function mapper(this: Scope) { - disposal[i] = this; - const $o = signal(newItems[i]); - signals[i] = $o.set; - return map($o, i); - } - }, - { id: __DEV__ ? options?.id : undefined, initial: [] }, - ); + this._len = this._nodes.length = newItems.length; + this._items = newItems.slice(0); + this._mappings = this._mappings.slice(0, this._len); + }, this._scope); + + return this._mappings; } // Adapted from: https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/array.ts#L16 @@ -84,137 +89,150 @@ export function computedKeyedMap( map: (value: Item, index: ReadSignal) => MappedItem, options?: { id?: string }, ): ReadSignal { - let items: Item[] = [], - mapping: MappedItem[] = [], - disposal: Dispose[] = [], - len = 0, - indicies: ((v: number) => number)[] | null = map.length > 1 ? [] : null; - - onDispose(() => emptyDisposal(disposal)); - - return computed( - () => { - let newItems = list() || [], - i: number, - j: number; - - return untrack(() => { - let newLen = newItems.length; - - // fast path for empty arrays - if (newLen === 0) { - if (len !== 0) { - emptyDisposal(disposal); - disposal = []; - items = []; - mapping = []; - len = 0; - indicies && (indicies = []); - } - } - // fast path for new create - else if (len === 0) { - mapping = new Array(newLen); + return read.bind( + createComputation( + [], + updateKeyedMap.bind({ + _scope: createScope(), + _len: 0, + _list: list, + _items: [], + _map: map, + _mappings: [], + _nodes: [], + }), + options, + ), + ); +} + +function updateKeyedMap(this: KeyedMapData): any[] { + const newItems = this._list() || [], + indexed = this._map.length > 1; + + scoped(() => { + let newLen = newItems.length, + i: number, + j: number, + mapper = indexed + ? () => this._map(newItems[j], read.bind(this._nodes[j])) + : () => (this._map as (value: Item) => MappedItem)(newItems[j]); + + // fast path for empty arrays + if (newLen === 0) { + if (this._len !== 0) { + dispose.call(this._scope, false); + this._nodes = []; + this._items = []; + this._mappings = []; + this._len = 0; + } + } + // fast path for new create + else if (this._len === 0) { + this._mappings = new Array(newLen); + + for (j = 0; j < newLen; j++) { + this._items[j] = newItems[j]; + this._mappings[j] = compute( + (this._nodes[j] = createComputation(j, null)), + mapper, + null, + ); + } + + this._len = newLen; + } else { + let start: number, + end: number, + newEnd: number, + item: Item, + newIndices: Map, + newIndicesNext: number[], + temp: MappedItem[] = new Array(newLen), + tempNodes: Computation[] = new Array(newLen); + + // skip common prefix + for ( + start = 0, end = Math.min(this._len, newLen); + start < end && this._items[start] === newItems[start]; + start++ + ); + + // common suffix + for ( + end = this._len - 1, newEnd = newLen - 1; + end >= start && newEnd >= start && this._items[end] === newItems[newEnd]; + end--, newEnd-- + ) { + temp[newEnd] = this._mappings[end]; + tempNodes[newEnd] = this._nodes[end]; + } - for (j = 0; j < newLen; j++) { - items[j] = newItems[j]; - mapping[j] = compute(new RootScope(), mapper, null); - } + // 0) prepare a map of all indices in newItems, scanning backwards so we encounter them in natural order + newIndices = new Map(); + newIndicesNext = new Array(newEnd + 1); + for (j = newEnd; j >= start; j--) { + item = newItems[j]; + i = newIndices.get(item)!; + newIndicesNext[j] = i === undefined ? -1 : i; + newIndices.set(item, j); + } + + // 1) step through all old items and see if they can be found in the new set; if so, save them in a temp array and mark them moved; if not, exit them + for (i = start; i <= end; i++) { + item = this._items[i]; + j = newIndices.get(item)!; + if (j !== undefined && j !== -1) { + temp[j] = this._mappings[i]; + tempNodes[j] = this._nodes[i]; + j = newIndicesNext[j]; + newIndices.set(item, j); + } else dispose.call(this._nodes[i]); + } - len = newLen; + // 2) set all the new values, pulling from the temp array if copied, otherwise entering the new value + for (j = start; j < newLen; j++) { + if (j in temp) { + this._mappings[j] = temp[j]; + this._nodes[j] = tempNodes[j]; + write.call(this._nodes[j], j); } else { - let start: number, - end: number, - newEnd: number, - item: Item, - newIndices: Map, - newIndicesNext: number[], - temp: MappedItem[] = new Array(newLen), - tempDisposal: Dispose[] = new Array(newLen), - tempIndicies: ((v: number) => number)[] = new Array(newLen); - - // skip common prefix - for ( - start = 0, end = Math.min(len, newLen); - start < end && items[start] === newItems[start]; - start++ + this._mappings[j] = compute( + (this._nodes[j] = createComputation(j, null)), + mapper, + null, ); - - // common suffix - for ( - end = len - 1, newEnd = newLen - 1; - end >= start && newEnd >= start && items[end] === newItems[newEnd]; - end--, newEnd-- - ) { - temp[newEnd] = mapping[end]; - tempDisposal[newEnd] = disposal[end]; - indicies && (tempIndicies![newEnd] = indicies[end]); - } - - // 0) prepare a map of all indices in newItems, scanning backwards so we encounter them in natural order - newIndices = new Map(); - newIndicesNext = new Array(newEnd + 1); - for (j = newEnd; j >= start; j--) { - item = newItems[j]; - i = newIndices.get(item)!; - newIndicesNext[j] = i === undefined ? -1 : i; - newIndices.set(item, j); - } - - // 1) step through all old items and see if they can be found in the new set; if so, save them in a temp array and mark them moved; if not, exit them - for (i = start; i <= end; i++) { - item = items[i]; - j = newIndices.get(item)!; - if (j !== undefined && j !== -1) { - temp[j] = mapping[i]; - tempDisposal[j] = disposal[i]; - indicies && (tempIndicies![j] = indicies[i]); - j = newIndicesNext[j]; - newIndices.set(item, j); - } else disposal[i].call(disposal[i]); - } - - // 2) set all the new values, pulling from the temp array if copied, otherwise entering the new value - for (j = start; j < newLen; j++) { - if (j in temp) { - mapping[j] = temp[j]; - disposal[j] = tempDisposal[j]; - if (indicies) { - indicies[j] = tempIndicies![j]; - indicies[j](j); - } - } else { - mapping[j] = compute(new RootScope(), mapper, null); - } - } - - // 3) in case the new set is shorter than the old, set the length of the mapped array - mapping = mapping.slice(0, (len = newLen)); - - // 4) save a copy of the mapped items for the next update - items = newItems.slice(0); } + } - return mapping; - }); + // 3) in case the new set is shorter than the old, set the length of the mapped array + this._mappings = this._mappings.slice(0, (this._len = newLen)); - function mapper(this: Scope) { - disposal[j] = this; + // 4) save a copy of the mapped items for the next update + this._items = newItems.slice(0); + } + }, this._scope); - if (indicies) { - const $signal = signal(j); - indicies[j] = $signal.set; - return map(newItems[j], $signal); - } + return this._mappings; +} - return map(newItems[j], () => -1); - } - }, - { id: __DEV__ ? options?.id : undefined, initial: [] }, - ); +interface MapData { + _scope: Scope; + _len: number; + _list: ReadSignal>; + _items: Item[]; + _mappings: MappedItem[]; + _nodes: Computation[]; + _map: (value: ReadSignal, index: number) => any; } -let i = 0; -function emptyDisposal(disposal: Dispose[]) { - for (i = 0; i < disposal.length; i++) disposal[i].call(disposal[i]); +interface KeyedMapData { + _scope: Scope; + _len: number; + _list: ReadSignal>; + _items: Item[]; + _mappings: MappedItem[]; + _nodes: Computation[]; + _map: (value: any, index: ReadSignal) => any; } diff --git a/src/scheduler.ts b/src/scheduler.ts index 1370b80..344f6d4 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -1,5 +1,5 @@ import { TASKS } from './symbols'; -import { Callable } from './types'; +import type { Callable } from './types'; export interface ScheduledTask extends Callable {} diff --git a/src/signals.ts b/src/signals.ts index 223d3ac..3446e90 100644 --- a/src/signals.ts +++ b/src/signals.ts @@ -1,113 +1,27 @@ -import { createScheduler, type Scheduler } from './scheduler'; -import { FLAGS, HANDLERS, SCOPE, TASKS } from './symbols'; +import { + createComputation, + dispose, + FLAG_DIRTY, + FLAG_SCOPED, + isFunction, + isNotEqual, + onDispose, + read, + write, +} from './core'; +import { FLAGS } from './symbols'; import type { - Callable, Computation, ComputedSignalOptions, - Dispose, Effect, - MaybeDispose, MaybeSignal, ReadSignal, - Scope, - ScopeConstructor, - DisposeScope, SelectorSignal, SignalOptions, StopEffect, WriteSignal, } from './types'; -let i = 0, - effects: Computation[] = [], - currentScope: Computation | null = null, - currentObserver: Computation | null = null, - currentObservers: Computation[] | null = null, - currentObserversIndex = 0; - -const SCHEDULER = createScheduler(), - NOOP = () => {}, - FLAG_DIRTY = 1 << 0, - FLAG_SCOPED = 1 << 1, - FLAG_INIT = 1 << 2, - FLAG_DISPOSED = 1 << 3; - -SCHEDULER.onFlush(function flushEffects() { - if (!effects.length) return; - - let effect: Computation; - - for (let i = 0; i < effects.length; i++) { - effect = effects[i]; - // If parent scope is dirty it means that this effect will be disposed of so we skip. - if (!isZombie(effect)) read.call(effect); - } - - effects = []; -}); - -// These are used only for debugging to determine how a cycle occurred. -let callStack: Computation[]; -let computeStack: Computation[]; - -if (__DEV__) { - callStack = []; - computeStack = []; - SCHEDULER.onFlush(() => { - callStack = []; - }); -} - -/** - * Creates a computation root which is given a `dispose()` function to dispose of all inner - * computations. - * - * @see {@link https://github.com/maverick-js/signals#root} - */ -export function root(init: (dispose: DisposeScope) => T): T { - const scope = new RootScope(); - return compute( - scope, - !init.length ? (init as () => T) : init.bind(null, dispose.bind(scope)), - null, - ); -} - -/** - * Returns the current value stored inside the given compute function without triggering any - * dependencies. Use `untrack` if you want to also disable scope tracking. - * - * @see {@link https://github.com/maverick-js/signals#peek} - */ -export function peek(compute: () => T): T { - const prevObserver = currentObserver; - - currentObserver = null; - const result = compute(); - currentObserver = prevObserver; - - return result; -} - -/** - * Returns the current value inside a signal whilst disabling both scope _and_ observer - * tracking. Use `peek` if only observer tracking should be disabled. - * - * @see {@link https://github.com/maverick-js/signals#untrack} - */ -export function untrack(compute: () => T): T { - const prevScope = currentScope, - prevObserver = currentObserver; - - currentScope = null; - currentObserver = null; - const result = compute(); - currentScope = prevScope; - currentObserver = prevObserver; - - return result; -} - /** * Wraps the given value into a signal. The signal will return the current value when invoked * `fn()`, and provide a simple write API via `set()`. The value can now be observed @@ -116,11 +30,11 @@ export function untrack(compute: () => T): T { * @see {@link https://github.com/maverick-js/signals#signal} */ export function signal(initialValue: T, options?: SignalOptions): WriteSignal { - const node = createComputation(initialValue, null, options), + const node = createComputation(initialValue, null, options), signal = read.bind(node) as WriteSignal; if (__DEV__) signal.node = node; - signal.set = write.bind(node); + signal.set = write.bind(node) as WriteSignal['set']; return signal; } @@ -162,7 +76,7 @@ export function computed( compute, options as ComputedSignalOptions, ), - ) as ReadSignal; + ); } /** @@ -211,16 +125,6 @@ export function readonly(signal: ReadSignal): ReadSignal { return (() => signal()) as ReadSignal; } -/** - * By default, signal updates are batched on the microtask queue which is an async process. You can - * flush the queue synchronously to get the latest updates by calling `tick()`. - * - * @see {@link https://github.com/maverick-js/signals#tick} - */ -export function tick(): void { - SCHEDULER.flushSync(); -} - /** * Whether the given value is a write signal (i.e., can produce new values via write API). * @@ -230,194 +134,6 @@ export function isWriteSignal(fn: MaybeSignal): fn is WriteSignal { return isReadSignal(fn) && 'set' in fn; } -/** - * Returns the currently executing parent scope. - * - * @see {@link https://github.com/maverick-js/signals#getscope} - */ -export function getScope(): Scope | null { - return currentScope; -} - -/** - * Returns the global scheduler. - * - * @see {@link https://github.com/maverick-js/signals#getscheduler} - */ -export function getScheduler(): Scheduler { - return SCHEDULER; -} - -/** - * Runs the given function in the given scope so context and error handling continue to work. - * - * @see {@link https://github.com/maverick-js/signals#scoped} - */ -export function scoped(run: Callable, scope: Scope | null): void { - try { - compute(scope, run, null); - } catch (error) { - handleError(scope, error); - } -} - -/** - * Attempts to get a context value for the given key. It will start from the parent scope and - * walk up the computation tree trying to find a context record and matching key. If no value can - * be found `undefined` will be returned. - * - * @see {@link https://github.com/maverick-js/signals#getcontext} - */ -export function getContext(key: string | symbol): T | undefined { - return lookup(currentScope, key); -} - -/** - * Attempts to set a context value on the parent scope with the given key. This will be a no-op if - * no parent is defined. - * - * @see {@link https://github.com/maverick-js/signals#setcontext} - */ -export function setContext(key: string | symbol, value: T) { - if (currentScope) (currentScope._context ??= {})[key] = value; -} - -/** - * Runs the given function when an error is thrown in a child scope. If the error is thrown again - * inside the error handler, it will trigger the next available parent scope handler. - * - * @see {@link https://github.com/maverick-js/signals#onerror} - */ -export function onError(handler: (error: T) => void): void { - if (!currentScope) return; - const context = (currentScope._context ??= {}); - if (!context[HANDLERS]) context[HANDLERS] = []; - (context[HANDLERS] as any[]).push(handler); -} - -/** - * Runs the given function when the parent scope computation is being disposed. - * - * @see {@link https://github.com/maverick-js/signals#ondispose} - */ -export function onDispose(dispose: MaybeDispose): Dispose { - if (!dispose || !currentScope) return dispose || NOOP; - - const node = currentScope; - - if (!node._disposal) { - node._disposal = dispose; - } else if (Array.isArray(node._disposal)) { - node._disposal.push(dispose); - } else { - node._disposal = [node._disposal, dispose]; - } - - return function removeDispose() { - if (isDisposed(node)) return; - dispose.call(null); - if (isFunction(node._disposal)) { - node._disposal = null; - } else if (Array.isArray(node._disposal)) { - node._disposal.splice(node._disposal.indexOf(dispose), 1); - } - }; -} - -const scopes = new Set(); - -function dispose(this: Computation, self = true) { - if (isDisposed(this)) return; - - let current: Computation | null = self ? this : this._nextSibling, - head = self ? this._prevSibling : this; - - if (current) { - scopes.add(this); - do { - if (current._disposal) emptyDisposal(current); - if (current._sources) removeSourceObservers(current, 0); - current[SCOPE] = null; - current._sources = null; - current._observers = null; - current._prevSibling = null; - current._context = null; - current[FLAGS] |= FLAG_DISPOSED; - scopes.add(current); - current = current._nextSibling; - if (current) current._prevSibling!._nextSibling = null; - } while (current && scopes.has(current[SCOPE])); - } - - if (head) head._nextSibling = current; - if (current) current._prevSibling = head; - scopes.clear(); -} - -function emptyDisposal(scope: Computation) { - try { - if (Array.isArray(scope._disposal)) { - for (i = 0; i < (scope._disposal as Dispose[]).length; i++) { - const callable = scope._disposal![i]; - callable.call(callable); - } - } else { - scope._disposal!.call(scope._disposal); - } - - scope._disposal = null; - } catch (error) { - handleError(scope, error); - } -} - -export function compute( - scope: Scope | null, - compute: Callable, - observer: Computation | null, -): Result { - const prevScope = currentScope, - prevObserver = currentObserver; - - if (__DEV__ && currentObserver) computeStack.push(currentObserver); - currentScope = scope; - currentObserver = observer; - - try { - return compute.call(scope); - } finally { - if (__DEV__ && currentObserver) computeStack.pop(); - currentScope = prevScope; - currentObserver = prevObserver; - } -} - -function lookup(scope: Computation | null, key: string | symbol): any { - if (!scope) return; - - let current: Computation | null = scope, - value; - - while (current) { - value = current._context?.[key]; - if (value !== undefined) return value; - current = current[SCOPE]; - } -} - -function handleError(scope: Computation | null, error: unknown, depth?: number) { - const handlers = lookup(scope, HANDLERS); - - if (!handlers) throw error; - - try { - const coercedError = error instanceof Error ? error : Error(JSON.stringify(error)); - for (const handler of handlers) handler(coercedError); - } catch (error) { - handleError(scope![SCOPE], error); - } -} - /** * Creates a signal that observes the given `source` and returns a new signal who only notifies * observers when entering or exiting a specified key. @@ -427,7 +143,7 @@ export function selector(source: ReadSignal): SelectorSignal { nodes = new Map>(); read.call( - createComputation(currentKey, function selectorChange() { + createComputation(currentKey, function selectorChange() { const newKey = source(), prev = nodes.get(currentKey!), next = nodes.get(newKey); @@ -437,16 +153,17 @@ export function selector(source: ReadSignal): SelectorSignal { }), ); - interface Selector { + interface Selector extends Computation { [FLAGS]: number; _key: T; _value: boolean; - _count: number; + _refs: number; call(): void; } function Selector(this: Selector, key: T, initialValue: boolean) { this._key = key; + this._refs = 0; this._value = initialValue; } @@ -455,8 +172,8 @@ export function selector(source: ReadSignal): SelectorSignal { SelectorProto._observers = null; SelectorProto._changed = isNotEqual; SelectorProto.call = function (this: Selector) { - this._count -= 1; - if (!this._count) nodes.delete(this._key); + this._refs -= 1; + if (!this._refs) nodes.delete(this._key); }; return function observeSelector(key: T) { @@ -464,229 +181,9 @@ export function selector(source: ReadSignal): SelectorSignal { if (!node) nodes.set(key, (node = new Selector(key, key === currentKey))); - node!._count += 1; + node!._refs += 1; onDispose(node); - return read.bind(node as unknown as Computation); + return read.bind(node!); }; } - -function createComputation( - initialValue: T, - compute: (() => T) | null, - options?: ComputedSignalOptions, -): Computation { - const node = { - [FLAGS]: FLAG_DIRTY, - [SCOPE]: currentScope, - _nextSibling: null, - _prevSibling: null, - _context: null, - _sources: null, - _observers: null, - _disposal: null, - _value: initialValue, - _compute: compute, - _changed: isNotEqual, - call: read, - } as Computation; - - if (__DEV__) node.id = options?.id ?? (node._compute ? 'computed' : 'signal'); - - if (currentScope) appendScope(node); - - if (options) { - if (options.scoped) node[FLAGS] |= FLAG_SCOPED; - if (options.dirty) node._changed = options.dirty; - } - - return node; -} - -function read(this: Computation): any { - if (isDisposed(this)) return this._value; - - if (__DEV__ && this._compute && computeStack.includes(this)) { - const calls = callStack.map((c) => c.id ?? '?').join(' --> '); - throw Error(`cyclic dependency detected\n\n${calls}\n`); - } - - if (__DEV__) callStack.push(this); - - if (currentObserver) { - if ( - !currentObservers && - currentObserver._sources && - currentObserver._sources[currentObserversIndex] == this - ) { - currentObserversIndex++; - } else if (!currentObservers) currentObservers = [this]; - else currentObservers.push(this); - } - - if (this._compute && isDirty(this)) { - let prevObservers = currentObservers, - prevObserversIndex = currentObserversIndex; - - currentObservers = null as Computation[] | null; - currentObserversIndex = 0; - - try { - const scoped = isScoped(this); - - if (scoped) { - if (this._nextSibling && this._nextSibling[SCOPE] === this) dispose.call(this, false); - if (this._disposal) emptyDisposal(this); - if (this._context && this._context[HANDLERS]) (this._context[HANDLERS] as any[]) = []; - } - - const result = compute(scoped ? this : currentScope!, this._compute, this); - - if (currentObservers) { - if (this._sources) removeSourceObservers(this, currentObserversIndex); - - if (this._sources && currentObserversIndex > 0) { - this._sources.length = currentObserversIndex + currentObservers.length; - for (i = 0; i < currentObservers.length; i++) { - this._sources[currentObserversIndex + i] = currentObservers[i]; - } - } else { - this._sources = currentObservers; - } - - let source: Computation; - for (i = currentObserversIndex; i < this._sources.length; i++) { - source = this._sources[i]; - if (!source._observers) source._observers = [this]; - else source._observers.push(this); - } - } else if (this._sources && currentObserversIndex < this._sources.length) { - removeSourceObservers(this, currentObserversIndex); - this._sources.length = currentObserversIndex; - } - - if (scoped || !isInit(this)) { - this._value = result; - } else { - write.call(this, result); - } - } catch (error) { - if (__DEV__ && !__TEST__ && !isInit(this) && typeof this._value === 'undefined') { - console.error( - `computed \`${this.id}\` threw error during first run, this can be fatal.` + - '\n\nSolutions:\n\n' + - '1. Set the `initial` option to silence this error', - '\n2. Or, use an `effect` if the return value is not being used', - '\n\n', - error, - ); - } - - handleError(this, error); - return this._value; - } - - currentObservers = prevObservers; - currentObserversIndex = prevObserversIndex; - - this[FLAGS] |= FLAG_INIT; - this[FLAGS] &= ~FLAG_DIRTY; - } - - return this._value; -} - -function write(this: Computation, newValue: any): any { - const value = !isFunction(this._value) && isFunction(newValue) ? newValue(this._value) : newValue; - - if (isDisposed(this) || !this._changed(this._value, value)) return this._value; - - this._value = value; - - if (!this._observers || !this._observers.length) return this._value; - - const tasks = SCHEDULER[TASKS](); - - for (i = 0; i < this._observers!.length; i++) { - const observer = this._observers![i]; - if (observer._compute) { - observer[FLAGS] |= FLAG_DIRTY; - if (isScoped(observer)) { - effects.push(observer); - } else { - tasks.push(observer); - } - } - } - - SCHEDULER.flush(); - - return this._value; -} - -function removeSourceObservers(node: Computation, index: number) { - let source: Computation, swap: number; - for (let i = index; i < node._sources!.length; i++) { - source = node._sources![i]; - if (source._observers) { - swap = source._observers.indexOf(node); - source._observers[swap] = source._observers[source._observers.length - 1]; - source._observers.pop(); - } - } -} - -function appendScope(node: Computation) { - node._prevSibling = currentScope!; - if (currentScope!._nextSibling) { - const next = currentScope!._nextSibling; - currentScope!._nextSibling = node; - node._nextSibling = next; - next._prevSibling = node; - } else { - currentScope!._nextSibling = node; - } -} - -export function isNotEqual(a: unknown, b: unknown) { - return a !== b; -} - -export function isFunction(value: unknown): value is Function { - return typeof value === 'function'; -} - -function isDisposed(node: Computation) { - return node[FLAGS] & FLAG_DISPOSED; -} - -function isDirty(node: Computation) { - return node[FLAGS] & FLAG_DIRTY; -} - -function isInit(node: Computation) { - return node[FLAGS] & FLAG_INIT; -} - -function isScoped(node: Computation) { - return node[FLAGS] & FLAG_SCOPED; -} - -function isZombie(node: Computation) { - let scope = node[SCOPE]; - while (scope && !isDirty(scope)) scope = scope[SCOPE]; - return scope !== null; -} - -export const RootScope = function RootScope(this: Computation) { - if (currentScope) { - this[SCOPE] = currentScope; - appendScope(this); - } -} as ScopeConstructor; - -const RootScopeProto = RootScope.prototype as Computation; -RootScopeProto[SCOPE] = null; -RootScopeProto._prevSibling = null; -RootScopeProto._nextSibling = null; -RootScopeProto.call = dispose; diff --git a/src/types.ts b/src/types.ts index 8ee2130..324267b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,22 +1,16 @@ import type { FLAGS, SCOPE } from './symbols'; -export interface Computation { +export interface Computation extends Scope { id?: string | undefined; - [FLAGS]: number; - [SCOPE]: Computation | null; - _prevSibling: Computation | null; - _nextSibling: Computation | null; - _value: T; - _disposal: Dispose | Dispose[] | null; - _context: ContextRecord | null; _sources: Computation[] | null; _observers: Computation[] | null; - call(this: Computation): T; _compute: (() => T) | null; _changed: (prev: T, next: T) => boolean; + /** read */ + call(this: Computation): T; } export interface ReadSignal { @@ -51,13 +45,16 @@ export interface SelectorSignal { (key: T): ReadSignal; } -export interface ScopeConstructor { - new (): Scope; - (): void; +export interface Scope { + [SCOPE]: Scope | null; + [FLAGS]: number; + _compute: unknown; + _prevSibling: Scope | null; + _nextSibling: Scope | null; + _context: ContextRecord | null; + _disposal: Dispose | Dispose[] | null; } -export interface Scope extends Computation {} - export interface Dispose extends Callable {} export interface DisposeScope { diff --git a/tests/computedKeyedMap.test.ts b/tests/computedKeyedMap.test.ts index 3dbe6f7..16aae4c 100644 --- a/tests/computedKeyedMap.test.ts +++ b/tests/computedKeyedMap.test.ts @@ -1,19 +1,17 @@ -import { signal, tick, computedKeyedMap } from '../src'; +import { signal, tick, computedKeyedMap, effect } from '../src'; it('should compute keyed map', () => { - const source = signal([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); - - const compute = vi.fn(); - - const map = computedKeyedMap(source, (value, index) => { - compute(); - return { - id: value.id, - get index() { - return index(); - }, - }; - }); + const source = signal([{ id: 'a' }, { id: 'b' }, { id: 'c' }]), + compute = vi.fn(), + map = computedKeyedMap(source, (value, index) => { + compute(); + return { + id: value.id, + get index() { + return index(); + }, + }; + }); const [a, b, c] = map(); expect(a.id).toBe('a'); @@ -70,3 +68,23 @@ it('should compute keyed map', () => { expect(map().length).toBe(0); expect(compute).toHaveBeenCalledTimes(4); }); + +it('should notify observer', () => { + const source = signal([{ id: 'a' }, { id: 'b' }, { id: 'c' }]), + map = computedKeyedMap( + source, + (value) => { + return { id: value.id }; + }, + { id: '$computedKeyedMap' }, + ), + $effect = vi.fn(() => { + map(); + }); + + effect($effect); + + source.set((prev) => prev.slice(1)); + tick(); + expect($effect).toHaveBeenCalledTimes(2); +});