From d75f64e8eea3a42c26f5ae16967477f853c64dad Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 26 Mar 2024 12:56:11 -0400 Subject: [PATCH 01/11] chore: set up basic package config and readme --- packages/signal-polyfill/package.json | 14 ++++++ packages/signal-polyfill/readme.md | 67 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/signal-polyfill/package.json create mode 100644 packages/signal-polyfill/readme.md diff --git a/packages/signal-polyfill/package.json b/packages/signal-polyfill/package.json new file mode 100644 index 0000000..94a6869 --- /dev/null +++ b/packages/signal-polyfill/package.json @@ -0,0 +1,14 @@ +{ + "name": "signal-polyfill", + "version": "0.1.0", + "description": "A polyfill for the TC39 Signals proposal.", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "watch": "tsc -b tsconfig.json -watch" + }, + "author": "EisenbergEffect", + "license": "MIT" +} \ No newline at end of file diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md new file mode 100644 index 0000000..a57130c --- /dev/null +++ b/packages/signal-polyfill/readme.md @@ -0,0 +1,67 @@ +# Signal Polyfill + +A "signal" is a proposed first-class JavaScript data type that enables one-way data flow through cells of state or computations derived from other data. + +This is a polyfill for the `Signal` API that makes these capabilities available in browsers that don't yet support them natively. + +# Examples + +## Using signals + +```js +import { Signal } from "signal-polyfill"; +import { effect } from "./effect.js"; + +const counter = new Signal.State(0); +const isEven = new Signal.Computed(() => (counter.get() & 1) == 0); +const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd"); + +effect(() => console.log(parity.get())); + +setInterval(() => counter.set(counter.get() + 1), 1000); +``` + +> [!NOTE] +> The signal proposal does not include an `effect` API, since such APIs are often deeply integrated with rendering and batch strategies that are highly framework/library dependent. However, the proposal does seek to define a set of primitives that library authors can use to implement their own effects. + +### Creating a simple effect + +```js +import { Signal } from "signal-polyfill"; + +let needsEnqueue = false; + +const w = new Signal.subtle.Watcher(() => { + if (needsEnqueue) { + needsEnqueue = false; + queueMicrotask.enqueue(processPending); + } +}); + +function processPending() { + needsEnqueue = true; + + for (const s of w.getPending()) { + s.get(); + } + + w.watch(); +} + +export function effect(callback) { + let onUnwatch; + const computed = new Signal.Computed(() => onUnwatch = callback(), { + [Signal.subtle.unwatched]() { + typeof onUnwatch === "function" && onUnwatch(); + } + }); + + w.watch(computed); + computed.get(); + + return () => w.unwatch(computed); +} +``` + +> [!IMPORTANT] +> The `Signal.subtle` APIs are so named in order to communicate that their correct use requires careful attention to detail. These APIs are not targeted at application-level code, but rather at framework/library authors. \ No newline at end of file From b29bc0303a5182150cfcb96ab5c94b54de853631 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 26 Mar 2024 13:17:06 -0400 Subject: [PATCH 02/11] feat: add polyfill and test source code Still needs the actual test and build setup. --- packages/signal-polyfill/package.json | 8 +- packages/signal-polyfill/readme.md | 4 +- packages/signal-polyfill/src/computed.ts | 138 +++ packages/signal-polyfill/src/equality.ts | 19 + packages/signal-polyfill/src/errors.ts | 21 + packages/signal-polyfill/src/graph.ts | 514 ++++++++++ packages/signal-polyfill/src/index.ts | 1 + packages/signal-polyfill/src/signal.ts | 98 ++ packages/signal-polyfill/src/wrapper.spec.ts | 995 +++++++++++++++++++ packages/signal-polyfill/src/wrapper.ts | 299 ++++++ packages/signal-polyfill/tsconfig.json | 30 + 11 files changed, 2124 insertions(+), 3 deletions(-) create mode 100644 packages/signal-polyfill/src/computed.ts create mode 100644 packages/signal-polyfill/src/equality.ts create mode 100644 packages/signal-polyfill/src/errors.ts create mode 100644 packages/signal-polyfill/src/graph.ts create mode 100644 packages/signal-polyfill/src/index.ts create mode 100644 packages/signal-polyfill/src/signal.ts create mode 100644 packages/signal-polyfill/src/wrapper.spec.ts create mode 100644 packages/signal-polyfill/src/wrapper.ts create mode 100644 packages/signal-polyfill/tsconfig.json diff --git a/packages/signal-polyfill/package.json b/packages/signal-polyfill/package.json index 94a6869..d5e4fe1 100644 --- a/packages/signal-polyfill/package.json +++ b/packages/signal-polyfill/package.json @@ -10,5 +10,11 @@ "watch": "tsc -b tsconfig.json -watch" }, "author": "EisenbergEffect", - "license": "MIT" + "license": "MIT", + "dependencies": { + "tslib": "latest" + }, + "devDependencies": { + "typescript": "latest" + } } \ No newline at end of file diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index a57130c..cc36237 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -1,8 +1,8 @@ # Signal Polyfill -A "signal" is a proposed first-class JavaScript data type that enables one-way data flow through cells of state or computations derived from other data. +A "signal" is a proposed first-class JavaScript data type that enables one-way data flow through cells of state or computations derived from other state/computations. -This is a polyfill for the `Signal` API that makes these capabilities available in browsers that don't yet support them natively. +This is a polyfill for the `Signal` API. # Examples diff --git a/packages/signal-polyfill/src/computed.ts b/packages/signal-polyfill/src/computed.ts new file mode 100644 index 0000000..1a27e2e --- /dev/null +++ b/packages/signal-polyfill/src/computed.ts @@ -0,0 +1,138 @@ +/** + * @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, ValueEqualityFn} 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 { + /** + * 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; + + equal: ValueEqualityFn; +} + +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; + try { + newValue = node.computation.call(node.wrapper); + } catch (err) { + newValue = ERRORED; + node.error = err; + } finally { + consumerAfterComputation(node, prevConsumer); + } + + if (oldValue !== UNSET && oldValue !== ERRORED && newValue !== ERRORED && + node.equal.call(node.wrapper, oldValue, newValue)) { + // No change to `valueVersion` - old and new values are + // semantically equivalent. + node.value = oldValue; + return; + } + + node.value = newValue; + node.version++; + }, + }; +})(); \ No newline at end of file diff --git a/packages/signal-polyfill/src/equality.ts b/packages/signal-polyfill/src/equality.ts new file mode 100644 index 0000000..55cb94c --- /dev/null +++ b/packages/signal-polyfill/src/equality.ts @@ -0,0 +1,19 @@ +/** + * @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 + */ + +/** + * A comparison function which can determine if two values are equal. + */ +export type ValueEqualityFn = (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); +} \ No newline at end of file diff --git a/packages/signal-polyfill/src/errors.ts b/packages/signal-polyfill/src/errors.ts new file mode 100644 index 0000000..dbfba19 --- /dev/null +++ b/packages/signal-polyfill/src/errors.ts @@ -0,0 +1,21 @@ +/** + * @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; +} \ No newline at end of file diff --git a/packages/signal-polyfill/src/graph.ts b/packages/signal-polyfill/src/graph.ts new file mode 100644 index 0000000..8a18dac --- /dev/null +++ b/packages/signal-polyfill/src/graph.ts @@ -0,0 +1,514 @@ +/** + * @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(node: 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 (consumerIsLive(node) && !node.dirty) { + // A live consumer will be marked dirty by producers, so a clean state means that its version + // is guaranteed to be up-to-date. + return; + } + + 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?.(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 ??= []; +} \ No newline at end of file diff --git a/packages/signal-polyfill/src/index.ts b/packages/signal-polyfill/src/index.ts new file mode 100644 index 0000000..72b9910 --- /dev/null +++ b/packages/signal-polyfill/src/index.ts @@ -0,0 +1 @@ +export { Signal } from "./wrapper.js"; \ No newline at end of file diff --git a/packages/signal-polyfill/src/signal.ts b/packages/signal-polyfill/src/signal.ts new file mode 100644 index 0000000..2bdfba3 --- /dev/null +++ b/packages/signal-polyfill/src/signal.ts @@ -0,0 +1,98 @@ +/** + * @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, ValueEqualityFn} 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 { + value: T; + equal: ValueEqualityFn; +} + +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?.(); +} \ No newline at end of file diff --git a/packages/signal-polyfill/src/wrapper.spec.ts b/packages/signal-polyfill/src/wrapper.spec.ts new file mode 100644 index 0000000..2aba65c --- /dev/null +++ b/packages/signal-polyfill/src/wrapper.spec.ts @@ -0,0 +1,995 @@ +/* + ** 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. + */ + +/* eslint-disable @typescript-eslint/no-this-alias */ +import { Signal } from './wrapper.js'; + +describe("Signal.State", () => { + it("should work", () => { + const stateSignal = new Signal.State(0); + expect(stateSignal.get()).toEqual(0); + + stateSignal.set(10); + + expect(stateSignal.get()).toEqual(10); + }); +}); + +describe("Computed", () => { + it("should work", () => { + const stateSignal = new Signal.State(1); + + const computedSignal = new Signal.Computed(() => { + const f = stateSignal.get() * 2; + return f; + }); + + expect(computedSignal.get()).toEqual(2); + + stateSignal.set(5); + + expect(stateSignal.get()).toEqual(5); + expect(computedSignal.get()).toEqual(10); + }); +}); + +describe("Watcher", () => { + type Destructor = () => void; + const notifySpy = jasmine.createSpy(); + + const watcher = new Signal.subtle.Watcher(() => { + notifySpy(); + }); + + function effect(cb: () => Destructor | void): () => void { + let destructor: Destructor | void; + const c = new Signal.Computed(() => (destructor = cb())); + watcher.watch(c); + c.get(); + return () => { + destructor?.(); + watcher.unwatch(c); + }; + } + + function flushPending() { + for (const signal of watcher.getPending()) { + signal.get(); + } + expect(watcher.getPending()).toEqual([]); + } + + afterEach(() => watcher.unwatch(...Signal.subtle.introspectSources(watcher))); + + it("should work", () => { + const watchedSpy = jasmine.createSpy(); + const unwatchedSpy = jasmine.createSpy(); + const stateSignal = new Signal.State(1, { + [Signal.subtle.watched]: watchedSpy, + [Signal.subtle.unwatched]: unwatchedSpy, + }); + + stateSignal.set(100); + stateSignal.set(5); + + const computedSignal = new Signal.Computed(() => stateSignal.get() * 2); + + let calls = 0; + let output = 0; + let computedOutput = 0; + + // Ensure the call backs are not called yet + expect(watchedSpy.calls.count()).toBe(0); + expect(unwatchedSpy.calls.count()).toBe(0); + + // Expect the watcher to not have any sources as nothing has been connected yet + expect(Signal.subtle.introspectSources(watcher)).toHaveSize(0); + expect(Signal.subtle.introspectSinks(computedSignal)).toHaveSize(0); + expect(Signal.subtle.introspectSinks(stateSignal)).toHaveSize(0); + + expect(Signal.subtle.isWatched(stateSignal)).toEqual(false); + + const destructor = effect(() => { + output = stateSignal.get(); + computedOutput = computedSignal.get(); + calls++; + return () => {}; + }); + + // The signal is now watched + expect(Signal.subtle.isWatched(stateSignal)).toEqual(true); + + // Now that the effect is created, there will be a source + expect(Signal.subtle.introspectSources(watcher)).toHaveSize(1); + expect(Signal.subtle.introspectSinks(computedSignal)).toHaveSize(1); + + // Note: stateSignal has more sinks because one is for the computed signal and one is the effect. + expect(Signal.subtle.introspectSinks(stateSignal)).toHaveSize(2); + + // Now the watched callback should be called + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(0); + + // It should not have notified yet + expect(notifySpy.calls.count()).toBe(0); + + stateSignal.set(10); + + // After a signal has been set, it should notify + expect(notifySpy.calls.count()).toBe(1); + + // Initially, the effect should not have run + expect(calls).toEqual(1); + expect(output).toEqual(5); + expect(computedOutput).toEqual(10); + + flushPending(); + + // The effect should run, and thus increment the value + expect(calls).toEqual(2); + expect(output).toEqual(10); + expect(computedOutput).toEqual(20); + + // Kicking it off again, the effect should run again + watcher.watch(); + stateSignal.set(20); + expect(watcher.getPending()).toHaveSize(1); + flushPending(); + + // After a signal has been set, it should notify again + expect(notifySpy.calls.count()).toBe(2); + + expect(calls).toEqual(3); + expect(output).toEqual(20); + expect(computedOutput).toEqual(40); + + Signal.subtle.untrack(() => { + // Untrack doesn't affect set, only get + stateSignal.set(999); + expect(calls).toEqual(3); + flushPending(); + expect(calls).toEqual(4); + }); + + // Destroy and un-subscribe + destructor(); + + // Since now it is un-subscribed, it should now be called + expect(unwatchedSpy.calls.count()).toBe(1); + // We can confirm that it is un-watched by checking it + expect(Signal.subtle.isWatched(stateSignal)).toEqual(false); + + // Since now it is un-subscribed, this should have no effect now + stateSignal.set(200); + flushPending(); + + // Make sure that effect is no longer running + // Everything should stay the same + expect(calls).toEqual(4); + expect(output).toEqual(999); + expect(computedOutput).toEqual(1998); + + expect(watcher.getPending()).toHaveSize(0); + + // Adding any other effect after an unwatch should work as expected + effect(() => { + output = stateSignal.get(); + return () => {}; + }); + + stateSignal.set(300); + flushPending(); + }); +}); + +describe("Expected class shape", () => { + it("should be on the prototype", () => { + expect(typeof Signal.State.prototype.get).toBe("function"); + expect(typeof Signal.State.prototype.set).toBe("function"); + expect(typeof Signal.Computed.prototype.get).toBe("function"); + expect(typeof Signal.subtle.Watcher.prototype.watch).toBe("function"); + expect(typeof Signal.subtle.Watcher.prototype.unwatch).toBe("function"); + expect(typeof Signal.subtle.Watcher.prototype.getPending).toBe("function"); + }); +}); + +describe("Comparison semantics", () => { + it("should cache State by Object.is", () => { + const state = new Signal.State(NaN); + let calls = 0; + const computed = new Signal.Computed(() => { + calls++; + return state.get(); + }); + expect(calls).toBe(0); + expect(Object.is(computed.get(), NaN)).toBeTrue(); + expect(calls).toBe(1); + state.set(NaN); + expect(Object.is(computed.get(), NaN)).toBeTrue(); + expect(calls).toBe(1); + }); + + it("should track Computed by Object.is", () => { + const state = new Signal.State(1); + let value = 5; + let calls = 0; + const computed = new Signal.Computed(() => (state.get(), value)); + const c2 = new Signal.Computed(() => (calls++, computed.get())); + + expect(calls).toBe(0); + expect(c2.get()).toBe(5); + expect(calls).toBe(1); + state.set(2); + expect(c2.get()).toBe(5); + expect(calls).toBe(1); + value = NaN; + expect(c2.get()).toBe(5); + expect(calls).toBe(1); + state.set(3); + expect(Object.is(c2.get(), NaN)).toBeTrue(); + expect(calls).toBe(2); + state.set(4); + expect(Object.is(c2.get(), NaN)).toBeTrue(); + expect(calls).toBe(2); + }); + + it("applies custom equality in State", () => { + let ecalls = 0; + const state = new Signal.State(1, { + equals() { + ecalls++; + return false; + }, + }); + let calls = 0; + const computed = new Signal.Computed(() => { + calls++; + return state.get(); + }); + + expect(calls).toBe(0); + expect(ecalls).toBe(0); + + expect(computed.get()).toBe(1); + expect(ecalls).toBe(0); + expect(calls).toBe(1); + + state.set(1); + expect(computed.get()).toBe(1); + expect(ecalls).toBe(1); + expect(calls).toBe(2); + }); + + it("applies custom equality in Computed", () => { + const s = new Signal.State(5); + let ecalls = 0; + const c1 = new Signal.Computed(() => (s.get(), 1), { + equals() { + ecalls++; + return false; + }, + }); + let calls = 0; + const c2 = new Signal.Computed(() => { + calls++; + return c1.get(); + }); + + expect(calls).toBe(0); + expect(ecalls).toBe(0); + + expect(c2.get()).toBe(1); + expect(ecalls).toBe(0); + expect(calls).toBe(1); + + s.set(10); + expect(c2.get()).toBe(1); + expect(ecalls).toBe(1); + expect(calls).toBe(2); + }); +}); + +describe("Untrack", () => { + it("works", () => { + const state = new Signal.State(1); + const computed = new Signal.Computed(() => + Signal.subtle.untrack(() => state.get()), + ); + expect(computed.get()).toBe(1); + state.set(2); + expect(computed.get()).toBe(1); + }); + it("works differently without untrack", () => { + const state = new Signal.State(1); + const computed = new Signal.Computed(() => state.get()); + expect(computed.get()).toBe(1); + state.set(2); + expect(computed.get()).toBe(2); + }); +}); + +describe("liveness", () => { + it("only changes on first and last descendant", () => { + const watchedSpy = jasmine.createSpy(); + const unwatchedSpy = jasmine.createSpy(); + const state = new Signal.State(1, { + [Signal.subtle.watched]: watchedSpy, + [Signal.subtle.unwatched]: unwatchedSpy, + }); + const computed = new Signal.Computed(() => state.get()); + computed.get(); + expect(watchedSpy.calls.count()).toBe(0); + expect(unwatchedSpy.calls.count()).toBe(0); + + const w = new Signal.subtle.Watcher(() => {}); + const w2 = new Signal.subtle.Watcher(() => {}); + + w.watch(computed); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(0); + + w2.watch(computed); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(0); + + w2.unwatch(computed); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(0); + + w.unwatch(computed); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(1); + }); + + it("is tracked well on computed signals", () => { + const watchedSpy = jasmine.createSpy(); + const unwatchedSpy = jasmine.createSpy(); + const s = new Signal.State(1); + const c = new Signal.Computed(() => s.get(), { + [Signal.subtle.watched]: watchedSpy, + [Signal.subtle.unwatched]: unwatchedSpy, + }); + + c.get(); + expect(watchedSpy.calls.count()).toBe(0); + expect(unwatchedSpy.calls.count()).toBe(0); + + const w = new Signal.subtle.Watcher(() => {}); + w.watch(c); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(0); + + w.unwatch(c); + expect(watchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy.calls.count()).toBe(1); + }); +}); + +describe("Errors", () => { + it("are cached by computed signals", () => { + const s = new Signal.State("first"); + let n = 0; + const c = new Signal.Computed(() => { + n++; + throw s.get(); + }); + let n2 = 0; + const c2 = new Signal.Computed(() => { + n2++; + return c.get(); + }); + expect(n).toBe(0); + expect(() => c.get()).toThrow("first"); + expect(() => c2.get()).toThrow("first"); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(() => c.get()).toThrow("first"); + expect(() => c2.get()).toThrow("first"); + expect(n).toBe(1); + expect(n2).toBe(1); + s.set("second"); + expect(() => c.get()).toThrow("second"); + expect(() => c2.get()).toThrow("second"); + expect(n).toBe(2); + expect(n2).toBe(2); + + // Doesn't retrigger on setting state to the same value + s.set("second"); + expect(n).toBe(2); + }); + it("are cached by computed signals when watched", () => { + const s = new Signal.State("first"); + let n = 0; + const c = new Signal.Computed(() => { + n++; + throw s.get(); + }); + const w = new Signal.subtle.Watcher(() => {}); + w.watch(c); + + expect(n).toBe(0); + expect(() => c.get()).toThrow("first"); + expect(n).toBe(1); + expect(() => c.get()).toThrow("first"); + expect(n).toBe(1); + s.set("second"); + expect(() => c.get()).toThrow("second"); + expect(n).toBe(2); + + s.set("second"); + expect(n).toBe(2); + }); +}); + +describe("Cycles", () => { + it("detects trivial cycles", () => { + const c: Signal.Computed = new Signal.Computed(() => c.get()); + expect(() => c.get()).toThrow(); + }); + it("detects slightly larger cycles", () => { + const c: Signal.Computed = new Signal.Computed(() => c2.get()); + const c2 = new Signal.Computed(() => c.get()); + const c3 = new Signal.Computed(() => c2.get()); + expect(() => c3.get()).toThrow(); + }); +}); + +describe("Pruning", () => { + it("only recalculates until things are equal", () => { + const s = new Signal.State(0); + let n = 0; + const c = new Signal.Computed(() => (n++, s.get())); + let n2 = 0; + const c2 = new Signal.Computed(() => (n2++, c.get(), 5)); + let n3 = 0; + const c3 = new Signal.Computed(() => (n3++, c2.get())); + + expect(n).toBe(0); + expect(n2).toBe(0); + expect(n3).toBe(0); + + expect(c3.get()).toBe(5); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); + + s.set(1); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); + + expect(c3.get()).toBe(5); + expect(n).toBe(2); + expect(n2).toBe(2); + expect(n3).toBe(1); + }); + it("does similar pruning for live signals", () => { + const s = new Signal.State(0); + let n = 0; + const c = new Signal.Computed(() => (n++, s.get())); + let n2 = 0; + const c2 = new Signal.Computed(() => (n2++, c.get(), 5)); + let n3 = 0; + const c3 = new Signal.Computed(() => (n3++, c2.get())); + const w = new Signal.subtle.Watcher(() => {}); + w.watch(c3); + + expect(n).toBe(0); + expect(n2).toBe(0); + expect(n3).toBe(0); + + expect(c3.get()).toBe(5); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); + + s.set(1); + expect(n).toBe(1); + expect(n2).toBe(1); + expect(n3).toBe(1); + + expect(w.getPending().length).toBe(1); + + expect(c3.get()).toBe(5); + expect(n).toBe(2); + expect(n2).toBe(2); + expect(n3).toBe(1); + + expect(w.getPending().length).toBe(0); + }); +}); + +describe("Prohibited contexts", () => { + it("allows writes during computed", () => { + const s = new Signal.State(1); + const c = new Signal.Computed(() => (s.set(s.get() + 1), s.get())); + 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); + + s.set(3); + + expect(c.get()).toBe(4); + expect(s.get()).toBe(4); + }); + it("disallows reads and writes during watcher notify", () => { + const s = new Signal.State(1); + const w = new Signal.subtle.Watcher(() => { + s.get(); + }); + w.watch(s); + expect(() => s.set(2)).toThrow(); + w.unwatch(s); + expect(() => s.set(3)).not.toThrow(); + + const w2 = new Signal.subtle.Watcher(() => { + s.set(4); + }); + w2.watch(s); + expect(() => s.set(5)).toThrow(); + w2.unwatch(s); + expect(() => s.set(3)).not.toThrow(); + }); +}); + +describe("Custom equality", () => { + it("works for State", () => { + let answer = true; + const s = new Signal.State(1, { + equals() { + return answer; + }, + }); + let n = 0; + const c = new Signal.Computed(() => (n++, s.get())); + + expect(c.get()).toBe(1); + expect(n).toBe(1); + + s.set(2); + expect(s.get()).toBe(1); + expect(c.get()).toBe(1); + expect(n).toBe(1); + + answer = false; + s.set(2); + expect(s.get()).toBe(2); + expect(c.get()).toBe(2); + expect(n).toBe(2); + + s.set(2); + expect(s.get()).toBe(2); + expect(c.get()).toBe(2); + expect(n).toBe(3); + }); + it("works for Computed", () => { + let answer = true; + let value = 1; + const u = new Signal.State(1); + const s = new Signal.Computed(() => (u.get(), value), { + equals() { + return answer; + }, + }); + let n = 0; + const c = new Signal.Computed(() => (n++, s.get())); + + expect(c.get()).toBe(1); + expect(n).toBe(1); + + u.set(2); + value = 2; + expect(s.get()).toBe(1); + expect(c.get()).toBe(1); + expect(n).toBe(1); + + answer = false; + u.set(3); + expect(s.get()).toBe(2); + expect(c.get()).toBe(2); + expect(n).toBe(2); + + u.set(4); + expect(s.get()).toBe(2); + expect(c.get()).toBe(2); + expect(n).toBe(3); + }); +}); + +describe("Receivers", () => { + it("is this for computed", () => { + let receiver!: Signal.Computed; + const c: Signal.Computed = new Signal.Computed(function (this: Signal.Computed) { + receiver = this; + }); + expect(c.get()).toBe(undefined); + expect(receiver).toBe(c); + }); + it("is this for watched/unwatched", () => { + let r1: Signal.State | undefined, r2: Signal.State | undefined; + const s = new Signal.State(1, { + [Signal.subtle.watched]() { + // TODO: Improve types to avoid this + // @ts-expect-error + r1 = this; + }, + [Signal.subtle.unwatched]() { + // TODO: Improve types to avoid this + // @ts-expect-error + r2 = this; + }, + }); + expect(r1).toBe(undefined); + expect(r2).toBe(undefined); + const w = new Signal.subtle.Watcher(() => {}); + w.watch(s); + expect(r1).toBe(s); + expect(r2).toBe(undefined); + w.unwatch(s); + expect(r2).toBe(s); + }); + it("is this for equals", () => { + let receiver!: Signal.State | Signal.Computed; + const options = { + equals(this: Signal.State | Signal.Computed, t1: number, t2: number) { + receiver = this; + return false; + }, + }; + const s: Signal.State = new Signal.State(1, options); + s.set(2); + expect(receiver).toBe(s); + + const c: Signal.Computed = new Signal.Computed(() => s.get(), options); + expect(c.get()).toBe(2); + s.set(4); + expect(c.get()).toBe(4); + expect(receiver).toBe(c); + }); +}); + +describe("Dynamic dependencies", () => { + function run(live: boolean) { + const states = Array.from("abcdefgh").map((s) => new Signal.State(s)); + const sources = new Signal.State(states); + const computed = new Signal.Computed(() => { + let str = ""; + for (const state of sources.get()) str += state.get(); + return str; + }); + if (live) { + const w = new Signal.subtle.Watcher(() => {}); + w.watch(computed); + } + expect(computed.get()).toBe("abcdefgh"); + expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + states, + ); + + sources.set(states.slice(0, 5)); + expect(computed.get()).toBe("abcde"); + expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + states.slice(0, 5), + ); + + sources.set(states.slice(3)); + expect(computed.get()).toBe("defgh"); + expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + states.slice(3), + ); + } + it("works live", () => run(true)); + it("works not live", () => run(false)); +}); + +describe("watch and unwatch", () => { + it("handles multiple watchers well", () => { + const s = new Signal.State(1); + const s2 = new Signal.State(2); + let n = 0; + const w = new Signal.subtle.Watcher(() => n++); + w.watch(s, s2); + + s.set(4); + expect(n).toBe(1); + expect(w.getPending()).toEqual([]); + + w.watch(); + s2.set(8); + expect(n).toBe(2); + + w.unwatch(s); + s.set(3); + expect(n).toBe(2); + + w.watch(); + s2.set(3); + expect(n).toBe(3); + + w.watch(); + s.set(2); + expect(n).toBe(3); + }); + it("understands dynamic dependency sets", () => { + let w1 = 0, + u1 = 0, + w2 = 0, + u2 = 0, + n = 0, + d = 0; + let s1 = new Signal.State(1, { + [Signal.subtle.watched]() { + w1++; + }, + [Signal.subtle.unwatched]() { + u1++; + }, + }); + let s2 = new Signal.State(2, { + [Signal.subtle.watched]() { + w2++; + }, + [Signal.subtle.unwatched]() { + u2++; + }, + }); + let which: { get(): number } = s1; + let c = new Signal.Computed(() => (d++, which.get())); + let w = new Signal.subtle.Watcher(() => n++); + + w.watch(c); + expect(w1 + w2 + u1 + u2 + n + d).toBe(0); + expect(Signal.subtle.isWatched(s1)).toBe(false); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([c]); + + expect(c.get()).toBe(1); + expect(w1).toBe(1); + expect(u1).toBe(0); + expect(w2).toBe(0); + expect(u2).toBe(0); + expect(n).toBe(0); + expect(Signal.subtle.isWatched(s1)).toBe(true); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([]); + expect(d).toBe(1); + + s1.set(3); + expect(w1).toBe(1); + expect(u1).toBe(0); + expect(w2).toBe(0); + expect(u2).toBe(0); + expect(n).toBe(1); + expect(Signal.subtle.isWatched(s1)).toBe(true); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([c]); + expect(d).toBe(1); + + expect(c.get()).toBe(3); + expect(w1).toBe(1); + expect(u1).toBe(0); + expect(w2).toBe(0); + expect(u2).toBe(0); + expect(n).toBe(1); + expect(Signal.subtle.isWatched(s1)).toBe(true); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([]); + expect(d).toBe(2); + + which = s2; + w.watch(); + s1.set(4); + expect(w1).toBe(1); + expect(u1).toBe(0); + expect(w2).toBe(0); + expect(u2).toBe(0); + expect(n).toBe(2); + expect(Signal.subtle.isWatched(s1)).toBe(true); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([c]); + expect(d).toBe(2); + + expect(c.get()).toBe(2); + expect(w1).toBe(1); + expect(u1).toBe(1); + expect(w2).toBe(1); + expect(u2).toBe(0); + expect(n).toBe(2); + expect(Signal.subtle.isWatched(s1)).toBe(false); + expect(Signal.subtle.isWatched(s2)).toBe(true); + expect(w.getPending()).toEqual([]); + expect(d).toBe(3); + + w.watch(); + which = { + get() { + return 10; + }, + }; + s1.set(5); + expect(c.get()).toBe(2); + expect(w1).toBe(1); + expect(u1).toBe(1); + expect(w2).toBe(1); + expect(u2).toBe(0); + expect(n).toBe(2); + expect(Signal.subtle.isWatched(s1)).toBe(false); + expect(Signal.subtle.isWatched(s2)).toBe(true); + expect(w.getPending()).toEqual([]); + expect(d).toBe(3); + + w.watch(); + s2.set(0); + expect(w1).toBe(1); + expect(u1).toBe(1); + expect(w2).toBe(1); + expect(u2).toBe(0); + expect(n).toBe(3); + expect(Signal.subtle.isWatched(s1)).toBe(false); + expect(Signal.subtle.isWatched(s2)).toBe(true); + expect(w.getPending()).toEqual([c]); + expect(d).toBe(3); + + expect(c.get()).toBe(10); + expect(w1).toBe(1); + expect(u1).toBe(1); + expect(w2).toBe(1); + expect(u2).toBe(1); + expect(n).toBe(3); + expect(Signal.subtle.isWatched(s1)).toBe(false); + expect(Signal.subtle.isWatched(s2)).toBe(false); + expect(w.getPending()).toEqual([]); + expect(d).toBe(4); + }); +}); + +describe("type checks", () => { + it("runs explicit checks", () => { + let x = {}; + let s = new Signal.State(1); + let c = new Signal.Computed(() => {}); + let w = new Signal.subtle.Watcher(() => {}); + + expect(Signal.State.isState(x)).toBe(false); + expect(Signal.State.isState(s)).toBe(true); + expect(Signal.State.isState(c)).toBe(false); + expect(Signal.State.isState(w)).toBe(false); + + expect(Signal.Computed.isComputed(x)).toBe(false); + expect(Signal.Computed.isComputed(s)).toBe(false); + expect(Signal.Computed.isComputed(c)).toBe(true); + expect(Signal.Computed.isComputed(w)).toBe(false); + + expect(Signal.subtle.Watcher.isWatcher(x)).toBe(false); + expect(Signal.subtle.Watcher.isWatcher(s)).toBe(false); + expect(Signal.subtle.Watcher.isWatcher(c)).toBe(false); + expect(Signal.subtle.Watcher.isWatcher(w)).toBe(true); + }); + + it("checks types in methods", () => { + let x = {}; + let s = new Signal.State(1); + let c = new Signal.Computed(() => {}); + let w = new Signal.subtle.Watcher(() => {}); + + expect(() => Signal.State.prototype.get.call(x)).toThrowError(TypeError); + expect(Signal.State.prototype.get.call(s)).toBe(1); + expect(() => Signal.State.prototype.get.call(c)).toThrowError(TypeError); + expect(() => Signal.State.prototype.get.call(w)).toThrowError(TypeError); + + expect(() => Signal.State.prototype.set.call(x, 2)).toThrowError(TypeError); + expect(Signal.State.prototype.set.call(s, 2)).toBe(undefined); + expect(() => Signal.State.prototype.set.call(c, 2)).toThrowError(TypeError); + expect(() => Signal.State.prototype.set.call(w, 2)).toThrowError(TypeError); + + expect(() => Signal.Computed.prototype.get.call(x)).toThrowError(TypeError); + expect(() => Signal.Computed.prototype.get.call(s)).toThrowError(TypeError); + expect(Signal.Computed.prototype.get.call(c)).toBe(undefined); + expect(() => Signal.Computed.prototype.get.call(w)).toThrowError(TypeError); + + expect(() => Signal.subtle.Watcher.prototype.watch.call(x, s)).toThrowError( + TypeError, + ); + expect(() => Signal.subtle.Watcher.prototype.watch.call(s, s)).toThrowError( + TypeError, + ); + expect(() => Signal.subtle.Watcher.prototype.watch.call(c, s)).toThrowError( + TypeError, + ); + expect(Signal.subtle.Watcher.prototype.watch.call(w, s)).toBe(undefined); + // @ts-expect-error + expect(() => Signal.subtle.Watcher.prototype.watch.call(w, w)).toThrowError( + TypeError, + ); + + expect(() => + Signal.subtle.Watcher.prototype.unwatch.call(x, s), + ).toThrowError(TypeError); + expect(() => + Signal.subtle.Watcher.prototype.unwatch.call(s, s), + ).toThrowError(TypeError); + expect(() => + Signal.subtle.Watcher.prototype.unwatch.call(c, s), + ).toThrowError(TypeError); + expect(Signal.subtle.Watcher.prototype.unwatch.call(w, s)).toBe(undefined); + expect(() => + // @ts-expect-error + Signal.subtle.Watcher.prototype.unwatch.call(w, w), + ).toThrowError(TypeError); + + expect(() => + // @ts-expect-error + Signal.subtle.Watcher.prototype.getPending.call(x, s), + ).toThrowError(TypeError); + expect(() => + // @ts-expect-error + Signal.subtle.Watcher.prototype.getPending.call(s, s), + ).toThrowError(TypeError); + expect(() => + // @ts-expect-error + Signal.subtle.Watcher.prototype.getPending.call(c, s), + ).toThrowError(TypeError); + // @ts-expect-error + expect(Signal.subtle.Watcher.prototype.getPending.call(w, s)).toEqual( + [], + ); + + // @ts-expect-error + expect(() => Signal.subtle.introspectSources(x)).toThrowError(TypeError); + // @ts-expect-error + expect(() => Signal.subtle.introspectSources(s)).toThrowError(TypeError); + expect(Signal.subtle.introspectSources(c)).toEqual([]); + expect(Signal.subtle.introspectSources(w)).toEqual([]); + + // @ts-expect-error + expect(() => Signal.subtle.isWatched(x)).toThrowError(TypeError); + expect(Signal.subtle.isWatched(s)).toBe(false); + expect(Signal.subtle.isWatched(c)).toBe(false); + // @ts-expect-error + expect(() => Signal.subtle.isWatched(w)).toThrowError(TypeError); + + // @ts-expect-error + expect(() => Signal.subtle.introspectSinks(x)).toThrowError(TypeError); + expect(Signal.subtle.introspectSinks(s)).toEqual([]); + expect(Signal.subtle.introspectSinks(c)).toEqual([]); + // @ts-expect-error + expect(() => Signal.subtle.introspectSinks(w)).toThrowError(TypeError); + }); +}); + +describe("currentComputed", () => { + it("works", () => { + expect(Signal.subtle.currentComputed()).toBe(undefined); + let context; + let c = new Signal.Computed( + () => (context = Signal.subtle.currentComputed()), + ); + c.get(); + // @ts-expect-error + expect(c).toBe(context); + }); +}); + +// Some things which we're comfortable with not hitting in code coverage: +// - The code for the callbacks (for reading signals and running watches) +// - Paths around writes being prohibited during computed/effect +// - Setters for various hooks +// - ngDevMode +// - Some predicates/getters for convenience, e.g., isReactive \ No newline at end of file diff --git a/packages/signal-polyfill/src/wrapper.ts b/packages/signal-polyfill/src/wrapper.ts new file mode 100644 index 0000000..b50cc76 --- /dev/null +++ b/packages/signal-polyfill/src/wrapper.ts @@ -0,0 +1,299 @@ +/* + ** 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 { +// A read-write Signal +export class State { + readonly [NODE]: SignalNode; + #brand() {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static isState(s: any): s is State { + return #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 (!State.isState(this)) + throw new TypeError( + "Wrong receiver type for Signal.State.prototype.get", + ); + return (signalGetFn).call(this[NODE]); + } + + public set(newValue: T): void { + if (!State.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): c is Computed { + return #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 (!Computed.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 (!Computed.isComputed(sink) && !Watcher.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 (!Computed.isComputed(signal) && !State.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 isWatched(signal: AnySignal): boolean { + if (!Computed.isComputed(signal) && !State.isState(signal)) { + throw new TypeError("Called isWatched without a Signal argument"); + } + const liveConsumerNode = signal[NODE].liveConsumerNode; + if (!liveConsumerNode) return false; + return liveConsumerNode.length > 0; + } + + export class Watcher { + readonly [NODE]: ReactiveNode; + + #brand() {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static isWatcher(w: any): w is Watcher { + return #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 (!Computed.isComputed(signal) && !State.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 (!Watcher.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 (!Watcher.isWatcher(this)) { + throw new TypeError("Called unwatch without Watcher receiver"); + } + this.#assertSignals(signals); + + const node = this[NODE]; + assertConsumerNode(node); + + let indicesToShift = []; + for (let i = 0; i < node.producerNode.length; i++) { + if (signals.includes(node.producerNode[i].wrapper)) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + indicesToShift.push(i); + } + } + for (const idx of indicesToShift) { + // Logic copied from producerRemoveLiveConsumerAtIndex, but reversed + const lastIdx = node.producerNode!.length - 1; + node.producerNode![idx] = node.producerNode![lastIdx]; + node.producerIndexOfThis[idx] = node.producerIndexOfThis[lastIdx]; + + node.producerNode.length--; + node.producerIndexOfThis.length--; + node.nextProducerIndex--; + + if (idx < node.producerNode.length) { + const idxConsumer = node.producerIndexOfThis[idx]; + const producer = node.producerNode[idx]; + assertProducerNode(producer); + producer.liveConsumerIndexOfThis[idxConsumer] = idx; + } + } + } + + // Returns the set of computeds in the Watcher's set which are still yet + // to be re-evaluated + getPending(): Computed[] { + if (!Watcher.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 isWatched becomes true, if it was previously false + [Signal.subtle.watched]?: (this: AnySignal) => void; + + // Callback called whenever isWatched becomes false, if it was previously true + [Signal.subtle.unwatched]?: (this: AnySignal) => void; +} +} \ No newline at end of file diff --git a/packages/signal-polyfill/tsconfig.json b/packages/signal-polyfill/tsconfig.json new file mode 100644 index 0000000..f7a398c --- /dev/null +++ b/packages/signal-polyfill/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "pretty": true, + "moduleResolution": "node", + "module": "ESNext", + "target": "ES2022", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "importHelpers": true, + "noEmitOnError": false, + "lib": [ + "DOM", + "ES2021" + ], + "strict": true, + "composite": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": [ + "**/node_modules/**", + "**/*.spec.ts", + "**/dist/**/*" + ], + "include": [ + "src" + ] +} \ No newline at end of file From 867b040719eb3191136df3faeef8c0b52559c89d Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 26 Mar 2024 16:37:29 -0400 Subject: [PATCH 03/11] chore: add vite and vitest setup --- .gitignore | 3 + packages/signal-polyfill/package.json | 18 +- packages/signal-polyfill/src/wrapper.spec.ts | 183 +++++++++---------- packages/signal-polyfill/src/wrapper.ts | 29 +-- packages/signal-polyfill/tsconfig.json | 1 - packages/signal-polyfill/vite.config.ts | 17 ++ 6 files changed, 132 insertions(+), 119 deletions(-) create mode 100644 packages/signal-polyfill/vite.config.ts diff --git a/.gitignore b/.gitignore index d3cae16..6a82cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ pnpm-lock.yaml # Build directory build +dist/ +*.tsbuildinfo +.DS_Store \ No newline at end of file diff --git a/packages/signal-polyfill/package.json b/packages/signal-polyfill/package.json index d5e4fe1..dfdab43 100644 --- a/packages/signal-polyfill/package.json +++ b/packages/signal-polyfill/package.json @@ -1,20 +1,22 @@ { "name": "signal-polyfill", "version": "0.1.0", - "description": "A polyfill for the TC39 Signals proposal.", + "description": "A polyfill for the TC39 Signal proposal.", "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", "scripts": { - "build": "tsc -b tsconfig.json", - "watch": "tsc -b tsconfig.json -watch" + "dev": "vite", + "build": "tsc && vite build", + "test": "vitest" }, "author": "EisenbergEffect", "license": "MIT", - "dependencies": { - "tslib": "latest" - }, "devDependencies": { - "typescript": "latest" + "@types/node": "^20.11.25", + "typescript": "latest", + "vite": "^5.2.6", + "vite-plugin-dts": "^3.7.3", + "vitest": "^1.4.0" } -} \ No newline at end of file +} diff --git a/packages/signal-polyfill/src/wrapper.spec.ts b/packages/signal-polyfill/src/wrapper.spec.ts index 2aba65c..635f327 100644 --- a/packages/signal-polyfill/src/wrapper.spec.ts +++ b/packages/signal-polyfill/src/wrapper.spec.ts @@ -15,6 +15,7 @@ */ /* eslint-disable @typescript-eslint/no-this-alias */ +import { afterEach, describe, expect, it, vi } from "vitest"; import { Signal } from './wrapper.js'; describe("Signal.State", () => { @@ -48,7 +49,7 @@ describe("Computed", () => { describe("Watcher", () => { type Destructor = () => void; - const notifySpy = jasmine.createSpy(); + const notifySpy = vi.fn(); const watcher = new Signal.subtle.Watcher(() => { notifySpy(); @@ -69,14 +70,14 @@ describe("Watcher", () => { for (const signal of watcher.getPending()) { signal.get(); } - expect(watcher.getPending()).toEqual([]); + expect(watcher.getPending()).toStrictEqual([]); } afterEach(() => watcher.unwatch(...Signal.subtle.introspectSources(watcher))); it("should work", () => { - const watchedSpy = jasmine.createSpy(); - const unwatchedSpy = jasmine.createSpy(); + const watchedSpy = vi.fn(); + const unwatchedSpy = vi.fn(); const stateSignal = new Signal.State(1, { [Signal.subtle.watched]: watchedSpy, [Signal.subtle.unwatched]: unwatchedSpy, @@ -92,13 +93,13 @@ describe("Watcher", () => { let computedOutput = 0; // Ensure the call backs are not called yet - expect(watchedSpy.calls.count()).toBe(0); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).not.toHaveBeenCalled(); + expect(unwatchedSpy).not.toHaveBeenCalled(); // Expect the watcher to not have any sources as nothing has been connected yet - expect(Signal.subtle.introspectSources(watcher)).toHaveSize(0); - expect(Signal.subtle.introspectSinks(computedSignal)).toHaveSize(0); - expect(Signal.subtle.introspectSinks(stateSignal)).toHaveSize(0); + expect(Signal.subtle.introspectSources(watcher)).toHaveLength(0); + expect(Signal.subtle.introspectSinks(computedSignal)).toHaveLength(0); + expect(Signal.subtle.introspectSinks(stateSignal)).toHaveLength(0); expect(Signal.subtle.isWatched(stateSignal)).toEqual(false); @@ -113,23 +114,23 @@ describe("Watcher", () => { expect(Signal.subtle.isWatched(stateSignal)).toEqual(true); // Now that the effect is created, there will be a source - expect(Signal.subtle.introspectSources(watcher)).toHaveSize(1); - expect(Signal.subtle.introspectSinks(computedSignal)).toHaveSize(1); + expect(Signal.subtle.introspectSources(watcher)).toHaveLength(1); + expect(Signal.subtle.introspectSinks(computedSignal)).toHaveLength(1); // Note: stateSignal has more sinks because one is for the computed signal and one is the effect. - expect(Signal.subtle.introspectSinks(stateSignal)).toHaveSize(2); + expect(Signal.subtle.introspectSinks(stateSignal)).toHaveLength(2); // Now the watched callback should be called - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).toHaveBeenCalled(); + expect(unwatchedSpy).not.toHaveBeenCalled(); // It should not have notified yet - expect(notifySpy.calls.count()).toBe(0); + expect(notifySpy).not.toHaveBeenCalled(); stateSignal.set(10); // After a signal has been set, it should notify - expect(notifySpy.calls.count()).toBe(1); + expect(notifySpy).toHaveBeenCalled(); // Initially, the effect should not have run expect(calls).toEqual(1); @@ -146,11 +147,11 @@ describe("Watcher", () => { // Kicking it off again, the effect should run again watcher.watch(); stateSignal.set(20); - expect(watcher.getPending()).toHaveSize(1); + expect(watcher.getPending()).toHaveLength(1); flushPending(); // After a signal has been set, it should notify again - expect(notifySpy.calls.count()).toBe(2); + expect(notifySpy).toHaveBeenCalledTimes(2); expect(calls).toEqual(3); expect(output).toEqual(20); @@ -168,7 +169,7 @@ describe("Watcher", () => { destructor(); // Since now it is un-subscribed, it should now be called - expect(unwatchedSpy.calls.count()).toBe(1); + expect(unwatchedSpy).toHaveBeenCalled(); // We can confirm that it is un-watched by checking it expect(Signal.subtle.isWatched(stateSignal)).toEqual(false); @@ -182,16 +183,17 @@ describe("Watcher", () => { expect(output).toEqual(999); expect(computedOutput).toEqual(1998); - expect(watcher.getPending()).toHaveSize(0); + expect(watcher.getPending()).toHaveLength(0); // Adding any other effect after an unwatch should work as expected - effect(() => { - output = stateSignal.get(); - return () => {}; + const destructor2 = effect(() => { + output = stateSignal.get(); + return () => {}; }); stateSignal.set(300); flushPending(); + }); }); @@ -215,10 +217,10 @@ describe("Comparison semantics", () => { return state.get(); }); expect(calls).toBe(0); - expect(Object.is(computed.get(), NaN)).toBeTrue(); + expect(computed.get()).toBe(NaN); expect(calls).toBe(1); state.set(NaN); - expect(Object.is(computed.get(), NaN)).toBeTrue(); + expect(computed.get()).toBe(NaN); expect(calls).toBe(1); }); @@ -239,10 +241,10 @@ describe("Comparison semantics", () => { expect(c2.get()).toBe(5); expect(calls).toBe(1); state.set(3); - expect(Object.is(c2.get(), NaN)).toBeTrue(); + expect(c2.get()).toBe(NaN); expect(calls).toBe(2); state.set(4); - expect(Object.is(c2.get(), NaN)).toBeTrue(); + expect(c2.get()).toBe(NaN); expect(calls).toBe(2); }); @@ -323,40 +325,40 @@ describe("Untrack", () => { describe("liveness", () => { it("only changes on first and last descendant", () => { - const watchedSpy = jasmine.createSpy(); - const unwatchedSpy = jasmine.createSpy(); + const watchedSpy = vi.fn(); + const unwatchedSpy = vi.fn(); const state = new Signal.State(1, { [Signal.subtle.watched]: watchedSpy, [Signal.subtle.unwatched]: unwatchedSpy, }); const computed = new Signal.Computed(() => state.get()); computed.get(); - expect(watchedSpy.calls.count()).toBe(0); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).not.toBeCalled(); + expect(unwatchedSpy).not.toBeCalled(); const w = new Signal.subtle.Watcher(() => {}); const w2 = new Signal.subtle.Watcher(() => {}); w.watch(computed); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).not.toBeCalled(); w2.watch(computed); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).not.toBeCalled(); w2.unwatch(computed); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).not.toBeCalled(); w.unwatch(computed); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(1); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).toBeCalledTimes(1); }); it("is tracked well on computed signals", () => { - const watchedSpy = jasmine.createSpy(); - const unwatchedSpy = jasmine.createSpy(); + const watchedSpy = vi.fn(); + const unwatchedSpy = vi.fn(); const s = new Signal.State(1); const c = new Signal.Computed(() => s.get(), { [Signal.subtle.watched]: watchedSpy, @@ -364,17 +366,17 @@ describe("liveness", () => { }); c.get(); - expect(watchedSpy.calls.count()).toBe(0); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).not.toBeCalled(); + expect(unwatchedSpy).not.toBeCalled(); const w = new Signal.subtle.Watcher(() => {}); w.watch(c); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(0); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).not.toBeCalled(); w.unwatch(c); - expect(watchedSpy.calls.count()).toBe(1); - expect(unwatchedSpy.calls.count()).toBe(1); + expect(watchedSpy).toBeCalledTimes(1); + expect(unwatchedSpy).toBeCalledTimes(1); }); }); @@ -392,17 +394,17 @@ describe("Errors", () => { return c.get(); }); expect(n).toBe(0); - expect(() => c.get()).toThrow("first"); - expect(() => c2.get()).toThrow("first"); + expect(() => c.get()).toThrowError("first"); + expect(() => c2.get()).toThrowError("first"); expect(n).toBe(1); expect(n2).toBe(1); - expect(() => c.get()).toThrow("first"); - expect(() => c2.get()).toThrow("first"); + expect(() => c.get()).toThrowError("first"); + expect(() => c2.get()).toThrowError("first"); expect(n).toBe(1); expect(n2).toBe(1); s.set("second"); - expect(() => c.get()).toThrow("second"); - expect(() => c2.get()).toThrow("second"); + expect(() => c.get()).toThrowError("second"); + expect(() => c2.get()).toThrowError("second"); expect(n).toBe(2); expect(n2).toBe(2); @@ -421,12 +423,12 @@ describe("Errors", () => { w.watch(c); expect(n).toBe(0); - expect(() => c.get()).toThrow("first"); + expect(() => c.get()).toThrowError("first"); expect(n).toBe(1); - expect(() => c.get()).toThrow("first"); + expect(() => c.get()).toThrowError("first"); expect(n).toBe(1); s.set("second"); - expect(() => c.get()).toThrow("second"); + expect(() => c.get()).toThrowError("second"); expect(n).toBe(2); s.set("second"); @@ -436,11 +438,11 @@ describe("Errors", () => { describe("Cycles", () => { it("detects trivial cycles", () => { - const c: Signal.Computed = new Signal.Computed(() => c.get()); + const c = new Signal.Computed(() => c.get()); expect(() => c.get()).toThrow(); }); it("detects slightly larger cycles", () => { - const c: Signal.Computed = new Signal.Computed(() => c2.get()); + const c = new Signal.Computed(() => c2.get()); const c2 = new Signal.Computed(() => c.get()); const c3 = new Signal.Computed(() => c2.get()); expect(() => c3.get()).toThrow(); @@ -615,24 +617,20 @@ describe("Custom equality", () => { describe("Receivers", () => { it("is this for computed", () => { - let receiver!: Signal.Computed; - const c: Signal.Computed = new Signal.Computed(function (this: Signal.Computed) { + let receiver; + const c = new Signal.Computed(function () { receiver = this; }); expect(c.get()).toBe(undefined); expect(receiver).toBe(c); }); it("is this for watched/unwatched", () => { - let r1: Signal.State | undefined, r2: Signal.State | undefined; - const s = new Signal.State(1, { + let r1, r2; + const s = new Signal.State(1, { [Signal.subtle.watched]() { - // TODO: Improve types to avoid this - // @ts-expect-error r1 = this; }, [Signal.subtle.unwatched]() { - // TODO: Improve types to avoid this - // @ts-expect-error r2 = this; }, }); @@ -646,18 +644,18 @@ describe("Receivers", () => { expect(r2).toBe(s); }); it("is this for equals", () => { - let receiver!: Signal.State | Signal.Computed; + let receiver; const options = { - equals(this: Signal.State | Signal.Computed, t1: number, t2: number) { + equals() { receiver = this; return false; }, }; - const s: Signal.State = new Signal.State(1, options); + const s = new Signal.State(1, options); s.set(2); expect(receiver).toBe(s); - const c: Signal.Computed = new Signal.Computed(() => s.get(), options); + const c = new Signal.Computed(() => s.get(), options); expect(c.get()).toBe(2); s.set(4); expect(c.get()).toBe(4); @@ -666,7 +664,7 @@ describe("Receivers", () => { }); describe("Dynamic dependencies", () => { - function run(live: boolean) { + function run(live) { const states = Array.from("abcdefgh").map((s) => new Signal.State(s)); const sources = new Signal.State(states); const computed = new Signal.Computed(() => { @@ -679,19 +677,19 @@ describe("Dynamic dependencies", () => { w.watch(computed); } expect(computed.get()).toBe("abcdefgh"); - expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual( states, ); sources.set(states.slice(0, 5)); expect(computed.get()).toBe("abcde"); - expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual( states.slice(0, 5), ); sources.set(states.slice(3)); expect(computed.get()).toBe("defgh"); - expect(Signal.subtle.introspectSources(computed).slice(1)).toEqual( + expect(Signal.subtle.introspectSources(computed).slice(1)).toStrictEqual( states.slice(3), ); } @@ -709,7 +707,7 @@ describe("watch and unwatch", () => { s.set(4); expect(n).toBe(1); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); w.watch(); s2.set(8); @@ -758,7 +756,7 @@ describe("watch and unwatch", () => { expect(w1 + w2 + u1 + u2 + n + d).toBe(0); expect(Signal.subtle.isWatched(s1)).toBe(false); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([c]); + expect(w.getPending()).toStrictEqual([c]); expect(c.get()).toBe(1); expect(w1).toBe(1); @@ -768,7 +766,7 @@ describe("watch and unwatch", () => { expect(n).toBe(0); expect(Signal.subtle.isWatched(s1)).toBe(true); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); expect(d).toBe(1); s1.set(3); @@ -779,7 +777,7 @@ describe("watch and unwatch", () => { expect(n).toBe(1); expect(Signal.subtle.isWatched(s1)).toBe(true); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([c]); + expect(w.getPending()).toStrictEqual([c]); expect(d).toBe(1); expect(c.get()).toBe(3); @@ -790,7 +788,7 @@ describe("watch and unwatch", () => { expect(n).toBe(1); expect(Signal.subtle.isWatched(s1)).toBe(true); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); expect(d).toBe(2); which = s2; @@ -803,7 +801,7 @@ describe("watch and unwatch", () => { expect(n).toBe(2); expect(Signal.subtle.isWatched(s1)).toBe(true); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([c]); + expect(w.getPending()).toStrictEqual([c]); expect(d).toBe(2); expect(c.get()).toBe(2); @@ -814,7 +812,7 @@ describe("watch and unwatch", () => { expect(n).toBe(2); expect(Signal.subtle.isWatched(s1)).toBe(false); expect(Signal.subtle.isWatched(s2)).toBe(true); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); expect(d).toBe(3); w.watch(); @@ -832,7 +830,7 @@ describe("watch and unwatch", () => { expect(n).toBe(2); expect(Signal.subtle.isWatched(s1)).toBe(false); expect(Signal.subtle.isWatched(s2)).toBe(true); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); expect(d).toBe(3); w.watch(); @@ -844,7 +842,7 @@ describe("watch and unwatch", () => { expect(n).toBe(3); expect(Signal.subtle.isWatched(s1)).toBe(false); expect(Signal.subtle.isWatched(s2)).toBe(true); - expect(w.getPending()).toEqual([c]); + expect(w.getPending()).toStrictEqual([c]); expect(d).toBe(3); expect(c.get()).toBe(10); @@ -855,7 +853,7 @@ describe("watch and unwatch", () => { expect(n).toBe(3); expect(Signal.subtle.isWatched(s1)).toBe(false); expect(Signal.subtle.isWatched(s2)).toBe(false); - expect(w.getPending()).toEqual([]); + expect(w.getPending()).toStrictEqual([]); expect(d).toBe(4); }); }); @@ -914,7 +912,6 @@ describe("type checks", () => { TypeError, ); expect(Signal.subtle.Watcher.prototype.watch.call(w, s)).toBe(undefined); - // @ts-expect-error expect(() => Signal.subtle.Watcher.prototype.watch.call(w, w)).toThrowError( TypeError, ); @@ -930,24 +927,19 @@ describe("type checks", () => { ).toThrowError(TypeError); expect(Signal.subtle.Watcher.prototype.unwatch.call(w, s)).toBe(undefined); expect(() => - // @ts-expect-error Signal.subtle.Watcher.prototype.unwatch.call(w, w), ).toThrowError(TypeError); expect(() => - // @ts-expect-error Signal.subtle.Watcher.prototype.getPending.call(x, s), ).toThrowError(TypeError); expect(() => - // @ts-expect-error Signal.subtle.Watcher.prototype.getPending.call(s, s), ).toThrowError(TypeError); expect(() => - // @ts-expect-error Signal.subtle.Watcher.prototype.getPending.call(c, s), ).toThrowError(TypeError); - // @ts-expect-error - expect(Signal.subtle.Watcher.prototype.getPending.call(w, s)).toEqual( + expect(Signal.subtle.Watcher.prototype.getPending.call(w, s)).toStrictEqual( [], ); @@ -955,8 +947,8 @@ describe("type checks", () => { expect(() => Signal.subtle.introspectSources(x)).toThrowError(TypeError); // @ts-expect-error expect(() => Signal.subtle.introspectSources(s)).toThrowError(TypeError); - expect(Signal.subtle.introspectSources(c)).toEqual([]); - expect(Signal.subtle.introspectSources(w)).toEqual([]); + expect(Signal.subtle.introspectSources(c)).toStrictEqual([]); + expect(Signal.subtle.introspectSources(w)).toStrictEqual([]); // @ts-expect-error expect(() => Signal.subtle.isWatched(x)).toThrowError(TypeError); @@ -967,8 +959,8 @@ describe("type checks", () => { // @ts-expect-error expect(() => Signal.subtle.introspectSinks(x)).toThrowError(TypeError); - expect(Signal.subtle.introspectSinks(s)).toEqual([]); - expect(Signal.subtle.introspectSinks(c)).toEqual([]); + expect(Signal.subtle.introspectSinks(s)).toStrictEqual([]); + expect(Signal.subtle.introspectSinks(c)).toStrictEqual([]); // @ts-expect-error expect(() => Signal.subtle.introspectSinks(w)).toThrowError(TypeError); }); @@ -982,7 +974,6 @@ describe("currentComputed", () => { () => (context = Signal.subtle.currentComputed()), ); c.get(); - // @ts-expect-error expect(c).toBe(context); }); }); diff --git a/packages/signal-polyfill/src/wrapper.ts b/packages/signal-polyfill/src/wrapper.ts index b50cc76..7f9a1a6 100644 --- a/packages/signal-polyfill/src/wrapper.ts +++ b/packages/signal-polyfill/src/wrapper.ts @@ -1,17 +1,18 @@ -/* - ** 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. +/** + * @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 { diff --git a/packages/signal-polyfill/tsconfig.json b/packages/signal-polyfill/tsconfig.json index f7a398c..f223b6e 100644 --- a/packages/signal-polyfill/tsconfig.json +++ b/packages/signal-polyfill/tsconfig.json @@ -9,7 +9,6 @@ "sourceMap": true, "declaration": true, "declarationMap": true, - "importHelpers": true, "noEmitOnError": false, "lib": [ "DOM", diff --git a/packages/signal-polyfill/vite.config.ts b/packages/signal-polyfill/vite.config.ts new file mode 100644 index 0000000..1fcdc1c --- /dev/null +++ b/packages/signal-polyfill/vite.config.ts @@ -0,0 +1,17 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +const entry = join(dirname(fileURLToPath(import.meta.url)), './src/index.ts'); + +export default defineConfig({ + plugins: [dts()], + build: { + lib: { + entry, + formats: ["es"], + fileName: "index" + } + } +}); \ No newline at end of file From d233896ce7a9f7f2243df21cf005c05425dfa8d8 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 26 Mar 2024 16:59:30 -0400 Subject: [PATCH 04/11] doc: add a signal example --- packages/signal-polyfill/readme.md | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index cc36237..bd774bf 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -64,4 +64,37 @@ export function effect(callback) { ``` > [!IMPORTANT] -> The `Signal.subtle` APIs are so named in order to communicate that their correct use requires careful attention to detail. These APIs are not targeted at application-level code, but rather at framework/library authors. \ No newline at end of file +> The `Signal.subtle` APIs are so named in order to communicate that their correct use requires careful attention to detail. These APIs are not targeted at application-level code, but rather at framework/library authors. + +### Using signals with decorators + +```js +import { Signal } from "signal-polyfill"; + +export function signal(target) { + const { get } = target; + + return { + get() { + return get.call(this).get(); + }, + + set(value) { + get.call(this).set(value); + }, + + init(value) { + return new Signal.State(value); + }, + }; +} + +export class Person { + @signal accessor firstName = ""; + @signal accessor lastName = ""; + + get fullName() { + return `${this.firstName} ${this.lastName}`; + } +} +``` \ No newline at end of file From b37a973625c4c632a3ffb8043b4f3435607940a2 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 26 Mar 2024 17:01:23 -0400 Subject: [PATCH 05/11] doc: add a decorator example --- packages/signal-polyfill/readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index bd774bf..a5aef2b 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -4,9 +4,9 @@ A "signal" is a proposed first-class JavaScript data type that enables one-way d This is a polyfill for the `Signal` API. -# Examples +## Examples -## Using signals +### Using signals ```js import { Signal } from "signal-polyfill"; @@ -66,7 +66,7 @@ export function effect(callback) { > [!IMPORTANT] > The `Signal.subtle` APIs are so named in order to communicate that their correct use requires careful attention to detail. These APIs are not targeted at application-level code, but rather at framework/library authors. -### Using signals with decorators +### Combining signals and decorators ```js import { Signal } from "signal-polyfill"; From 98c13a89b2ef038c50819bf791bf066c00507635 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 12:31:21 -0400 Subject: [PATCH 06/11] chore: set license and add contributors --- packages/signal-polyfill/LICENSE | 201 ++++++++++++++++++++++++++ packages/signal-polyfill/package.json | 7 +- 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 packages/signal-polyfill/LICENSE diff --git a/packages/signal-polyfill/LICENSE b/packages/signal-polyfill/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/packages/signal-polyfill/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. \ No newline at end of file diff --git a/packages/signal-polyfill/package.json b/packages/signal-polyfill/package.json index dfdab43..c4bf070 100644 --- a/packages/signal-polyfill/package.json +++ b/packages/signal-polyfill/package.json @@ -2,6 +2,11 @@ "name": "signal-polyfill", "version": "0.1.0", "description": "A polyfill for the TC39 Signal proposal.", + "contributors": [ + "Google LLC", + "Bloomberg Finance L.P.", + "EisenbergEffect" + ], "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", @@ -11,7 +16,7 @@ "test": "vitest" }, "author": "EisenbergEffect", - "license": "MIT", + "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.11.25", "typescript": "latest", From 91924a2850bbd0298e2104a499353ab1d7cc7dc2 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 12:38:33 -0400 Subject: [PATCH 07/11] doc: improve the effect implementation example --- packages/signal-polyfill/readme.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index a5aef2b..3a246ff 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -49,17 +49,20 @@ function processPending() { } export function effect(callback) { - let onUnwatch; - const computed = new Signal.Computed(() => onUnwatch = callback(), { - [Signal.subtle.unwatched]() { - typeof onUnwatch === "function" && onUnwatch(); - } + let cleanup; + + const computed = new Signal.Computed(() => { + typeof cleanup === "function" && cleanup(); + cleanup = callback(); }); - + w.watch(computed); computed.get(); - - return () => w.unwatch(computed); + + return () => { + w.unwatch(computed); + typeof cleanup === "function" && cleanup(); + }; } ``` From ef60edbc0ee764c931ab92b70e19c057476b2fcb Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 12:55:57 -0400 Subject: [PATCH 08/11] doc: extend the Using signals example with a bit more explanation and guidance --- packages/signal-polyfill/readme.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index 3a246ff..86a6642 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -8,6 +8,9 @@ This is a polyfill for the `Signal` API. ### Using signals +* Use `Signal.State(value)` to create a single "cell" of data that can flow through the unidirectional state graph. +* Use `Signal.Computed(callback)` to define a computation based on state or other computations flowing through the graph. + ```js import { Signal } from "signal-polyfill"; import { effect } from "./effect.js"; @@ -16,13 +19,26 @@ const counter = new Signal.State(0); const isEven = new Signal.Computed(() => (counter.get() & 1) == 0); const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd"); -effect(() => console.log(parity.get())); +effect(() => console.log(parity.get())); // Console logs "even" immediately. +setInterval(() => counter.set(counter.get() + 1), 1000); // Changes the counter every 1000ms. + +// effect triggers console log "odd" +// effect triggers console log "even" +// effect triggers console log "odd" +// ... +``` + +The signal proposal does not include an `effect` API, since such APIs are often deeply integrated with rendering and batch strategies that are highly framework/library dependent. However, the proposal does seek to define a set of primitives that library authors can use to implement their own effects. + +When working directly with library effect APIs, always be sure to understand the behavior of the `effect` implementation. While the signal algorithm is standardized, effects are not and may vary. To illustrate this, have a look at this code: -setInterval(() => counter.set(counter.get() + 1), 1000); +```js +counter.get(); // 0 +effect(() => counter.set(counter.get() + 1)); // Infinite loop??? +counter.get(); // 1 ``` -> [!NOTE] -> The signal proposal does not include an `effect` API, since such APIs are often deeply integrated with rendering and batch strategies that are highly framework/library dependent. However, the proposal does seek to define a set of primitives that library authors can use to implement their own effects. +Depending on how the effect is implemented, the above code could result in an infinite loop. It's also important to note that running the effect, in this case, causes an immediate invocation of the callback, changing the value of the counter. ### Creating a simple effect From 68291a7339c12729216852016e54711584e20dca Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 13:14:32 -0400 Subject: [PATCH 09/11] doc: update decorator example to show private state and controlled mutations --- packages/signal-polyfill/readme.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index 86a6642..6a86035 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -87,6 +87,8 @@ export function effect(callback) { ### Combining signals and decorators +A class accessor decorator can be combined with the `Signal.State()` API to enable improved DX. + ```js import { Signal } from "signal-polyfill"; @@ -107,13 +109,26 @@ export function signal(target) { }, }; } +``` + +The above decorator can be used on public or **private** accessors, enabling reactivity while carefully controlling state mutations. + +```js +export class Counter { + @signal accessor #value = 0; + + get value() { + return this.#value; + } -export class Person { - @signal accessor firstName = ""; - @signal accessor lastName = ""; + increment() { + this.#value++; + } - get fullName() { - return `${this.firstName} ${this.lastName}`; + decrement() { + if (this.#value > 0) { + this.#value--; + } } } ``` \ No newline at end of file From 06a7b0dd14ade07bcc6d3c0b939c5dea6b93fbcf Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 13:30:11 -0400 Subject: [PATCH 10/11] doc: add explanation to the effect example --- packages/signal-polyfill/readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index 6a86035..e2cb314 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -42,6 +42,12 @@ Depending on how the effect is implemented, the above code could result in an in ### Creating a simple effect +* You can use `Signal.subtle.Watch(callback)` combined with `Signal.Computed(callback)` to create a simple _effect_ implementation. +* The `Signal.subtle.Watch` `callback` is invoked synchronously when a watched signal becomes dirty. +* To batch effect updates, library authors are expected to implement their own schedulers. +* Use `Signal.subtle.Watch#getPending()` to retrieve an array of dirty signals. +* Calling `Signal.subtle.Watch#watch()` with no arguments will re-watch the list of tracked signals again. + ```js import { Signal } from "signal-polyfill"; From 8ea158302d7c6577d358f3c5762a5510d06f527e Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Wed, 27 Mar 2024 13:32:38 -0400 Subject: [PATCH 11/11] doc: add package readme link back to proposal readme --- packages/signal-polyfill/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/signal-polyfill/readme.md b/packages/signal-polyfill/readme.md index e2cb314..e6b3220 100644 --- a/packages/signal-polyfill/readme.md +++ b/packages/signal-polyfill/readme.md @@ -1,6 +1,6 @@ # Signal Polyfill -A "signal" is a proposed first-class JavaScript data type that enables one-way data flow through cells of state or computations derived from other state/computations. +A "signal" is [a proposed first-class JavaScript data type](../../README.md) that enables one-way data flow through cells of state or computations derived from other state/computations. This is a polyfill for the `Signal` API.