diff --git a/src/computed.ts b/src/computed.ts deleted file mode 100644 index 555896a..0000000 --- a/src/computed.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {defaultEquals, ValueEqualityComparer} from './equality.js'; -import { - consumerAfterComputation, - consumerBeforeComputation, - producerAccessed, - producerUpdateValueVersion, - REACTIVE_NODE, - ReactiveNode, - SIGNAL, -} from './graph.js'; - -/** - * A computation, which derives a value from a declarative reactive expression. - * - * `Computed`s are both producers and consumers of reactivity. - */ -export interface ComputedNode extends ReactiveNode, ValueEqualityComparer { - /** - * Current value of the computation, or one of the sentinel values above (`UNSET`, `COMPUTING`, - * `ERROR`). - */ - value: T; - - /** - * If `value` is `ERRORED`, the error caught from the last computation attempt which will - * be re-thrown. - */ - error: unknown; - - /** - * The computation function which will produce a new value. - */ - computation: () => T; -} - -export type ComputedGetter = (() => T) & { - [SIGNAL]: ComputedNode; -}; - -export function computedGet(node: ComputedNode) { - // Check if the value needs updating before returning it. - producerUpdateValueVersion(node); - - // Record that someone looked at this signal. - producerAccessed(node); - - if (node.value === ERRORED) { - throw node.error; - } - - return node.value; -} - -/** - * Create a computed signal which derives a reactive value from an expression. - */ -export function createComputed(computation: () => T): ComputedGetter { - const node: ComputedNode = Object.create(COMPUTED_NODE); - node.computation = computation; - - const computed = () => computedGet(node); - (computed as ComputedGetter)[SIGNAL] = node; - return computed as unknown as ComputedGetter; -} - -/** - * A dedicated symbol used before a computed value has been calculated for the first time. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const UNSET: any = /* @__PURE__ */ Symbol('UNSET'); - -/** - * A dedicated symbol used in place of a computed signal value to indicate that a given computation - * is in progress. Used to detect cycles in computation chains. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const COMPUTING: any = /* @__PURE__ */ Symbol('COMPUTING'); - -/** - * A dedicated symbol used in place of a computed signal value to indicate that a given computation - * failed. The thrown error is cached until the computation gets dirty again. - * Explicitly typed as `any` so we can use it as signal's value. - */ -const ERRORED: any = /* @__PURE__ */ Symbol('ERRORED'); - -// Note: Using an IIFE here to ensure that the spread assignment is not considered -// a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -const COMPUTED_NODE = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - value: UNSET, - dirty: true, - error: null, - equal: defaultEquals, - - producerMustRecompute(node: ComputedNode): boolean { - // Force a recomputation if there's no current value, or if the current value is in the - // process of being calculated (which should throw an error). - return node.value === UNSET || node.value === COMPUTING; - }, - - producerRecomputeValue(node: ComputedNode): void { - if (node.value === COMPUTING) { - // Our computation somehow led to a cyclic read of itself. - throw new Error('Detected cycle in computations.'); - } - - const oldValue = node.value; - node.value = COMPUTING; - - const prevConsumer = consumerBeforeComputation(node); - let newValue: unknown; - let wasEqual = false; - try { - newValue = node.computation.call(node.wrapper); - const oldOk = oldValue !== UNSET && oldValue !== ERRORED; - wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue); - } catch (err) { - newValue = ERRORED; - node.error = err; - } finally { - consumerAfterComputation(node, prevConsumer); - } - - if (wasEqual) { - // No change to `valueVersion` - old and new values are - // semantically equivalent. - node.value = oldValue; - return; - } - - node.value = newValue; - node.version++; - }, - }; -})(); diff --git a/src/equality.ts b/src/equality.ts deleted file mode 100644 index 5014620..0000000 --- a/src/equality.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/** - * An interface representing a comparison strategy to determine if two values are equal. - * - * @template T The type of the values to be compared. - */ -export interface ValueEqualityComparer { - equal(a: T, b: T): boolean; -} - -/** - * The default equality function used for `signal` and `computed`, which uses referential equality. - */ -export function defaultEquals(a: T, b: T) { - return Object.is(a, b); -} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index 0f58fe9..0000000 --- a/src/errors.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -function defaultThrowError(): never { - throw new Error(); -} - -let throwInvalidWriteToSignalErrorFn = defaultThrowError; - -export function throwInvalidWriteToSignalError() { - throwInvalidWriteToSignalErrorFn(); -} - -export function setThrowInvalidWriteToSignalError(fn: () => never): void { - throwInvalidWriteToSignalErrorFn = fn; -} diff --git a/src/graph.ts b/src/graph.ts deleted file mode 100644 index 24a93d9..0000000 --- a/src/graph.ts +++ /dev/null @@ -1,520 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// Required as the signals library is in a separate package, so we need to explicitly ensure the -// global `ngDevMode` type is defined. -declare const ngDevMode: boolean | undefined; - -/** - * The currently active consumer `ReactiveNode`, if running code in a reactive context. - * - * Change this via `setActiveConsumer`. - */ -let activeConsumer: ReactiveNode | null = null; -let inNotificationPhase = false; - -type Version = number & {__brand: 'Version'}; - -/** - * Global epoch counter. Incremented whenever a source signal is set. - */ -let epoch: Version = 1 as Version; - -/** - * Symbol used to tell `Signal`s apart from other functions. - * - * This can be used to auto-unwrap signals in various cases, or to auto-wrap non-signal values. - */ -export const SIGNAL = /* @__PURE__ */ Symbol('SIGNAL'); - -export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null { - const prev = activeConsumer; - activeConsumer = consumer; - return prev; -} - -export function getActiveConsumer(): ReactiveNode | null { - return activeConsumer; -} - -export function isInNotificationPhase(): boolean { - return inNotificationPhase; -} - -export interface Reactive { - [SIGNAL]: ReactiveNode; -} - -export function isReactive(value: unknown): value is Reactive { - return (value as Partial)[SIGNAL] !== undefined; -} - -export const REACTIVE_NODE: ReactiveNode = { - version: 0 as Version, - lastCleanEpoch: 0 as Version, - dirty: false, - producerNode: undefined, - producerLastReadVersion: undefined, - producerIndexOfThis: undefined, - nextProducerIndex: 0, - liveConsumerNode: undefined, - liveConsumerIndexOfThis: undefined, - consumerAllowSignalWrites: false, - consumerIsAlwaysLive: false, - producerMustRecompute: () => false, - producerRecomputeValue: () => {}, - consumerMarkedDirty: () => {}, - consumerOnSignalRead: () => {}, -}; - -/** - * A producer and/or consumer which participates in the reactive graph. - * - * Producer `ReactiveNode`s which are accessed when a consumer `ReactiveNode` is the - * `activeConsumer` are tracked as dependencies of that consumer. - * - * Certain consumers are also tracked as "live" consumers and create edges in the other direction, - * from producer to consumer. These edges are used to propagate change notifications when a - * producer's value is updated. - * - * A `ReactiveNode` may be both a producer and consumer. - */ -export interface ReactiveNode { - /** - * Version of the value that this node produces. - * - * This is incremented whenever a new value is produced by this node which is not equal to the - * previous value (by whatever definition of equality is in use). - */ - version: Version; - - /** - * Epoch at which this node is verified to be clean. - * - * This allows skipping of some polling operations in the case where no signals have been set - * since this node was last read. - */ - lastCleanEpoch: Version; - - /** - * Whether this node (in its consumer capacity) is dirty. - * - * Only live consumers become dirty, when receiving a change notification from a dependency - * producer. - */ - dirty: boolean; - - /** - * Producers which are dependencies of this consumer. - * - * Uses the same indices as the `producerLastReadVersion` and `producerIndexOfThis` arrays. - */ - producerNode: ReactiveNode[] | undefined; - - /** - * `Version` of the value last read by a given producer. - * - * Uses the same indices as the `producerNode` and `producerIndexOfThis` arrays. - */ - producerLastReadVersion: Version[] | undefined; - - /** - * Index of `this` (consumer) in each producer's `liveConsumers` array. - * - * This value is only meaningful if this node is live (`liveConsumers.length > 0`). Otherwise - * these indices are stale. - * - * Uses the same indices as the `producerNode` and `producerLastReadVersion` arrays. - */ - producerIndexOfThis: number[] | undefined; - - /** - * Index into the producer arrays that the next dependency of this node as a consumer will use. - * - * This index is zeroed before this node as a consumer begins executing. When a producer is read, - * it gets inserted into the producers arrays at this index. There may be an existing dependency - * in this location which may or may not match the incoming producer, depending on whether the - * same producers were read in the same order as the last computation. - */ - nextProducerIndex: number; - - /** - * Array of consumers of this producer that are "live" (they require push notifications). - * - * `liveConsumerNode.length` is effectively our reference count for this node. - */ - liveConsumerNode: ReactiveNode[] | undefined; - - /** - * Index of `this` (producer) in each consumer's `producerNode` array. - * - * Uses the same indices as the `liveConsumerNode` array. - */ - liveConsumerIndexOfThis: number[] | undefined; - - /** - * Whether writes to signals are allowed when this consumer is the `activeConsumer`. - * - * This is used to enforce guardrails such as preventing writes to writable signals in the - * computation function of computed signals, which is supposed to be pure. - */ - consumerAllowSignalWrites: boolean; - - readonly consumerIsAlwaysLive: boolean; - - /** - * Tracks whether producers need to recompute their value independently of the reactive graph (for - * example, if no initial value has been computed). - */ - producerMustRecompute(node: unknown): boolean; - producerRecomputeValue(node: unknown): void; - consumerMarkedDirty(this: unknown): void; - - /** - * Called when a signal is read within this consumer. - */ - consumerOnSignalRead(node: unknown): void; - - /** - * Called when the signal becomes "live" - */ - watched?(): void; - - /** - * Called when the signal stops being "live" - */ - unwatched?(): void; - - /** - * Optional extra data for embedder of this signal library. - * Sent to various callbacks as the this value. - */ - wrapper?: any; -} - -interface ConsumerNode extends ReactiveNode { - producerNode: NonNullable; - producerIndexOfThis: NonNullable; - producerLastReadVersion: NonNullable; -} - -interface ProducerNode extends ReactiveNode { - liveConsumerNode: NonNullable; - liveConsumerIndexOfThis: NonNullable; -} - -/** - * Called by implementations when a producer's signal is read. - */ -export function producerAccessed(node: ReactiveNode): void { - if (inNotificationPhase) { - throw new Error( - typeof ngDevMode !== 'undefined' && ngDevMode - ? `Assertion error: signal read during notification phase` - : '', - ); - } - - if (activeConsumer === null) { - // Accessed outside of a reactive context, so nothing to record. - return; - } - - activeConsumer.consumerOnSignalRead(node); - - // This producer is the `idx`th dependency of `activeConsumer`. - const idx = activeConsumer.nextProducerIndex++; - - assertConsumerNode(activeConsumer); - - if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { - // There's been a change in producers since the last execution of `activeConsumer`. - // `activeConsumer.producerNode[idx]` holds a stale dependency which will be be removed and - // replaced with `this`. - // - // If `activeConsumer` isn't live, then this is a no-op, since we can replace the producer in - // `activeConsumer.producerNode` directly. However, if `activeConsumer` is live, then we need - // to remove it from the stale producer's `liveConsumer`s. - if (consumerIsLive(activeConsumer)) { - const staleProducer = activeConsumer.producerNode[idx]; - producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); - - // At this point, the only record of `staleProducer` is the reference at - // `activeConsumer.producerNode[idx]` which will be overwritten below. - } - } - - if (activeConsumer.producerNode[idx] !== node) { - // We're a new dependency of the consumer (at `idx`). - activeConsumer.producerNode[idx] = node; - - // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a - // placeholder value. - activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) - ? producerAddLiveConsumer(node, activeConsumer, idx) - : 0; - } - activeConsumer.producerLastReadVersion[idx] = node.version; -} - -/** - * Increment the global epoch counter. - * - * Called by source producers (that is, not computeds) whenever their values change. - */ -export function producerIncrementEpoch(): void { - epoch++; -} - -/** - * Ensure this producer's `version` is up-to-date. - */ -export function producerUpdateValueVersion(node: ReactiveNode): void { - if (!node.dirty && node.lastCleanEpoch === epoch) { - // Even non-live consumers can skip polling if they previously found themselves to be clean at - // the current epoch, since their dependencies could not possibly have changed (such a change - // would've increased the epoch). - return; - } - - if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { - // None of our producers report a change since the last time they were read, so no - // recomputation of our value is necessary, and we can consider ourselves clean. - node.dirty = false; - node.lastCleanEpoch = epoch; - return; - } - - node.producerRecomputeValue(node); - - // After recomputing the value, we're no longer dirty. - node.dirty = false; - node.lastCleanEpoch = epoch; -} - -/** - * Propagate a dirty notification to live consumers of this producer. - */ -export function producerNotifyConsumers(node: ReactiveNode): void { - if (node.liveConsumerNode === undefined) { - return; - } - - // Prevent signal reads when we're updating the graph - const prev = inNotificationPhase; - inNotificationPhase = true; - try { - for (const consumer of node.liveConsumerNode) { - if (!consumer.dirty) { - consumerMarkDirty(consumer); - } - } - } finally { - inNotificationPhase = prev; - } -} - -/** - * Whether this `ReactiveNode` in its producer capacity is currently allowed to initiate updates, - * based on the current consumer context. - */ -export function producerUpdatesAllowed(): boolean { - return activeConsumer?.consumerAllowSignalWrites !== false; -} - -export function consumerMarkDirty(node: ReactiveNode): void { - node.dirty = true; - producerNotifyConsumers(node); - node.consumerMarkedDirty?.call(node.wrapper ?? node); -} - -/** - * Prepare this consumer to run a computation in its reactive context. - * - * Must be called by subclasses which represent reactive computations, before those computations - * begin. - */ -export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNode | null { - node && (node.nextProducerIndex = 0); - return setActiveConsumer(node); -} - -/** - * Finalize this consumer's state after a reactive computation has run. - * - * Must be called by subclasses which represent reactive computations, after those computations - * have finished. - */ -export function consumerAfterComputation( - node: ReactiveNode | null, - prevConsumer: ReactiveNode | null, -): void { - setActiveConsumer(prevConsumer); - - if ( - !node || - node.producerNode === undefined || - node.producerIndexOfThis === undefined || - node.producerLastReadVersion === undefined - ) { - return; - } - - if (consumerIsLive(node)) { - // For live consumers, we need to remove the producer -> consumer edge for any stale producers - // which weren't dependencies after the recomputation. - for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Truncate the producer tracking arrays. - // Perf note: this is essentially truncating the length to `node.nextProducerIndex`, but - // benchmarking has shown that individual pop operations are faster. - while (node.producerNode.length > node.nextProducerIndex) { - node.producerNode.pop(); - node.producerLastReadVersion.pop(); - node.producerIndexOfThis.pop(); - } -} - -/** - * Determine whether this consumer has any dependencies which have changed since the last time - * they were read. - */ -export function consumerPollProducersForChange(node: ReactiveNode): boolean { - assertConsumerNode(node); - - // Poll producers for change. - for (let i = 0; i < node.producerNode.length; i++) { - const producer = node.producerNode[i]; - const seenVersion = node.producerLastReadVersion[i]; - - // First check the versions. A mismatch means that the producer's value is known to have - // changed since the last time we read it. - if (seenVersion !== producer.version) { - return true; - } - - // The producer's version is the same as the last time we read it, but it might itself be - // stale. Force the producer to recompute its version (calculating a new value if necessary). - producerUpdateValueVersion(producer); - - // Now when we do this check, `producer.version` is guaranteed to be up to date, so if the - // versions still match then it has not changed since the last time we read it. - if (seenVersion !== producer.version) { - return true; - } - } - - return false; -} - -/** - * Disconnect this consumer from the graph. - */ -export function consumerDestroy(node: ReactiveNode): void { - assertConsumerNode(node); - if (consumerIsLive(node)) { - // Drop all connections from the graph to this node. - for (let i = 0; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Truncate all the arrays to drop all connection from this node to the graph. - node.producerNode.length = - node.producerLastReadVersion.length = - node.producerIndexOfThis.length = - 0; - if (node.liveConsumerNode) { - node.liveConsumerNode.length = node.liveConsumerIndexOfThis!.length = 0; - } -} - -/** - * Add `consumer` as a live consumer of this node. - * - * Note that this operation is potentially transitive. If this node becomes live, then it becomes - * a live consumer of all of its current producers. - */ -function producerAddLiveConsumer( - node: ReactiveNode, - consumer: ReactiveNode, - indexOfThis: number, -): number { - assertProducerNode(node); - assertConsumerNode(node); - if (node.liveConsumerNode.length === 0) { - node.watched?.call(node.wrapper); - // When going from 0 to 1 live consumers, we become a live consumer to our producers. - for (let i = 0; i < node.producerNode.length; i++) { - node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); - } - } - node.liveConsumerIndexOfThis.push(indexOfThis); - return node.liveConsumerNode.push(consumer) - 1; -} - -/** - * Remove the live consumer at `idx`. - */ -export function producerRemoveLiveConsumerAtIndex(node: ReactiveNode, idx: number): void { - assertProducerNode(node); - assertConsumerNode(node); - - if (typeof ngDevMode !== 'undefined' && ngDevMode && idx >= node.liveConsumerNode.length) { - throw new Error( - `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, - ); - } - - if (node.liveConsumerNode.length === 1) { - // When removing the last live consumer, we will no longer be live. We need to remove - // ourselves from our producers' tracking (which may cause consumer-producers to lose - // liveness as well). - node.unwatched?.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - } - } - - // Move the last value of `liveConsumers` into `idx`. Note that if there's only a single - // live consumer, this is a no-op. - const lastIdx = node.liveConsumerNode.length - 1; - node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; - node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; - - // Truncate the array. - node.liveConsumerNode.length--; - node.liveConsumerIndexOfThis.length--; - - // If the index is still valid, then we need to fix the index pointer from the producer to this - // consumer, and update it from `lastIdx` to `idx` (accounting for the move above). - if (idx < node.liveConsumerNode.length) { - const idxProducer = node.liveConsumerIndexOfThis[idx]; - const consumer = node.liveConsumerNode[idx]; - assertConsumerNode(consumer); - consumer.producerIndexOfThis[idxProducer] = idx; - } -} - -function consumerIsLive(node: ReactiveNode): boolean { - return node.consumerIsAlwaysLive || (node?.liveConsumerNode?.length ?? 0) > 0; -} - -export function assertConsumerNode(node: ReactiveNode): asserts node is ConsumerNode { - node.producerNode ??= []; - node.producerIndexOfThis ??= []; - node.producerLastReadVersion ??= []; -} - -export function assertProducerNode(node: ReactiveNode): asserts node is ProducerNode { - node.liveConsumerNode ??= []; - node.liveConsumerIndexOfThis ??= []; -} diff --git a/src/index.ts b/src/index.ts index ecab651..7812bec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,441 @@ -export {Signal} from './wrapper.js'; +import {createReactiveSystem, type Link, ReactiveFlags, type ReactiveNode} from './system'; + +export namespace Signal { + export let isState: (s: any) => s is State, + isComputed: (s: any) => s is Computed, + isWatcher: (s: any) => s is subtle.Watcher; + + const WATCHER_PLACEHOLDER = Symbol('watcher') as any; + + let cycle = 0; + let notifyIndex = 0; + let queuedLength = 0; + let activeSub: ReactiveNode | undefined; + + const queued: (_Watcher | undefined)[] = []; + const {link, unlink, propagate, checkDirty, shallowPropagate} = createReactiveSystem({ + update(node: _Computed) { + return node.update(); + }, + notify(node: _Watcher) { + queued[queuedLength++] = node; + node.flags &= ~ReactiveFlags.Watching; + }, + unwatched(node) { + let toRemove = node.deps; + if (toRemove !== undefined) { + do { + toRemove = unlink(toRemove, node); + } while (toRemove !== undefined); + node.flags |= ReactiveFlags.Dirty; + } + }, + }); + + function flush(): void { + try { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]!; + queued[notifyIndex++] = undefined; + effect.flags |= ReactiveFlags.Watching; + effect.run(); + } + } finally { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]!; + queued[notifyIndex++] = undefined; + effect.flags |= ReactiveFlags.Watching | ReactiveFlags.Recursed; + } + notifyIndex = 0; + queuedLength = 0; + } + } + + class _State implements ReactiveNode, State { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + flags: ReactiveFlags = ReactiveFlags.None; + watchCount = 0; + previousValue: T; + + #brand() {} + static { + isState = ((s) => typeof s === 'object' && #brand in s) as typeof isState; + } + + constructor( + private value: T, + private options?: Options, + ) { + this.previousValue = value; + if (options?.equals !== undefined) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + } + } + + get() { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot read from state inside watcher'); + } + if (activeSub !== undefined) { + const lastLink = this.subsTail; + link(this, activeSub, cycle); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; + if (isComputed(newSub) && (newSub as _Computed).watchCount) { + this.onWatched(); + } + } + } + return this.value; + } + + set(value: T): void { + if (!isState(this)) { + throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot write to state inside watcher'); + } + if (!this.equals(this.value, value)) { + this.value = value; + const subs = this.subs; + if (subs !== undefined) { + propagate(subs); + shallowPropagate(subs); + flush(); + } + } + } + } + + export interface State { + get(): T; + set(value: T): void; + } + + export const State: { + new (value: T, options?: Options): State; + } = _State; + + class _Computed implements ReactiveNode, Computed { + subs: Link | undefined = undefined; + subsTail: Link | undefined = undefined; + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty; + isError = true; + watchCount = 0; + value: T | undefined = undefined; + + #brand() {} + static { + isComputed = ((c: any) => typeof c === 'object' && #brand in c) as typeof isComputed; + } + + constructor( + private getter: () => T, + private options?: Options, + ) { + if (options?.equals !== undefined) { + this.equals = options.equals; + } + } + + equals(t: T, t2: T): boolean { + return Object.is(t, t2); + } + + onWatched() { + if (this.watchCount++ === 0) { + this.options?.[subtle.watched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as _AnySignal; + dep.onWatched(); + } + } + } + + onUnwatched() { + if (--this.watchCount === 0) { + this.options?.[subtle.unwatched]?.call(this); + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep as _AnySignal; + dep.onUnwatched(); + } + } + } + + get() { + if (!isComputed(this)) { + throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); + } + if (activeSub === WATCHER_PLACEHOLDER) { + throw new Error('Cannot read from computed inside watcher'); + } + let flags = this.flags; + if (flags & ReactiveFlags.RecursedCheck) { + throw new Error('Cycles detected'); + } + if ( + flags & ReactiveFlags.Dirty || + (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this)) + ) { + if (this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } + } else if (flags & ReactiveFlags.Pending) { + this.flags = flags & ~ReactiveFlags.Pending; + } + if (activeSub !== undefined) { + const lastLink = this.subsTail; + link(this, activeSub, cycle); + const newLink = this.subsTail!; + if (newLink !== lastLink) { + const newSub = newLink.sub; + if (isComputed(newSub) && (newSub as _Computed).watchCount) { + this.onWatched(); + } + } + } + if (this.isError) { + throw this.value; + } + return this.value!; + } + + update(): boolean { + const prevSub = activeSub; + activeSub = this; + ++cycle; + this.depsTail = undefined; + this.flags = 5 as ReactiveFlags.Mutable | ReactiveFlags.RecursedCheck; + const oldValue = this.value; + try { + const newValue = this.getter(); + if (this.isError || !this.equals(oldValue!, newValue)) { + this.isError = false; + this.value = newValue; + return true; + } + return false; + } catch (err) { + if (!this.isError || !this.equals(oldValue!, err as any)) { + this.isError = true; + this.value = err as any; + return true; + } + return false; + } finally { + const watched = !!this.watchCount; + let toRemove = this.depsTail !== undefined ? (this.depsTail as Link).nextDep : this.deps; + while (toRemove !== undefined) { + if (watched) { + const dep = toRemove.dep as _AnySignal; + dep.onUnwatched(); + } + toRemove = unlink(toRemove, this); + } + activeSub = prevSub; + this.flags &= ~(4 satisfies ReactiveFlags.RecursedCheck); + } + } + } + + export interface Computed { + get(): T; + } + + export const Computed: { + new (getter: () => T, options?: Options): Computed; + } = _Computed; + + class _Watcher implements ReactiveNode, subtle.Watcher { + deps: Link | undefined = undefined; + depsTail: Link | undefined = undefined; + flags = ReactiveFlags.Watching; + watchList = new Map<_AnySignal, Link>(); + + #brand() {} + static { + isWatcher = (w: any): w is _Watcher => #brand in w; + } + + constructor(private fn: () => void) {} + + run() { + const prevSub = activeSub; + activeSub = WATCHER_PLACEHOLDER; + this.flags &= ~(ReactiveFlags.Dirty | ReactiveFlags.Pending); + try { + this.fn(); + } finally { + activeSub = prevSub; + } + } + + #assertSignals(signals: _AnySignal[]): void { + for (const signal of signals) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called watch/unwatch without a Computed or State argument'); + } + } + } + + watch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called watch without Watcher receiver'); + } + this.#assertSignals(signals); + + for (const signal of signals) { + if (this.watchList.has(signal)) { + continue; + } + signal.onWatched(); + link(signal, this, 0); + this.watchList.set(signal, this.depsTail!); + } + } + + unwatch(...signals: _AnySignal[]): void { + if (!isWatcher(this)) { + throw new TypeError('Called unwatch without Watcher receiver'); + } + this.#assertSignals(signals); + + for (const signal of signals) { + const link = this.watchList.get(signal); + if (link === undefined) { + continue; + } + signal.onUnwatched(); + unlink(link, this); + this.watchList.delete(signal); + } + } + + getPending(): _AnySignal[] { + if (!isWatcher(this)) { + throw new TypeError('Called getPending without Watcher receiver'); + } + const arr: _AnySignal[] = []; + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const source = link.dep; + if (source.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending) && isComputed(source)) { + arr.push(link.dep as _AnySignal); + } + } + return arr; + } + } + + type _AnySignal = _State | _Computed; + type _AnySink = _Computed | _Watcher; + + type AnySignal = State | Computed; + type AnySink = Computed | subtle.Watcher; + + export namespace subtle { + export function untrack(fn: () => T) { + const prevSub = activeSub; + activeSub = undefined; + try { + return fn(); + } finally { + activeSub = prevSub; + } + } + + export interface Watcher { + watch(...signals: AnySignal[]): void; + unwatch(...signals: AnySignal[]): void; + getPending(): AnySignal[]; + } + + export const Watcher: { + new (fn: () => void): Watcher; + } = _Watcher; + + export function hasSinks(signal: AnySignal) { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called hasSinks without a Signal argument'); + } + return (signal as _AnySignal).watchCount > 0; + } + + export function hasSources(signal: AnySink) { + if (!isComputed(signal) && !isWatcher(signal)) { + throw new TypeError('Called hasSources without a Computed or Watcher argument'); + } + return (signal as _AnySink).depsTail !== undefined; + } + + export function introspectSinks(signal: AnySignal): AnySink[] { + if (!isComputed(signal) && !isState(signal)) { + throw new TypeError('Called introspectSinks without a Signal argument'); + } + const arr: _AnySink[] = []; + for (let link = (signal as _AnySignal).subs; link !== undefined; link = link.nextSub) { + arr.push(link.sub as _AnySink); + } + return arr; + } + + export function introspectSources(sink: AnySink): AnySignal[] { + if (!isComputed(sink) && !isWatcher(sink)) { + throw new TypeError('Called introspectSources without a Computed or Watcher argument'); + } + const arr: _AnySignal[] = []; + for (let link = (sink as _AnySink).deps; link !== undefined; link = link.nextDep) { + arr.push(link.dep as _AnySignal); + } + return arr; + } + + export function currentComputed(): Computed | undefined { + if (isComputed(activeSub)) { + return activeSub; + } + } + + // Hooks to observe being watched or no longer watched + export const watched = Symbol('watched'); + export const unwatched = Symbol('unwatched'); + } + + export interface Options { + // Custom comparison function between old and new value. Default: Object.is. + // The signal is passed in as an optionally-used third parameter for context. + equals?: (this: AnySignal, t: T, t2: T) => boolean; + + // Callback called when hasSinks becomes true, if it was previously false + [subtle.watched]?: (this: AnySignal) => void; + + // Callback called whenever hasSinks becomes false, if it was previously true + [subtle.unwatched]?: (this: AnySignal) => void; + } +} diff --git a/src/public-api-types.ts b/src/public-api-types.ts index 762a8f6..f9945c9 100644 --- a/src/public-api-types.ts +++ b/src/public-api-types.ts @@ -3,7 +3,7 @@ * and that we double-check what we're exposing as public API */ import {expectTypeOf} from 'expect-type'; -import {Signal} from './wrapper.ts'; +import {Signal} from '.'; /** * Top-Level @@ -69,8 +69,8 @@ expectTypeOf(Signal.Computed).toBeConstructibleWith(() => 1, { /** * Properties on each of the instances / namespaces */ -expectTypeOf & string>().toEqualTypeOf<'get' | 'set'>(); -expectTypeOf & string>().toEqualTypeOf<'get'>(); +expectTypeOf>().toEqualTypeOf<'get' | 'set'>(); +expectTypeOf>().toEqualTypeOf<'get'>(); expectTypeOf().toEqualTypeOf< | 'untrack' | 'currentComputed' @@ -83,9 +83,7 @@ expectTypeOf().toEqualTypeOf< | 'unwatched' >(); -expectTypeOf().toEqualTypeOf< - 'watch' | 'unwatch' | 'getPending' ->(); +expectTypeOf().toEqualTypeOf<'watch' | 'unwatch' | 'getPending'>(); /** * Inference works diff --git a/src/signal.ts b/src/signal.ts deleted file mode 100644 index 6d5b692..0000000 --- a/src/signal.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {defaultEquals, ValueEqualityComparer} from './equality.js'; -import {throwInvalidWriteToSignalError} from './errors.js'; -import { - producerAccessed, - producerIncrementEpoch, - producerNotifyConsumers, - producerUpdatesAllowed, - REACTIVE_NODE, - ReactiveNode, - SIGNAL, -} from './graph.js'; - -// Required as the signals library is in a separate package, so we need to explicitly ensure the -// global `ngDevMode` type is defined. -declare const ngDevMode: boolean | undefined; - -/** - * If set, called after `WritableSignal`s are updated. - * - * This hook can be used to achieve various effects, such as running effects synchronously as part - * of setting a signal. - */ -let postSignalSetFn: (() => void) | null = null; - -export interface SignalNode extends ReactiveNode, ValueEqualityComparer { - value: T; -} - -export type SignalBaseGetter = (() => T) & {readonly [SIGNAL]: unknown}; - -// Note: Closure *requires* this to be an `interface` and not a type, which is why the -// `SignalBaseGetter` type exists to provide the correct shape. -export interface SignalGetter extends SignalBaseGetter { - readonly [SIGNAL]: SignalNode; -} - -/** - * Create a `Signal` that can be set or updated directly. - */ -export function createSignal(initialValue: T): SignalGetter { - const node: SignalNode = Object.create(SIGNAL_NODE); - node.value = initialValue; - const getter = (() => { - producerAccessed(node); - return node.value; - }) as SignalGetter; - (getter as any)[SIGNAL] = node; - return getter; -} - -export function setPostSignalSetFn(fn: (() => void) | null): (() => void) | null { - const prev = postSignalSetFn; - postSignalSetFn = fn; - return prev; -} - -export function signalGetFn(this: SignalNode): T { - producerAccessed(this); - return this.value; -} - -export function signalSetFn(node: SignalNode, newValue: T) { - if (!producerUpdatesAllowed()) { - throwInvalidWriteToSignalError(); - } - - if (!node.equal.call(node.wrapper, node.value, newValue)) { - node.value = newValue; - signalValueChanged(node); - } -} - -export function signalUpdateFn(node: SignalNode, updater: (value: T) => T): void { - if (!producerUpdatesAllowed()) { - throwInvalidWriteToSignalError(); - } - - signalSetFn(node, updater(node.value)); -} - -// Note: Using an IIFE here to ensure that the spread assignment is not considered -// a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`. -// TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved. -export const SIGNAL_NODE: SignalNode = /* @__PURE__ */ (() => { - return { - ...REACTIVE_NODE, - equal: defaultEquals, - value: undefined, - }; -})(); - -function signalValueChanged(node: SignalNode): void { - node.version++; - producerIncrementEpoch(); - producerNotifyConsumers(node); - postSignalSetFn?.(); -} diff --git a/src/system.ts b/src/system.ts new file mode 100644 index 0000000..10ca55f --- /dev/null +++ b/src/system.ts @@ -0,0 +1,292 @@ +// copyed from https://github.com/stackblitz/alien-signals/blob/836130c4837f8184ae0079e6d550b0adba07b936/src/system.ts + +export interface ReactiveNode { + deps?: Link; + depsTail?: Link; + subs?: Link; + subsTail?: Link; + flags: ReactiveFlags; +} + +export interface Link { + version: number; + dep: ReactiveNode; + sub: ReactiveNode; + prevSub: Link | undefined; + nextSub: Link | undefined; + prevDep: Link | undefined; + nextDep: Link | undefined; +} + +interface Stack { + value: T; + prev: Stack | undefined; +} + +export const enum ReactiveFlags { + None = 0, + Mutable = 1, + Watching = 2, + RecursedCheck = 4, + Recursed = 8, + Dirty = 16, + Pending = 32, +} + +export function createReactiveSystem({ + update, + notify, + unwatched, +}: { + update(sub: ReactiveNode): boolean; + notify(sub: ReactiveNode): void; + unwatched(sub: ReactiveNode): void; +}) { + return { + link, + unlink, + propagate, + checkDirty, + shallowPropagate, + }; + + function link(dep: ReactiveNode, sub: ReactiveNode, version: number): void { + const prevDep = sub.depsTail; + if (prevDep !== undefined && prevDep.dep === dep) { + return; + } + const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps; + if (nextDep !== undefined && nextDep.dep === dep) { + nextDep.version = version; + sub.depsTail = nextDep; + return; + } + const prevSub = dep.subsTail; + if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub) { + return; + } + const newLink = + (sub.depsTail = + dep.subsTail = + { + version, + dep, + sub, + prevDep, + nextDep, + prevSub, + nextSub: undefined, + }); + if (nextDep !== undefined) { + nextDep.prevDep = newLink; + } + if (prevDep !== undefined) { + prevDep.nextDep = newLink; + } else { + sub.deps = newLink; + } + if (prevSub !== undefined) { + prevSub.nextSub = newLink; + } else { + dep.subs = newLink; + } + } + + function unlink(link: Link, sub = link.sub): Link | undefined { + const dep = link.dep; + const prevDep = link.prevDep; + const nextDep = link.nextDep; + const nextSub = link.nextSub; + const prevSub = link.prevSub; + if (nextDep !== undefined) { + nextDep.prevDep = prevDep; + } else { + sub.depsTail = prevDep; + } + if (prevDep !== undefined) { + prevDep.nextDep = nextDep; + } else { + sub.deps = nextDep; + } + if (nextSub !== undefined) { + nextSub.prevSub = prevSub; + } else { + dep.subsTail = prevSub; + } + if (prevSub !== undefined) { + prevSub.nextSub = nextSub; + } else if ((dep.subs = nextSub) === undefined) { + unwatched(dep); + } + return nextDep; + } + + function propagate(link: Link): void { + let next = link.nextSub; + let stack: Stack | undefined; + + top: do { + const sub = link.sub; + let flags = sub.flags; + + if ( + !( + flags & + (ReactiveFlags.RecursedCheck | + ReactiveFlags.Recursed | + ReactiveFlags.Dirty | + ReactiveFlags.Pending) + ) + ) { + sub.flags = flags | ReactiveFlags.Pending; + } else if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed))) { + flags = ReactiveFlags.None; + } else if (!(flags & ReactiveFlags.RecursedCheck)) { + sub.flags = (flags & ~ReactiveFlags.Recursed) | ReactiveFlags.Pending; + } else if ( + !(flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) && + isValidLink(link, sub) + ) { + sub.flags = flags | (ReactiveFlags.Recursed | ReactiveFlags.Pending); + flags &= ReactiveFlags.Mutable; + } else { + flags = ReactiveFlags.None; + } + + if (flags & ReactiveFlags.Watching) { + notify(sub); + } + + if (flags & ReactiveFlags.Mutable) { + const subSubs = sub.subs; + if (subSubs !== undefined) { + const nextSub = (link = subSubs).nextSub; + if (nextSub !== undefined) { + stack = {value: next, prev: stack}; + next = nextSub; + } + continue; + } + } + + if ((link = next!) !== undefined) { + next = link.nextSub; + continue; + } + + while (stack !== undefined) { + link = stack.value!; + stack = stack.prev; + if (link !== undefined) { + next = link.nextSub; + continue top; + } + } + + break; + } while (true); + } + + function checkDirty(link: Link, sub: ReactiveNode): boolean { + let stack: Stack | undefined; + let checkDepth = 0; + let dirty = false; + + top: do { + const dep = link.dep; + const flags = dep.flags; + + if (sub.flags & ReactiveFlags.Dirty) { + dirty = true; + } else if ( + (flags & (ReactiveFlags.Mutable | ReactiveFlags.Dirty)) === + (ReactiveFlags.Mutable | ReactiveFlags.Dirty) + ) { + if (update(dep)) { + const subs = dep.subs!; + if (subs.nextSub !== undefined) { + shallowPropagate(subs); + } + dirty = true; + } + } else if ( + (flags & (ReactiveFlags.Mutable | ReactiveFlags.Pending)) === + (ReactiveFlags.Mutable | ReactiveFlags.Pending) + ) { + if (link.nextSub !== undefined || link.prevSub !== undefined) { + stack = {value: link, prev: stack}; + } + link = dep.deps!; + sub = dep; + ++checkDepth; + continue; + } + + if (!dirty) { + const nextDep = link.nextDep; + if (nextDep !== undefined) { + link = nextDep; + continue; + } + } + + while (checkDepth--) { + const firstSub = sub.subs!; + const hasMultipleSubs = firstSub.nextSub !== undefined; + if (hasMultipleSubs) { + link = stack!.value; + stack = stack!.prev; + } else { + link = firstSub; + } + if (dirty) { + if (update(sub)) { + if (hasMultipleSubs) { + shallowPropagate(firstSub); + } + sub = link.sub; + continue; + } + dirty = false; + } else { + sub.flags &= ~ReactiveFlags.Pending; + } + sub = link.sub; + const nextDep = link.nextDep; + if (nextDep !== undefined) { + link = nextDep; + continue top; + } + } + + return dirty; + } while (true); + } + + function shallowPropagate(link: Link): void { + do { + const sub = link.sub; + const flags = sub.flags; + if ((flags & (ReactiveFlags.Pending | ReactiveFlags.Dirty)) === ReactiveFlags.Pending) { + sub.flags = flags | ReactiveFlags.Dirty; + if ( + (flags & (ReactiveFlags.Watching | ReactiveFlags.RecursedCheck)) === + ReactiveFlags.Watching + ) { + notify(sub); + } + } + } while ((link = link.nextSub!) !== undefined); + } + + function isValidLink(checkLink: Link, sub: ReactiveNode): boolean { + let link = sub.depsTail; + while (link !== undefined) { + if (link === checkLink) { + return true; + } + link = link.prevDep; + } + return false; + } +} diff --git a/src/wrapper.ts b/src/wrapper.ts deleted file mode 100644 index e15af74..0000000 --- a/src/wrapper.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @license - * Copyright 2024 Bloomberg Finance L.P. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {computedGet, createComputed, type ComputedNode} from './computed.js'; -import { - SIGNAL, - getActiveConsumer, - isInNotificationPhase, - producerAccessed, - assertConsumerNode, - setActiveConsumer, - REACTIVE_NODE, - type ReactiveNode, - assertProducerNode, - producerRemoveLiveConsumerAtIndex, -} from './graph.js'; -import {createSignal, signalGetFn, signalSetFn, type SignalNode} from './signal.js'; - -const NODE: unique symbol = Symbol('node'); - -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace Signal { - export let isState: (s: any) => boolean, - isComputed: (s: any) => boolean, - isWatcher: (s: any) => boolean; - - // A read-write Signal - export class State { - readonly [NODE]: SignalNode; - #brand() {} - - static { - isState = (s) => typeof s === 'object' && #brand in s; - } - - constructor(initialValue: T, options: Signal.Options = {}) { - const ref = createSignal(initialValue); - const node: SignalNode = ref[SIGNAL]; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal.subtle.watched]; - node.unwatched = options[Signal.subtle.unwatched]; - } - } - - public get(): T { - if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.get'); - return (signalGetFn).call(this[NODE]); - } - - public set(newValue: T): void { - if (!isState(this)) throw new TypeError('Wrong receiver type for Signal.State.prototype.set'); - if (isInNotificationPhase()) { - throw new Error('Writes to signals not permitted during Watcher callback'); - } - const ref = this[NODE]; - signalSetFn(ref, newValue); - } - } - - // A Signal which is a formula based on other Signals - export class Computed { - readonly [NODE]: ComputedNode; - - #brand() {} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static { - isComputed = (c: any) => typeof c === 'object' && #brand in c; - } - - // Create a Signal which evaluates to the value returned by the callback. - // Callback is called with this signal as the parameter. - constructor(computation: () => T, options?: Signal.Options) { - const ref = createComputed(computation); - const node = ref[SIGNAL]; - node.consumerAllowSignalWrites = true; - this[NODE] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) { - node.equal = equals; - } - node.watched = options[Signal.subtle.watched]; - node.unwatched = options[Signal.subtle.unwatched]; - } - } - - get(): T { - if (!isComputed(this)) - throw new TypeError('Wrong receiver type for Signal.Computed.prototype.get'); - return computedGet(this[NODE]); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnySignal = State | Computed; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnySink = Computed | subtle.Watcher; - - // eslint-disable-next-line @typescript-eslint/no-namespace - export namespace subtle { - // Run a callback with all tracking disabled (even for nested computed). - export function untrack(cb: () => T): T { - let output: T; - let prevActiveConsumer = null; - try { - prevActiveConsumer = setActiveConsumer(null); - output = cb(); - } finally { - setActiveConsumer(prevActiveConsumer); - } - return output; - } - - // Returns ordered list of all signals which this one referenced - // during the last time it was evaluated - export function introspectSources(sink: AnySink): AnySignal[] { - if (!isComputed(sink) && !isWatcher(sink)) { - throw new TypeError('Called introspectSources without a Computed or Watcher argument'); - } - return sink[NODE].producerNode?.map((n) => n.wrapper) ?? []; - } - - // Returns the subset of signal sinks which recursively - // lead to an Effect which has not been disposed - // Note: Only watched Computed signals will be in this list. - export function introspectSinks(signal: AnySignal): AnySink[] { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called introspectSinks without a Signal argument'); - } - return signal[NODE].liveConsumerNode?.map((n) => n.wrapper) ?? []; - } - - // True iff introspectSinks() is non-empty - export function hasSinks(signal: AnySignal): boolean { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called hasSinks without a Signal argument'); - } - const liveConsumerNode = signal[NODE].liveConsumerNode; - if (!liveConsumerNode) return false; - return liveConsumerNode.length > 0; - } - - // True iff introspectSources() is non-empty - export function hasSources(signal: AnySink): boolean { - if (!isComputed(signal) && !isWatcher(signal)) { - throw new TypeError('Called hasSources without a Computed or Watcher argument'); - } - const producerNode = signal[NODE].producerNode; - if (!producerNode) return false; - return producerNode.length > 0; - } - - export class Watcher { - readonly [NODE]: ReactiveNode; - - #brand() {} - static { - isWatcher = (w: any): w is Watcher => #brand in w; - } - - // When a (recursive) source of Watcher is written to, call this callback, - // if it hasn't already been called since the last `watch` call. - // No signals may be read or written during the notify. - constructor(notify: (this: Watcher) => void) { - let node = Object.create(REACTIVE_NODE); - node.wrapper = this; - node.consumerMarkedDirty = notify; - node.consumerIsAlwaysLive = true; - node.consumerAllowSignalWrites = false; - node.producerNode = []; - this[NODE] = node; - } - - #assertSignals(signals: AnySignal[]): void { - for (const signal of signals) { - if (!isComputed(signal) && !isState(signal)) { - throw new TypeError('Called watch/unwatch without a Computed or State argument'); - } - } - } - - // Add these signals to the Watcher's set, and set the watcher to run its - // notify callback next time any signal in the set (or one of its dependencies) changes. - // Can be called with no arguments just to reset the "notified" state, so that - // the notify callback will be invoked again. - watch(...signals: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); - - const node = this[NODE]; - node.dirty = false; // Give the watcher a chance to trigger again - const prev = setActiveConsumer(node); - for (const signal of signals) { - producerAccessed(signal[NODE]); - } - setActiveConsumer(prev); - } - - // Remove these signals from the watched set (e.g., for an effect which is disposed) - unwatch(...signals: AnySignal[]): void { - if (!isWatcher(this)) { - throw new TypeError('Called unwatch without Watcher receiver'); - } - this.#assertSignals(signals); - - const node = this[NODE]; - assertConsumerNode(node); - - for (let i = node.producerNode.length - 1; i >= 0; i--) { - if (signals.includes(node.producerNode[i].wrapper)) { - producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); - - // Logic copied from producerRemoveLiveConsumerAtIndex, but reversed - const lastIdx = node.producerNode!.length - 1; - node.producerNode![i] = node.producerNode![lastIdx]; - node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; - - node.producerNode.length--; - node.producerIndexOfThis.length--; - node.nextProducerIndex--; - - if (i < node.producerNode.length) { - const idxConsumer = node.producerIndexOfThis[i]; - const producer = node.producerNode[i]; - assertProducerNode(producer); - producer.liveConsumerIndexOfThis[idxConsumer] = i; - } - } - } - } - - // Returns the set of computeds in the Watcher's set which are still yet - // to be re-evaluated - getPending(): Computed[] { - if (!isWatcher(this)) { - throw new TypeError('Called getPending without Watcher receiver'); - } - const node = this[NODE]; - return node.producerNode!.filter((n) => n.dirty).map((n) => n.wrapper); - } - } - - export function currentComputed(): Computed | undefined { - return getActiveConsumer()?.wrapper; - } - - // Hooks to observe being watched or no longer watched - export const watched = Symbol('watched'); - export const unwatched = Symbol('unwatched'); - } - - export interface Options { - // Custom comparison function between old and new value. Default: Object.is. - // The signal is passed in as an optionally-used third parameter for context. - equals?: (this: AnySignal, t: T, t2: T) => boolean; - - // Callback called when hasSinks becomes true, if it was previously false - [Signal.subtle.watched]?: (this: AnySignal) => void; - - // Callback called whenever hasSinks becomes false, if it was previously true - [Signal.subtle.unwatched]?: (this: AnySignal) => void; - } -} diff --git a/tests/Signal/computed.test.ts b/tests/Signal/computed.test.ts index 6c02416..0003048 100644 --- a/tests/Signal/computed.test.ts +++ b/tests/Signal/computed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Computed', () => { it('should work', () => { diff --git a/tests/Signal/ported/vue.test.ts b/tests/Signal/ported/vue.test.ts index 647d8a7..e09e128 100644 --- a/tests/Signal/ported/vue.test.ts +++ b/tests/Signal/ported/vue.test.ts @@ -52,7 +52,7 @@ describe('Ported - Vue', () => { }); const c2 = new Signal.Computed(() => v.get() + c1.get()); expect(c2.get()).toBe('0foo'); - expect(c2.get()).toBe('0foo'); // ! In vue it recomputes and becomes '1foo' + expect(c2.get()).toBe('1foo'); }); // https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L925 @@ -68,6 +68,6 @@ describe('Ported - Vue', () => { expect(c2.get()).toBe('0,0'); v.set(1); - expect(c2.get()).toBe('0,0'); // ! In vue it recomputes and becomes '1,0' + expect(c2.get()).toBe('1,0'); }); }); diff --git a/tests/Signal/state.test.ts b/tests/Signal/state.test.ts index 067830a..b8d2286 100644 --- a/tests/Signal/state.test.ts +++ b/tests/Signal/state.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Signal.State', () => { it('should work', () => { diff --git a/tests/Signal/subtle/currentComputed.test.ts b/tests/Signal/subtle/currentComputed.test.ts index a21c26d..98ec2d1 100644 --- a/tests/Signal/subtle/currentComputed.test.ts +++ b/tests/Signal/subtle/currentComputed.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src'; describe('currentComputed', () => { it('works', () => { diff --git a/tests/Signal/subtle/untrack.test.ts b/tests/Signal/subtle/untrack.test.ts index 5e06be7..0a72cbd 100644 --- a/tests/Signal/subtle/untrack.test.ts +++ b/tests/Signal/subtle/untrack.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src'; describe('Untrack', () => { it('works', () => { diff --git a/tests/Signal/subtle/watch-unwatch.test.ts b/tests/Signal/subtle/watch-unwatch.test.ts index e6e4e1d..84a5165 100644 --- a/tests/Signal/subtle/watch-unwatch.test.ts +++ b/tests/Signal/subtle/watch-unwatch.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src'; describe('watch and unwatch', () => { it('handles multiple watchers well', () => { diff --git a/tests/Signal/subtle/watcher.test.ts b/tests/Signal/subtle/watcher.test.ts index 8eada4f..6483eb5 100644 --- a/tests/Signal/subtle/watcher.test.ts +++ b/tests/Signal/subtle/watcher.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../../src/wrapper.js'; +import {Signal} from '../../../src'; describe('Watcher', () => { type Destructor = () => void; diff --git a/tests/behaviors/custom-equality.test.ts b/tests/behaviors/custom-equality.test.ts index 3fc5416..729b822 100644 --- a/tests/behaviors/custom-equality.test.ts +++ b/tests/behaviors/custom-equality.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Custom equality', () => { it('works for State', () => { diff --git a/tests/behaviors/cycles.test.ts b/tests/behaviors/cycles.test.ts index 5ebb7a4..12ac647 100644 --- a/tests/behaviors/cycles.test.ts +++ b/tests/behaviors/cycles.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Cycles', () => { it('detects trivial cycles', () => { diff --git a/tests/behaviors/dynamic-dependencies.test.ts b/tests/behaviors/dynamic-dependencies.test.ts index fbe404e..1ae02b0 100644 --- a/tests/behaviors/dynamic-dependencies.test.ts +++ b/tests/behaviors/dynamic-dependencies.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Dynamic dependencies', () => { function run(live) { diff --git a/tests/behaviors/errors.test.ts b/tests/behaviors/errors.test.ts index bf366a2..72cff09 100644 --- a/tests/behaviors/errors.test.ts +++ b/tests/behaviors/errors.test.ts @@ -1,5 +1,5 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Errors', () => { it('are cached by computed signals', () => { diff --git a/tests/behaviors/guards.test.ts b/tests/behaviors/guards.test.ts index d66a143..5efb1ed 100644 --- a/tests/behaviors/guards.test.ts +++ b/tests/behaviors/guards.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Guards', () => { it('should work with Signals', () => { diff --git a/tests/behaviors/liveness.test.ts b/tests/behaviors/liveness.test.ts index fce1773..eee5b26 100644 --- a/tests/behaviors/liveness.test.ts +++ b/tests/behaviors/liveness.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('liveness', () => { it('only changes on first and last descendant', () => { diff --git a/tests/behaviors/prohibited-contexts.test.ts b/tests/behaviors/prohibited-contexts.test.ts index 2136a1b..9048e46 100644 --- a/tests/behaviors/prohibited-contexts.test.ts +++ b/tests/behaviors/prohibited-contexts.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Prohibited contexts', () => { it('allows writes during computed', () => { @@ -8,10 +8,8 @@ describe('Prohibited contexts', () => { expect(c.get()).toBe(2); expect(s.get()).toBe(2); - // Note: c is marked clean in this case, even though re-evaluating it - // would cause it to change value (due to the set inside of it). - expect(c.get()).toBe(2); - expect(s.get()).toBe(2); + expect(c.get()).toBe(3); + expect(s.get()).toBe(3); s.set(3); diff --git a/tests/behaviors/pruning.test.ts b/tests/behaviors/pruning.test.ts index bec8175..a7011c0 100644 --- a/tests/behaviors/pruning.test.ts +++ b/tests/behaviors/pruning.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Pruning', () => { it('only recalculates until things are equal', () => { diff --git a/tests/behaviors/receivers.test.ts b/tests/behaviors/receivers.test.ts index abd6683..09f2eee 100644 --- a/tests/behaviors/receivers.test.ts +++ b/tests/behaviors/receivers.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Receivers', () => { it('is this for computed', () => { diff --git a/tests/behaviors/type-checking.test.ts b/tests/behaviors/type-checking.test.ts index b66fefd..13650d1 100644 --- a/tests/behaviors/type-checking.test.ts +++ b/tests/behaviors/type-checking.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {Signal} from '../../src/wrapper.js'; +import {Signal} from '../../src'; describe('Expected class shape', () => { it('should be on the prototype', () => { diff --git a/tsconfig.json b/tsconfig.json index 6ead5fc..61d57b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "outDir": "dist", "rootDir": "src", "pretty": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "module": "ESNext", "target": "ES2022", "sourceMap": true,