From 11938976cc2c59e4e2f065cf6dab299f48f411d2 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 14 Feb 2023 13:55:14 -0800 Subject: [PATCH] refactor(core): prototype of signals, a reactive primitive for Angular This commit checks in (but does not export) a prototype implementation of Angular Signals, along with its unit test suite and a README explaining the algorithms used. Signals are not a new concept in the framework space, but there are many different flavors of implementations. These differ radically both in terms of public API as well as behavioral details (such as eager vs lazy computation, batching behavior, equality, cleanup, nesting, etc). This commit comprises a bespoke implementation that we've designed to best meet Angular's needs, especially when it comes to compatibility and flexibility of use within existing applications. Many of the API features of this implementation of signals, as well as the larger direction of reactivity in Angular, will be discussed in future RFCs. Co-Authored-By: Pawel Kozlowski --- packages/core/BUILD.bazel | 2 + packages/core/src/signals/BUILD.bazel | 32 ++ packages/core/src/signals/README.md | 162 ++++++++++ packages/core/src/signals/index.ts | 15 + packages/core/src/signals/src/api.ts | 89 +++++ packages/core/src/signals/src/computed.ts | 164 ++++++++++ packages/core/src/signals/src/effect.ts | 104 ++++++ packages/core/src/signals/src/graph.ts | 304 ++++++++++++++++++ packages/core/src/signals/src/signal.ts | 108 +++++++ packages/core/src/signals/src/untracked.ts | 26 ++ packages/core/src/signals/src/watch.ts | 56 ++++ packages/core/src/signals/src/weak_ref.ts | 23 ++ packages/core/test/signals/BUILD.bazel | 30 ++ packages/core/test/signals/computed_spec.ts | 166 ++++++++++ packages/core/test/signals/effect_spec.ts | 110 +++++++ .../core/test/signals/glitch_free_spec.ts | 44 +++ .../core/test/signals/non_reactive_spec.ts | 116 +++++++ packages/core/test/signals/signal_spec.ts | 100 ++++++ 18 files changed, 1651 insertions(+) create mode 100644 packages/core/src/signals/BUILD.bazel create mode 100644 packages/core/src/signals/README.md create mode 100644 packages/core/src/signals/index.ts create mode 100644 packages/core/src/signals/src/api.ts create mode 100644 packages/core/src/signals/src/computed.ts create mode 100644 packages/core/src/signals/src/effect.ts create mode 100644 packages/core/src/signals/src/graph.ts create mode 100644 packages/core/src/signals/src/signal.ts create mode 100644 packages/core/src/signals/src/untracked.ts create mode 100644 packages/core/src/signals/src/watch.ts create mode 100644 packages/core/src/signals/src/weak_ref.ts create mode 100644 packages/core/test/signals/BUILD.bazel create mode 100644 packages/core/test/signals/computed_spec.ts create mode 100644 packages/core/test/signals/effect_spec.ts create mode 100644 packages/core/test/signals/glitch_free_spec.ts create mode 100644 packages/core/test/signals/non_reactive_spec.ts create mode 100644 packages/core/test/signals/signal_spec.ts diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 266dc0f48d01..171a1f0b115f 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -33,6 +33,7 @@ ng_module( "//packages/core/src/di/interface", "//packages/core/src/interface", "//packages/core/src/reflection", + "//packages/core/src/signals", "//packages/core/src/util", "//packages/zone.js/lib:zone_d_ts", "@npm//rxjs", @@ -100,6 +101,7 @@ api_golden_test( "//packages/core/src/di/interface", "//packages/core/src/interface", "//packages/core/src/reflection", + "//packages/core/src/signals", "//packages/core/src/util", ], entry_point = "angular/packages/core/src/render3/global_utils_api.d.ts", diff --git a/packages/core/src/signals/BUILD.bazel b/packages/core/src/signals/BUILD.bazel new file mode 100644 index 000000000000..0e10db6afff2 --- /dev/null +++ b/packages/core/src/signals/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "ts_library", "tsec_test") + +package(default_visibility = [ + "//packages:__pkg__", + "//packages/core:__subpackages__", + "//tools/public_api_guard:__pkg__", +]) + +ts_library( + name = "signals", + srcs = glob( + [ + "**/*.ts", + ], + ), + deps = [ + "//packages/core/src/util", + ], +) + +tsec_test( + name = "tsec_test", + target = "signals", + tsconfig = "//packages:tsec_config", +) + +filegroup( + name = "files_for_docgen", + srcs = glob([ + "*.ts", + ]), +) diff --git a/packages/core/src/signals/README.md b/packages/core/src/signals/README.md new file mode 100644 index 000000000000..40b6e0581288 --- /dev/null +++ b/packages/core/src/signals/README.md @@ -0,0 +1,162 @@ +# Angular Signals Implementation + +This directory contains the code for Angular's reactive primitive, an implementation of the "signal" concept. A signal is a value which is "reactive", meaning it can notify interested consumers when it changes. There are many different implementations of this concept, with different designs for how these notifications are subscribed to and propagated, how cleanup/unsubscription works, how dependencies are tracked, etc. This document describes the algorithm behind our specific implementation of the signal pattern. + +## Conceptual surface + +Angular Signals are zero-argument functions (`() => T`). When executed, they return the current value of the signal. Executing signals does not trigger side effects, though it may lazily recompute intermediate values (lazy memoization). + +Particular contexts (such as template expressions) can be _reactive_. In such contexts, executing a signal will return the value, but also register the signal as a dependency of the context in question. The context's owner will then be notified if any of its signal dependencies produces a new value (usually, this results in the re-execution of those expressions to consume the new values). + +This context and getter function mechanism allows for signal dependencies of a context to be tracked _automatically_ and _implicitly_. Users do not need to declare arrays of dependencies, nor does the set of dependencies of a particular context need to remain static across executions. + +### Settable signals: `signal()` + +The `signal()` function produces a specific type of signal known as a `SettableSignal`. In addition to being a getter function, `SettableSignal`s have an additional API for changing the value of the signal (along with notifying any dependents of the change). These include the `.set` operation for replacing the signal value, `.update` for deriving a new value, and `.mutate` for performing internal mutation of the current value. These are exposed as functions on the signal getter itself. + +```typescript +const counter = signal(0); + +counter.set(2); +counter.update(count => count + 1); +``` + +The signal value can be also updated in-place, using the dedicated `.mutate` method: + +```typescript +const todoList = signal { + list.push({title: 'One more task', completed: false}); +}); +``` + +#### Equality + +The signal creation function one can, optionally, specify an equality comparator function. The comparator is used to decide whether the new supplied value is the same, or different, as compared to the current signal’s value. + +If the equality function determines that 2 values are equal it will: +* block update of signal’s value; +* skip change propagation. + +### Declarative derived values: `computed()` + +`computed()` creates a memoizing signal, which calculates its value from the values of some number of input signals. + +```typescript +const counter = signal(0); + +// Automatically updates when `counter` changes: +const isEven = computed(() => counter() % 2 === 0); +``` + +Because the calculation function used to create the `computed` is executed in a reactive context, any signals read by that calculation will be tracked as dependencies, and the value of the computed signal recalculated whenever any of those dependencies changes. + +Similarly to signals, the `computed` can (optionally) specify an equality comparator function. + +### Side effects: `effect()` + +`effect()` schedules and runs a side-effectful function inside a reactive context. Signal dependencies of this function are captured, and the side effect is re-executed whenever any of its dependencies produces a new value. + +```typescript +const counter = signal(0); +effect(() => console.log('The counter is:', counter())); +// The counter is: 0 + +counter.set(1); +// The counter is: 1 +``` + +Effects do not execute synchronously with the set (see the section on glitch-free execution below), but are scheduled and resolved by the framework. The exact timing of effects is unspecified. + +## Producer and Consumer + +Internally, the signals implementation is defined in terms of two abstractions, `Producer` and `Consumer`, which are interfaces implemented by various parts of the reactivity system. `Producer` represents values which can deliver change notifications, such as the various flavors of `Signal`s. `Consumer` represents a reactive context which may depend on some number of `Producer`s. In other words, `Producer`s produce reactivity, and `Consumer`s consume it. + +Implementers of `Producer` and `Consumer` expose instances of data structures used by the signal library, and interact with the library through calls to utility functions. + +Some concepts are both `Producer`s _and_ `Consumer`s. For example, derived `computed` expressions consume other signals to produce new reactive values. + +### The Dependency Graph + +Both `Producer` and `Consumer` keep track of dependency `Edge`s to each other. `Producer`s are aware of which `Consumer`s depend on their value, while `Consumer`s are aware of all of the `Producer`s on which they depend. These references are always bidirectional. + +A major design feature of Angular Signals is that dependency `Edge`s are tracked using weak references (`WeakRef`). At any point, it's possible that a `Consumer` may go out of scope and be garbage collected, even if it is still referenced by a `Producer` (or vice versa). This removes the need for explicit cleanup operations that would remove these dependency edges for signals going "out of scope". Lifecycle management of signals is greatly simplified as a result, and there is no chance of memory leaks due to the dependency tracking. + +To simplify tracking `Edge`s via `WeakRef`s, both `Producer` and `Consumer` have numeric IDs generated when they're created. These IDs are used as `Map` keys instead of the tracked `Producer` or `Consumer` objects, which are instead stored in the `Edge` as `WeakRef`s. + +At various points during the read or write of signal values, these `WeakRef`s are dereferenced. If a reference turns out to be `undefined` (that is, the other side of the dependency edge was reclaimed by garbage collection), then the dependency `Edge` can be cleaned up. + +## "Glitch Free" property + +Consider the following setup: + +```typescript +const counter = signal(0); +const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd'); +effect(() => console.log(counter() + ' is ' + evenOrOdd()); + +counter.set(1); +``` + +When the effect is first created, it will print "0 is even", as expected, and record that both `counter` and `evenOrOdd` are dependencies of the logging effect. + +When `counter` is set to `1`, this invalidates both `evenOrOdd` and the logging effect. If `counter.set()` iterated through the dependencies of `counter` and triggered the logging effect first, before notifying `evenOrOdd` of the change, however, we might observe the inconsistent logging statement "1 is even". Eventually `evenOrOdd` would be notified, which would trigger the logging effect again, logging the correct statement "1 is odd". + +In this situation, the logging effect's observation of the inconsistent state "1 is even" is known as a _glitch_. A major goal of reactive system design is to prevent such intermediate states from ever being observed, and ensure _glitch-free execution_. + +### Push/Pull Algorithm + +Angular Signals guarantees glitch-free execution by separating updates to the `Producer`/`Consumer` graph into two phases. The first phase is performed eagerly when a `Producer` value is changed. This change notification is propagated through the graph, notifying `Consumer`s which depend on the `Producer` of the potential update. Some of these `Consumer`s may be derived values and thus also `Producer`s, which invalidate their cached values and then continue the propagation of the change notification to their own `Consumer`s, and so on. Other `Consumer`s may be effects, which schedule themselves for re-execution. + +Crucially, during this first phase, no side effects are run, and no recomputation of intermediate or derived values is performed, only invalidation of cached values. This allows the change notification to reach all affected nodes in the graph without the possibility of observing intermediate or glitchy states. + +Once this change propagation has completed (synchronously), the second phase can begin. In this second phase, signal values may be read by the application or framework, triggering recomputation of any needed derived values which were previously invalidated. + +We refer to this as the "push/pull" algorithm: "dirtiness" is eagerly _pushed_ through the graph when a source signal is changed, but recalculation is performed lazily, only when values are _pulled_ by reading their signals. + +## Dynamic Dependency Tracking + +When a reactive context operation (for example, an `effect`'s side effect function) is executed, the signals that it reads are tracked as dependencies. However, this may not be the same set of signals from one execution to the next. For example, this computed signal: + +```typescript +const dynamic = computed(() => useA() ? dataA() : dataB()); +``` + +reads either `dataA` or `dataB` depending on the value of the `useA` signal. At any given point, it will have a dependency set of either `[useA, dataA]` or `[useA, dataB]`, and it can never depend on `dataA` and `dataB` at the same time. + +The potential dependencies of a reactive context are unbounded. Signals may be stored in variables or other data structures and swapped out with other signals from time to time. Thus, the signals implementation must deal with potential changes in the set of dependencies of a `Consumer` on each execution. + +A naive approach would be to simply remove all old dependency edges before re-executing the reactive operation, or to mark them all as stale beforehand and remove the ones that don't get read. This is conceptually simple, but computationally heavy, especially for reactive contexts that have a largely unchanging set of dependencies. + +### Dependency Edge Versioning + +Instead, our implementation uses a lighter weight approach to dependency invalidation which relies on a monotonic version counter maintained by the `Consumer`, called the `trackingVersion`. Before the `Consumer`'s reactive operation is executed, its `trackingVersion` is incremented. When a signal is read, the `trackingVersion` of the `Consumer` is stored in the dependency `Edge`, where it is available to the `Producer`. + +When a `Producer` has an updated value, it iterates through its outgoing edges to any interested `Consumer`s to notify them of the change. At this point, the `Producer` can check whether the dependency is current or stale by comparing the `Consumer`'s current `trackingVersion` to the one stored on the dependency `Edge`. A mismatch means that the `Consumer`'s dependencies have changed and no longer include that `Producer`, so that `Consumer` is not notified and the stale edge is instead removed. + +## Equality Semantics + +`Producer`s may lazily produce their value (such as a `computed` which only recalculates its value when pulled). However, a `Producer` may also choose to apply an equality check to the values that it produces, and determine that the newly computed value is "equal" semantically to the previous. In this case, `Consumer`s which depend on that value should not be re-executed. For example, the following effect: + +```typescript +const counter = signal(0); +const isEven = computed(() => counter() % 2 === 0); +effect(() => console.log(isEven() ? 'even!' : 'odd!')); +``` + +should run if `counter` is updated to `1` as the value of `isEven` switches from `true` to `false`. But if `counter` is then set to `3`, `isEven` will recompute the same value: `false`. Therefore the logging effect should not run. + +This is a tricky property to guarantee in our implementation because values are not recomputed during the push phase of change propagation. `isEven` is invalidated when `counter` is changed, which causes the logging `effect` to also be invalidated and scheduled. Naively, `isEven` wouldn't be recomputed until the logging effect actually runs and attempts to read its value, which is too late to notice that it didn't need to run at all. + +### Value Versioning + +To solve this problem, our implementation uses a similar technique to tracking dependency staleness. `Producer`s track a monotonically increasing `valueVersion`, representing the semantic identity of their value. `valueVersion` is incremented when the `Producer` produces a semantically new value. The current `valueVersion` is saved into the dependency `Edge` structure when a `Consumer` reads from the `Producer`. + +Before `Consumer`s trigger their reactive operations (e.g. the side effect function for `effect`s, or the recomputation for `computed`s), they poll their dependencies and ask for `valueVersion` to be refreshed if needed. For a `computed`, this will trigger recomputation of the value and the subsequent equality check, if the value is stale (which makes this polling a recursive process as the `computed` is also a `Consumer` which will poll its own `Producer`s). If this recomputation produces a semantically changed value, `valueVersion` is incremented. + +The `Consumer` can then compare the `valueVersion` of the new value with the one cached in its dependency `Edge`, to determine if that particular dependency really did change. By doing this for all `Producer`s, the `Consumer` can determine that, if all `valueVersion`s match, that no _actual_ change to any dependency has occurred, and it can skip reacting to that change (e.g. skip running the side effect function). + +## `Watch` primitive + +`Watch` is a primitive used to build different types of effects. `Watch`es are `Consumer`s that run side-effectful functions in their reactive context, but where the scheduling of the side effect is delegated to the implementor. The `Watch` will call this scheduling operation when it receives a notification that it's stale. diff --git a/packages/core/src/signals/index.ts b/packages/core/src/signals/index.ts new file mode 100644 index 000000000000..10247bfe699d --- /dev/null +++ b/packages/core/src/signals/index.ts @@ -0,0 +1,15 @@ +/** + * @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 + */ + +export {isSignal, Signal, ValueEqualityFn} from './src/api'; +export {computed} from './src/computed'; +export {effect} from './src/effect'; +export {setActiveConsumer} from './src/graph'; +export {SettableSignal, signal} from './src/signal'; +export {untracked as untrack} from './src/untracked'; +export {Watch} from './src/watch'; diff --git a/packages/core/src/signals/src/api.ts b/packages/core/src/signals/src/api.ts new file mode 100644 index 000000000000..e5528079e8b3 --- /dev/null +++ b/packages/core/src/signals/src/api.ts @@ -0,0 +1,89 @@ +/** + * @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 + */ + +/** + * 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. + */ +const SIGNAL = Symbol('SIGNAL'); + +/** + * A reactive value which notifies consumers of any changes. + * + * Signals are functions which returns their current value. To access the current value of a signal, + * call it. + * + * Ordinary values can be turned into `Signal`s with the `signal` function. + * + * @developerPreview + */ +export type Signal = (() => T)&{ + [SIGNAL]: true; +}; + +/** + * Checks if the given `value` function is a reactive `Signal`. + */ +export function isSignal(value: Function): value is Signal { + return (value as Signal)[SIGNAL] ?? false; +} + +/** + * Converts `fn` into a marked signal function (where `isSignal(fn)` will be `true`). + * + * @param fn A zero-argument function which will be converted into a `Signal`. + */ +export function createSignalFromFunction(fn: () => T): Signal; + +/** + * Converts `fn` into a marked signal function (where `isSignal(fn)` will be `true`), and + * potentially add some set of extra properties (passed as an object record `extraApi`). + * + * @param fn A zero-argument function which will be converted into a `Signal`. + * @param extraApi An object whose properties will be copied onto `fn` in order to create a specific + * desired interface for the `Signal`. + */ +export function createSignalFromFunction>( + fn: () => T, extraApi: U): Signal&U; + +/** + * Converts `fn` into a marked signal function (where `isSignal(fn)` will be `true`), and + * potentially add some set of extra properties (passed as an object record `extraApi`). + */ +export function createSignalFromFunction = {}>( + fn: () => T, extraApi: U = ({} as U)): Signal&U { + (fn as any)[SIGNAL] = true; + // Copy properties from `extraApi` to `fn` to complete the desired API of the `Signal`. + return Object.assign(fn, extraApi) as (Signal& U); +} + +/** + * A comparison function which can determine if two values are equal. + * + * @developerPreview + */ +export type ValueEqualityFn = (a: T, b: T) => boolean; + +/** + * The default equality function used for `signal` and `computed`, which treats objects and arrays + * as never equal, and all other primitive values using identity semantics. + * + * This allows signals to hold non-primitive values (arrays, objects, other collections) and still + * propagate change notification upon explicit mutation without identity change. + * + * @developerPreview + */ +export function defaultEquals(a: T, b: T) { + // `Object.is` compares two values using identity semantics which is desired behavior for + // primitive values. If `Object.is` determines two values to be equal we need to make sure that + // those don't represent objects (we want to make sure that 2 objects are always considered + // "unequal"). The null check is needed for the special case of JavaScript reporting null values + // as objects (`typeof null === 'object'`). + return (a === null || typeof a !== 'object') && Object.is(a, b); +} diff --git a/packages/core/src/signals/src/computed.ts b/packages/core/src/signals/src/computed.ts new file mode 100644 index 000000000000..0062e896297c --- /dev/null +++ b/packages/core/src/signals/src/computed.ts @@ -0,0 +1,164 @@ +/** + * @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 {createSignalFromFunction, defaultEquals, Signal, ValueEqualityFn} from './api'; +import {Consumer, ConsumerId, consumerPollValueStatus, Edge, nextReactiveId, Producer, producerAccessed, ProducerId, producerNotifyConsumers, setActiveConsumer} from './graph'; +import {WeakRef} from './weak_ref'; + +/** + * Create a computed `Signal` which derives a reactive value from an expression. + * + * @developerPreview + */ +export function computed( + computation: () => T, equal: ValueEqualityFn = defaultEquals): Signal { + const node = new ComputedImpl(computation, equal); + return createSignalFromFunction(node.signal.bind(node)); +} + +/** + * 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 = 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 = 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 = Symbol('ERRORED'); + +/** + * A computation, which derives a value from a declarative reactive expression. + * + * `Computed`s are both `Producer`s and `Consumer`s of reactivity. + */ +class ComputedImpl implements Producer, Consumer { + /** + * Current value of the computation. + * + * This can also be one of the special values `UNSET`, `COMPUTING`, or `ERRORED`. + */ + private value: T = UNSET; + + /** + * If `value` is `ERRORED`, the error caught from the last computation attempt which will + * be re-thrown. + */ + private error: unknown = null; + + /** + * Flag indicating that the computation is currently stale, meaning that one of the + * dependencies has notified of a potential change. + * + * It's possible that no dependency has _actually_ changed, in which case the `stale` + * state can be resolved without recomputing the value. + */ + private stale = true; + + readonly id = nextReactiveId(); + readonly ref = new WeakRef(this); + readonly producers = new Map(); + readonly consumers = new Map(); + trackingVersion = 0; + valueVersion = 0; + + constructor(private computation: () => T, private equal: (oldValue: T, newValue: T) => boolean) {} + + checkForChangedValue(): void { + if (!this.stale) { + // The current value and its version are already up to date. + return; + } + + // The current value is stale. Check whether we need to produce a new one. + + if (this.value !== UNSET && this.value !== COMPUTING && !consumerPollValueStatus(this)) { + // Even though we were previously notified of a potential dependency update, all of + // our dependencies report that they have not actually changed in value, so we can + // resolve the stale state without needing to recompute the current value. + this.stale = false; + return; + } + + // The current value is stale, and needs to be recomputed. It still may not change - + // that depends on whether the newly computed value is equal to the old. + this.recomputeValue(); + } + + private recomputeValue(): void { + if (this.value === COMPUTING) { + // Our computation somehow led to a cyclic read of itself. + throw new Error('Detected cycle in computations.'); + } + + const oldValue = this.value; + this.value = COMPUTING; + + // As we're re-running the computation, update our dependent tracking version number. + this.trackingVersion++; + const prevConsumer = setActiveConsumer(this); + let newValue: T; + try { + newValue = this.computation(); + } catch (err) { + newValue = ERRORED; + this.error = err; + } finally { + setActiveConsumer(prevConsumer); + } + + this.stale = false; + + if (oldValue !== UNSET && oldValue !== ERRORED && newValue !== ERRORED && + this.equal(oldValue, newValue)) { + // No change to `valueVersion` - old and new values are + // semantically equivalent. + this.value = oldValue; + return; + } + + this.value = newValue; + this.valueVersion++; + } + + notify(): void { + if (this.stale) { + // We've already notified consumers that this value has potentially changed. + return; + } + + // Record that the currently cached value may be stale. + this.stale = true; + + // Notify any consumers about the potential change. + producerNotifyConsumers(this); + } + + signal(): T { + // Check if the value needs updating before returning it. + this.checkForChangedValue(); + + // Record that someone looked at this signal. + producerAccessed(this); + + if (this.value === ERRORED) { + throw this.error; + } + + return this.value; + } +} diff --git a/packages/core/src/signals/src/effect.ts b/packages/core/src/signals/src/effect.ts new file mode 100644 index 000000000000..5bf1f396d8c2 --- /dev/null +++ b/packages/core/src/signals/src/effect.ts @@ -0,0 +1,104 @@ +/** + * @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 {Consumer} from './graph'; +import {Watch} from './watch'; + +/** + * A global reactive effect, which can be manually scheduled or destroyed. + */ +export interface Effect { + /** + * Schedule the effect for manual execution, if it's not already. + */ + schedule(): void; + + /** + * Shut down the effect, removing it from any upcoming scheduled executions. + */ + destroy(): void; + + /** + * Direct access to the effect's `Consumer` for advanced use cases. + */ + readonly consumer: Consumer; +} + +/** + * Create a global `Effect` for the given reactive function. + * + * @developerPreview + */ +export function effect(effectFn: () => void): Effect { + const watch = new Watch(effectFn, queueWatch); + globalWatches.add(watch); + + // Effects start dirty. + watch.notify(); + + return { + consumer: watch, + schedule: watch.notify.bind(watch), + destroy: () => { + queuedWatches.delete(watch); + globalWatches.delete(watch); + }, + }; +} + +/** + * Get a `Promise` that resolves when any scheduled effects have resolved. + */ +export function effectsDone(): Promise { + return watchQueuePromise?.promise ?? Promise.resolve(); +} + +/** + * Shut down all active effects. + */ +export function resetEffects(): void { + queuedWatches.clear(); + globalWatches.clear(); +} + +const globalWatches = new Set(); +const queuedWatches = new Set(); + +let watchQueuePromise: {promise: Promise; resolveFn: () => void;}|null = null; + +function queueWatch(watch: Watch): void { + if (queuedWatches.has(watch) || !globalWatches.has(watch)) { + return; + } + + queuedWatches.add(watch); + + if (watchQueuePromise === null) { + Promise.resolve().then(runWatchQueue); + + let resolveFn!: () => void; + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + + watchQueuePromise = { + promise, + resolveFn, + }; + } +} + +function runWatchQueue(): void { + for (const watch of queuedWatches) { + queuedWatches.delete(watch); + watch.run(); + } + + watchQueuePromise!.resolveFn(); + watchQueuePromise = null; +} diff --git a/packages/core/src/signals/src/graph.ts b/packages/core/src/signals/src/graph.ts new file mode 100644 index 000000000000..0b3f83058ae6 --- /dev/null +++ b/packages/core/src/signals/src/graph.ts @@ -0,0 +1,304 @@ +/** + * @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 {WeakRef} from './weak_ref'; + +/** + * Identifier for a `Producer`, which is a branded `number`. + * + * Note that `ProducerId` and `ConsumerId` are assigned from the same sequence, so the same `number` + * will never be used for both. + * + * Branding provides additional type safety by ensuring that `ProducerId` and `ConsumerId` are + * mutually unassignable without a cast. Since several `Map`s are keyed by these IDs, this prevents + * `ProducerId`s from being inadvertently used to look up `Consumer`s or vice versa. + */ +export type ProducerId = number&{__producer: true}; + +/** + * Identifier for a `Consumer`, which is a branded `number`. + * + * Note that `ProducerId` and `ConsumerId` are assigned from the same sequence, so the same `number` + * will never be used for both. + * + * Branding provides additional type safety by ensuring that `ProducerId` and `ConsumerId` are + * mutually unassignable without a cast. Since several `Map`s are keyed by these IDs, this prevents + * `ConsumerId`s from being inadvertently used to look up `Producer`s or vice versa. + */ +export type ConsumerId = number&{__consumer: true}; + +/** + * Tracks the currently active reactive context (or `null` if there is no active + * context). + */ +let activeConsumer: Consumer|null = null; + +/** + * Counter tracking the next `ProducerId` or `ConsumerId`. + */ +let _nextReactiveId: number = 0; + +/** + * Get a new `ProducerId` or `ConsumerId`, allocated from the global sequence. + * + * The value returned is a type intersection of both branded types, and thus can be assigned to + * either. + */ +export function nextReactiveId(): ProducerId&ConsumerId { + return (_nextReactiveId++ as ProducerId & ConsumerId); +} + +/** + * Set `consumer` as the active reactive context, and return the previous `Consumer` + * (if any) for later restoration. + */ +export function setActiveConsumer(consumer: Consumer|null): Consumer|null { + const prevConsumer = activeConsumer; + activeConsumer = consumer; + return prevConsumer; +} + +/** + * A bidirectional edge in the producer-consumer dependency graph. + */ +export interface Edge { + /** + * Weakly held reference to the `Consumer` side of this edge. + */ + readonly consumerRef: WeakRef; + + /** + * Weakly held reference to the `Producer` side of this edge. + */ + readonly producerRef: WeakRef; + + /** + * `trackingVersion` of the `Consumer` at which this dependency edge was last observed. + * + * If this doesn't match the `Consumer`'s current `trackingVersion`, then this dependency record + * is stale, and needs to be cleaned up. + */ + atTrackingVersion: number; + + /** + * `valueVersion` of the `Producer` at the time this dependency was last accessed. + * + * This is used by `consumerPollValueStatus` to determine whether a `Consumer`'s dependencies have + * semantically changed. + */ + seenValueVersion: number; +} + +/** + * Represents a value that can be read reactively, and can notify readers (`Consumer`s) + * when it changes. + * + * Producers maintain a weak reference to any `Consumer`s which may depend on the + * producer's value. + * + * Implementers of `Producer` expose a monotonic `valueVersion` counter, and are responsible + * for incrementing this version when their value semantically changes. Some Producers may + * produce this value lazily and thus at times need to be polled for potential updates to + * their value (and by extension their `valueVersion`). This is accomplished via the + * `checkForChangedValue` method for Producers, which should perform whatever calculations + * are necessary to ensure `valueVersion` is up to date. + * + * `Producer`s support two operations: + * * `producerNotifyConsumers` + * * `producerAccessed` + */ +export interface Producer { + /** + * Numeric identifier of this `Producer`. + * + * May also be used to satisfy the interface for `Consumer`. + */ + readonly id: ProducerId; + + /** + * A `WeakRef` to this `Producer` instance. + * + * An implementer provides this as a cached value to avoid the need to instantiate + * multiple `WeakRef` instances for the same `Producer`. + * + * May also be used to satisfy the interface for `Consumer`. + */ + readonly ref: WeakRef; + + /** + * A map of dependency `Edge`s to `Consumer`s, keyed by the `ConsumerId`. + * + * Used when the produced value changes to notify interested `Consumer`s. + */ + readonly consumers: Map; + + /** + * Monotonically increasing counter which increases when the value of this `Producer` + * semantically changes. + */ + readonly valueVersion: number; + + /** + * Ensure that `valueVersion` is up to date for the `Producer`'s value. + * + * Some `Producer`s may produce values lazily, and thus require polling before their + * `valueVersion` can be compared with the version captured during a previous read. + */ + checkForChangedValue(): void; +} + +/** + * Notify all `Consumer`s of the given `Producer` that its value may have changed. + */ +export function producerNotifyConsumers(producer: Producer): void { + for (const [consumerId, edge] of producer.consumers) { + const consumer = edge.consumerRef.deref(); + if (consumer === undefined || consumer.trackingVersion !== edge.atTrackingVersion) { + producer.consumers.delete(consumerId); + consumer?.producers.delete(producer.id); + continue; + } + + consumer.notify(); + } +} + +/** + * Record a dependency on the given `Producer` by the current reactive `Consumer` if + * one is present. + */ +export function producerAccessed(producer: Producer): void { + if (activeConsumer === null) { + return; + } + + // Either create or update the dependency `Edge` in both directions. + let edge = activeConsumer.producers.get(producer.id); + if (edge === undefined) { + edge = { + consumerRef: activeConsumer.ref, + producerRef: producer.ref, + seenValueVersion: producer.valueVersion, + atTrackingVersion: activeConsumer.trackingVersion, + }; + activeConsumer.producers.set(producer.id, edge); + producer.consumers.set(activeConsumer.id, edge); + } else { + edge.seenValueVersion = producer.valueVersion; + edge.atTrackingVersion = activeConsumer.trackingVersion; + } +} + +/** + * Checks if a `Producer` has a current value which is different than the value + * last seen at a specific version by a `Consumer` which recorded a dependency on + * this `Producer`. + */ +function producerPollStatus(producer: Producer, lastSeenValueVersion: number): boolean { + // `producer.valueVersion` may be stale, but a mismatch still means that the value + // last seen by the `Consumer` is also stale. + if (producer.valueVersion !== lastSeenValueVersion) { + return true; + } + + // Trigger the `Producer` to update its `valueVersion` if necessary. + producer.checkForChangedValue(); + + // At this point, we can trust `producer.valueVersion`. + return producer.valueVersion !== lastSeenValueVersion; +} + +/** + * Represents a reader that can depend on reactive values (`Producer`s) and receive + * notifications when those values change. + * + * `Consumer`s do not wrap the reads they consume themselves, but rather can be set + * as the active reader via `setActiveConsumer`. + * + * The set of dependencies of a `Consumer` is dynamic. Implementers expose a + * monotonically increasing `trackingVersion` counter, which increments whenever + * the `Consumer` is about to re-run any reactive reads it needs and establish a + * new set of dependencies as a result. + * + * `Producer`s store the last `trackingVersion` they've seen from `Consumer`s which + * have read them. This allows a `Producer` to identify whether its record of the + * dependency is current or stale, by comparing the `Consumer`'s `trackingVersion` + * to the version at which the dependency was established. + */ +export interface Consumer { + /** + * Numeric identifier of this `Producer`. + * + * May also be used to satisfy the interface for `Producer`. + */ + readonly id: ConsumerId; + + /** + * A `WeakRef` to this `Consumer` instance. + * + * An implementer provides this as a cached value to avoid the need to instantiate + * multiple `WeakRef` instances for the same `Consumer`. + * + * May also be used to satisfy the interface for `Producer`. + */ + readonly ref: WeakRef; + + /** + * A map of `Edge`s to `Producer` dependencies, keyed by the `ProducerId`. + * + * Used to poll `Producer`s to determine if the `Consumer` has really updated + * or not. + */ + readonly producers: Map; + + /** + * Monotonically increasing counter representing a version of this `Consumer`'s + * dependencies. + */ + readonly trackingVersion: number; + + /** + * Called when a `Producer` dependency of this `Consumer` indicates it may + * have a new value. + * + * Notification alone does not mean the `Producer` has definitely produced a + * semantically different value, only that it _may_ have changed. Before a + * `Consumer` re-runs any computations or side effects, it should use the + * `consumerPollValueStatus` method to poll the `Producer`s on which it depends + * and determine if any of them have actually updated. + */ + notify(): void; +} + +/** + * Function called to check the stale status of dependencies (producers) for a given consumer. This + * is a verification step before refreshing a given consumer: if none of the the dependencies + * reports a semantically new value, then the `Consumer` has not observed a real dependency change + * (even though it may have been notified of one). + */ +export function consumerPollValueStatus(consumer: Consumer): boolean { + for (const [producerId, edge] of consumer.producers) { + const producer = edge.producerRef.deref(); + + if (producer === undefined || edge.atTrackingVersion !== consumer.trackingVersion) { + // This dependency edge is stale, so remove it. + consumer.producers.delete(producerId); + producer?.consumers.delete(consumer.id); + continue; + } + + if (producerPollStatus(producer, edge.seenValueVersion)) { + // One of the dependencies reports a real value change. + return true; + } + } + + // No dependency reported a real value change, so the `Consumer` has also not been + // impacted. + return false; +} diff --git a/packages/core/src/signals/src/signal.ts b/packages/core/src/signals/src/signal.ts new file mode 100644 index 000000000000..4b44a6a87187 --- /dev/null +++ b/packages/core/src/signals/src/signal.ts @@ -0,0 +1,108 @@ +/** + * @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 {createSignalFromFunction, defaultEquals, Signal, ValueEqualityFn} from './api'; +import {ConsumerId, Edge, nextReactiveId, Producer, producerAccessed, producerNotifyConsumers} from './graph'; +import {WeakRef} from './weak_ref'; + +/** + * A `Signal` with a value that can be mutated via a setter interface. + * + * @developerPreview + */ +export interface SettableSignal extends Signal { + /** + * Directly set the signal to a new value, and notify any dependents. + */ + set(value: T): void; + + /** + * Update the value of the signal based on its current value, and + * notify any dependents. + */ + update(updateFn: (value: T) => T): void; + + /** + * Update the current value by mutating it in-place, and + * notify any dependents. + */ + mutate(mutatorFn: (value: T) => void): void; +} + +/** + * Backing type for a `SettableSignal`, a mutable reactive value. + */ +class SettableSignalImpl implements Producer { + constructor(private value: T, private equal: ValueEqualityFn) {} + + readonly id = nextReactiveId(); + readonly ref = new WeakRef(this); + readonly consumers = new Map(); + valueVersion = 0; + + checkForChangedValue(): void { + // Settable signals can only change when set, so there's nothing to check here. + } + + /** + * Directly update the value of the signal to a new value, which may or may not be + * equal to the previous. + * + * In the event that `newValue` is semantically equal to the current value, `set` is + * a no-op. + */ + set(newValue: T): void { + if (!this.equal(this.value, newValue)) { + this.value = newValue; + this.valueVersion++; + producerNotifyConsumers(this); + } + } + + /** + * Derive a new value for the signal from its current value using the `updater` function. + * + * This is equivalent to calling `set` on the result of running `updater` on the current + * value. + */ + update(updater: (value: T) => T): void { + this.set(updater(this.value)); + } + + /** + * Calls `mutator` on the current value and assumes that it has been mutated. + */ + mutate(mutator: (value: T) => void): void { + // Mutate bypasses equality checks as it's by definition changing the value. + mutator(this.value); + this.valueVersion++; + producerNotifyConsumers(this); + } + + signal(): T { + producerAccessed(this); + return this.value; + } +} + +/** + * Create a `Signal` that can be set or updated directly. + * + * @developerPreview + */ +export function signal( + initialValue: T, equal: ValueEqualityFn = defaultEquals): SettableSignal { + const signalNode = new SettableSignalImpl(initialValue, equal); + // Casting here is required for g3. + const signalFn = createSignalFromFunction(signalNode.signal.bind(signalNode), { + set: signalNode.set.bind(signalNode), + update: signalNode.update.bind(signalNode), + mutate: signalNode.mutate.bind(signalNode), + }) as unknown as SettableSignal; + return signalFn; +} diff --git a/packages/core/src/signals/src/untracked.ts b/packages/core/src/signals/src/untracked.ts new file mode 100644 index 000000000000..5d1a60498a32 --- /dev/null +++ b/packages/core/src/signals/src/untracked.ts @@ -0,0 +1,26 @@ +/** + * @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 {setActiveConsumer} from './graph'; + +/** + * Execute an arbitrary function in a non-reactive (non-tracking) context. The executed function + * can, optionally, return a value. + * + * @developerPreview + */ +export function untracked(nonReactiveReadsFn: () => T): T { + const prevConsumer = setActiveConsumer(null); + // We are not trying to catch any particular errors here, just making sure that the consumers + // stack is restored in case of errors. + try { + return nonReactiveReadsFn(); + } finally { + setActiveConsumer(prevConsumer); + } +} diff --git a/packages/core/src/signals/src/watch.ts b/packages/core/src/signals/src/watch.ts new file mode 100644 index 000000000000..ba96d6c917c4 --- /dev/null +++ b/packages/core/src/signals/src/watch.ts @@ -0,0 +1,56 @@ +/** + * @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 {Consumer, consumerPollValueStatus, Edge, nextReactiveId, ProducerId, setActiveConsumer} from './graph'; +import {WeakRef} from './weak_ref'; + +/** + * Watches a reactive expression and allows it to be scheduled to re-run + * when any dependencies notify of a change. + * + * `Watch` doesn't run reactive expressions itself, but relies on a consumer- + * provided scheduling operation to coordinate calling `Watch.run()`. + */ +export class Watch implements Consumer { + readonly id = nextReactiveId(); + readonly ref = new WeakRef(this); + readonly producers = new Map(); + trackingVersion = 0; + + private dirty = false; + + constructor(private watch: () => void, private schedule: (watch: Watch) => void) {} + + notify(): void { + if (!this.dirty) { + this.schedule(this); + } + this.dirty = true; + } + + /** + * Execute the reactive expression in the context of this `Watch` consumer. + * + * Should be called by the user scheduling algorithm when the provided + * `schedule` hook is called by `Watch`. + */ + run(): void { + this.dirty = false; + if (this.trackingVersion !== 0 && !consumerPollValueStatus(this)) { + return; + } + + const prevConsumer = setActiveConsumer(this); + this.trackingVersion++; + try { + this.watch(); + } finally { + setActiveConsumer(prevConsumer); + } + } +} diff --git a/packages/core/src/signals/src/weak_ref.ts b/packages/core/src/signals/src/weak_ref.ts new file mode 100644 index 000000000000..53587229f29e --- /dev/null +++ b/packages/core/src/signals/src/weak_ref.ts @@ -0,0 +1,23 @@ +/** + * @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 {global} from '../../util/global'; + +// `WeakRef` is not always defined in every TS environment where Angular is compiled. Instead, +// alias it as a local export by reading it off of the global context. + +export interface WeakRef { + deref(): T|undefined; +} + +export interface WeakRefCtor { + new(value: T): WeakRef; +} + +// tslint:disable-next-line: no-toplevel-property-access +export const WeakRef: WeakRefCtor = global['WeakRef']; diff --git a/packages/core/test/signals/BUILD.bazel b/packages/core/test/signals/BUILD.bazel new file mode 100644 index 000000000000..f91a0b7e301d --- /dev/null +++ b/packages/core/test/signals/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "karma_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:private"]) + +ts_library( + name = "signals_lib", + testonly = True, + srcs = glob( + ["**/*.ts"], + ), + deps = [ + "//packages/core/src/signals", + "//packages/core/src/util", + ], +) + +jasmine_node_test( + name = "signals", + bootstrap = ["//tools/testing:node_no_angular"], + deps = [ + ":signals_lib", + ], +) + +karma_web_test_suite( + name = "signals_web", + deps = [ + ":signals_lib", + ], +) diff --git a/packages/core/test/signals/computed_spec.ts b/packages/core/test/signals/computed_spec.ts new file mode 100644 index 000000000000..d380b98f22e2 --- /dev/null +++ b/packages/core/test/signals/computed_spec.ts @@ -0,0 +1,166 @@ +/** + * @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 {computed, signal, Watch} from '@angular/core/src/signals'; + +describe('computed', () => { + it('should create computed', () => { + const counter = signal(0); + + let computedRunCount = 0; + const double = computed(() => `${counter() * 2}:${++computedRunCount}`); + + expect(double()).toEqual('0:1'); + + counter.set(1); + expect(double()).toEqual('2:2'); + expect(double()).toEqual('2:2'); + + counter.set(2); + expect(double()).toEqual('4:3'); + expect(double()).toEqual('4:3'); + }); + + it('should not re-compute if there are no dependencies', () => { + let tick = 0; + const c = computed(() => ++tick); + + expect(c()).toEqual(1); + expect(c()).toEqual(1); + }); + + it('should not re-compute if the dependency is a primitive value and the value did not change', + () => { + const counter = signal(0); + + let computedRunCount = 0; + const double = computed(() => `${counter() * 2}:${++computedRunCount}`); + + expect(double()).toEqual('0:1'); + + counter.set(0); + expect(double()).toEqual('0:1'); + }); + + it('should chain computed', () => { + const name = signal('abc'); + const reverse = computed(() => name().split('').reverse().join('')); + const upper = computed(() => reverse().toUpperCase()); + + expect(upper()).toEqual('CBA'); + + name.set('foo'); + expect(upper()).toEqual('OOF'); + }); + + it('should evaluate computed only when subscribing', () => { + const name = signal('John'); + const show = signal(true); + + let computeCount = 0; + const displayName = computed(() => `${show() ? name() : 'anonymous'}:${++computeCount}`); + + expect(displayName()).toEqual('John:1'); + + show.set(false); + expect(displayName()).toEqual('anonymous:2'); + + name.set('Bob'); + expect(displayName()).toEqual('anonymous:2'); + }); + + it('should detect simple dependency cycles', () => { + const a: () => unknown = computed(() => a()); + expect(() => a()).toThrowError('Detected cycle in computations.'); + }); + + it('should detect deep dependency cycles', () => { + const a: () => unknown = computed(() => b()); + const b = computed(() => c()); + const c = computed(() => d()); + const d = computed(() => a()); + expect(() => a()).toThrowError('Detected cycle in computations.'); + }); + + it('should cache exceptions thrown until computed gets dirty again', () => { + const toggle = signal('KO'); + const c = computed(() => { + const val = toggle(); + if (val === 'KO') { + throw new Error('KO'); + } else { + return val; + } + }); + + expect(() => c()).toThrowError('KO'); + expect(() => c()).toThrowError('KO'); + + toggle.set('OK'); + expect(c()).toEqual('OK'); + }); + + it('should not update dependencies of computations when dependencies don\'t change', () => { + const source = signal(0); + const isEven = computed(() => source() % 2 === 0); + let updateCounter = 0; + const updateTracker = computed(() => { + isEven(); + return updateCounter++; + }); + + updateTracker(); + expect(updateCounter).toEqual(1); + + source.set(1); + updateTracker(); + expect(updateCounter).toEqual(2); + + // Setting the counter to another odd value should not trigger `updateTracker` to update. + source.set(3); + updateTracker(); + expect(updateCounter).toEqual(2); + + source.set(4); + updateTracker(); + expect(updateCounter).toEqual(3); + }); + + it('should not mark dirty computed signals that are dirty already', () => { + const source = signal('a'); + const derived = computed(() => source().toUpperCase()); + + let watchCount = 0; + const watch = new Watch( + () => { + derived(); + }, + () => { + watchCount++; + }); + + watch.run(); + expect(watchCount).toEqual(0); + + // change signal, mark downstream dependencies dirty + source.set('b'); + expect(watchCount).toEqual(1); + + // change signal again, downstream dependencies should be dirty already and not marked again + source.set('c'); + expect(watchCount).toEqual(1); + + // resetting dependencies back to clean + watch.run(); + expect(watchCount).toEqual(1); + + // expecting another notification at this point + source.set('d'); + expect(watchCount).toEqual(2); + }); +}); diff --git a/packages/core/test/signals/effect_spec.ts b/packages/core/test/signals/effect_spec.ts new file mode 100644 index 000000000000..bab0527c5434 --- /dev/null +++ b/packages/core/test/signals/effect_spec.ts @@ -0,0 +1,110 @@ +/** + * @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 {computed, signal} from '@angular/core/src/signals'; +import {effect, effectsDone as flush, resetEffects} from '@angular/core/src/signals/src/effect'; + +describe('effects', () => { + afterEach(() => { + resetEffects(); + }); + + it('should create and run once effect without dependencies', async () => { + let runs = 0; + + const effectRef = effect(() => { + runs++; + }); + + await flush(); + expect(runs).toEqual(1); + + effectRef.destroy(); + await flush(); + expect(runs).toEqual(1); + }); + + it('should schedule effects on dependencies (signal) change', async () => { + const count = signal(0); + let runLog: number[] = []; + const effectRef = effect(() => { + runLog.push(count()); + }); + + await flush(); + expect(runLog).toEqual([0]); + + count.set(1); + await flush(); + expect(runLog).toEqual([0, 1]); + + effectRef.destroy(); + count.set(2); + await flush(); + expect(runLog).toEqual([0, 1]); + }); + + it('should not schedule when a previous dependency changes', async () => { + const increment = (value: number) => value + 1; + const countA = signal(0); + const countB = signal(100); + const useCountA = signal(true); + + + const runLog: number[] = []; + effect(() => { + runLog.push(useCountA() ? countA() : countB()); + }); + + await flush(); + expect(runLog).toEqual([0]); + + countB.update(increment); + await flush(); + // No update expected: updated the wrong signal. + expect(runLog).toEqual([0]); + + countA.update(increment); + await flush(); + expect(runLog).toEqual([0, 1]); + + useCountA.set(false); + await flush(); + expect(runLog).toEqual([0, 1, 101]); + + countA.update(increment); + await flush(); + // No update expected: updated the wrong signal. + expect(runLog).toEqual([0, 1, 101]); + }); + + it('should not update dependencies of effects when dependencies don\'t change', async () => { + const source = signal(0); + const isEven = computed(() => source() % 2 === 0); + let updateCounter = 0; + effect(() => { + isEven(); + updateCounter++; + }); + + await flush(); + expect(updateCounter).toEqual(1); + + source.set(1); + await flush(); + expect(updateCounter).toEqual(2); + + source.set(3); + await flush(); + expect(updateCounter).toEqual(2); + + source.set(4); + await flush(); + expect(updateCounter).toEqual(3); + }); +}); diff --git a/packages/core/test/signals/glitch_free_spec.ts b/packages/core/test/signals/glitch_free_spec.ts new file mode 100644 index 000000000000..001dff36dde9 --- /dev/null +++ b/packages/core/test/signals/glitch_free_spec.ts @@ -0,0 +1,44 @@ +/** + * @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 {computed, signal} from '@angular/core/src/signals'; + +describe('glitch-free computations', () => { + it('should recompute only once for diamond dependency graph', () => { + let fullRecompute = 0; + + const name = signal('John Doe'); + const first = computed(() => name().split(' ')[0]); + const last = computed(() => name().split(' ')[1]); + const full = computed(() => { + fullRecompute++; + return `${first()}/${last()}`; + }); + + expect(full()).toEqual('John/Doe'); + expect(fullRecompute).toEqual(1); + + name.set('Bob Fisher'); + expect(full()).toEqual('Bob/Fisher'); + expect(fullRecompute).toEqual(2); + }); + + it('should recompute only once', () => { + const a = signal('a'); + const b = computed(() => a() + 'b'); + let cRecompute = 0; + const c = computed(() => { + return `${a()}|${b()}|${++cRecompute}`; + }); + + expect(c()).toEqual('a|ab|1'); + + a.set('A'); + expect(c()).toEqual('A|Ab|2'); + }); +}); diff --git a/packages/core/test/signals/non_reactive_spec.ts b/packages/core/test/signals/non_reactive_spec.ts new file mode 100644 index 000000000000..51d5515fe54b --- /dev/null +++ b/packages/core/test/signals/non_reactive_spec.ts @@ -0,0 +1,116 @@ +/** + * @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 {computed, signal, untrack} from '@angular/core/src/signals'; +import {effect, effectsDone as flush, resetEffects} from '@angular/core/src/signals/src/effect'; + +describe('non-reactive reads', () => { + afterEach(() => { + resetEffects(); + }); + + it('should read the latest value from signal', () => { + const counter = signal(0); + + expect(untrack(counter)).toEqual(0); + + counter.set(1); + expect(untrack(counter)).toEqual(1); + }); + + it('should not add dependencies to computed when reading a value from a signal', () => { + const counter = signal(0); + const double = computed(() => untrack(counter) * 2); + + expect(double()).toEqual(0); + + counter.set(2); + expect(double()).toEqual(0); + }); + + it('should refresh computed value if stale and read non-reactively ', () => { + const counter = signal(0); + const double = computed(() => counter() * 2); + + expect(untrack(double)).toEqual(0); + + counter.set(2); + expect(untrack(double)).toEqual(4); + }); + + it('should not make surrounding effect depend on the signal', async () => { + const s = signal(1); + + const runLog: number[] = []; + effect(() => { + runLog.push(untrack(s)); + }); + + // an effect will run at least once + await flush(); + expect(runLog).toEqual([1]); + + // subsequent signal changes should not trigger effects as signal is untracked + s.set(2); + await flush(); + expect(runLog).toEqual([1]); + }); + + it('should schedule effects on dependencies (computed) change', async () => { + const count = signal(0); + const double = computed(() => count() * 2); + + let runLog: number[] = []; + const effectRef = effect(() => { + runLog.push(double()); + }); + + await flush(); + expect(runLog).toEqual([0]); + + count.set(1); + await flush(); + expect(runLog).toEqual([0, 2]); + + effectRef.destroy(); + count.set(2); + await flush(); + expect(runLog).toEqual([0, 2]); + }); + + it('should non-reactively read all signals accessed inside untrack', async () => { + const first = signal('John'); + const last = signal('Doe'); + + let runLog: string[] = []; + const effectRef = effect(() => { + untrack(() => runLog.push(`${first()} ${last()}`)); + }); + + // effects run at least once + await flush(); + expect(runLog).toEqual(['John Doe']); + + // change one of the signals - should not update as not read reactively + first.set('Patricia'); + await flush(); + expect(runLog).toEqual(['John Doe']); + + // change one of the signals - should not update as not read reactively + last.set('Garcia'); + await flush(); + expect(runLog).toEqual(['John Doe']); + + // destroy effect, should not respond to changes + effectRef.destroy(); + first.set('Robert'); + last.set('Smith'); + await flush(); + expect(runLog).toEqual(['John Doe']); + }); +}); diff --git a/packages/core/test/signals/signal_spec.ts b/packages/core/test/signals/signal_spec.ts new file mode 100644 index 000000000000..a3272f3e7052 --- /dev/null +++ b/packages/core/test/signals/signal_spec.ts @@ -0,0 +1,100 @@ +/** + * @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 {computed, signal} from '@angular/core/src/signals'; + +describe('signals', () => { + it('should be a getter which reflect the set value', () => { + const state = signal(false); + expect(state()).toBeFalse(); + state.set(true); + expect(state()).toBeTrue(); + }); + + it('should accept update function to set new value based on the previous one', () => { + const counter = signal(0); + expect(counter()).toEqual(0); + + counter.update(c => c + 1); + expect(counter()).toEqual(1); + }); + + it('should have mutate function for mutable, out of bound updates', () => { + const state = signal(['a']); + const derived = computed(() => state().join(':')); + + expect(derived()).toEqual('a'); + + state.mutate((s) => { + s.push('b'); + }); + expect(derived()).toEqual('a:b'); + }); + + it('should not update signal when new value is equal to the previous one', () => { + const state = signal('aaa', (a, b) => a.length === b.length); + expect(state()).toEqual('aaa'); + + // set to a "different" value that is "equal" to the previous one + // there should be no change in the signal's value as the new value is determined to be equal + // to the previous one + state.set('bbb'); + expect(state()).toEqual('aaa'); + + state.update((_) => 'ccc'); + expect(state()).toEqual('aaa'); + + // setting a "non-equal" value + state.set('d'); + expect(state()).toEqual('d'); + }); + + it('should not propagate change when the new signal value is equal to the previous one', () => { + const state = signal('aaa', (a, b) => a.length === b.length); + const upper = computed(() => state().toUpperCase()); + + // set to a "different" value that is "equal" to the previous one + // there should be no change in the signal's value as the new value is determined to be equal + // to the previous one + state.set('bbb'); + expect(upper()).toEqual('AAA'); + + state.update((_) => 'ccc'); + expect(upper()).toEqual('AAA'); + + // setting a "non-equal" value + state.set('d'); + expect(upper()).toEqual('D'); + }); + + it('should consider objects as non-equal with the default equality function', () => { + let stateValue: unknown = {}; + const state = signal(stateValue); + let computeCount = 0; + const derived = computed(() => `${typeof state()}:${++computeCount}`); + expect(derived()).toEqual('object:1'); + + // reset signal value to the same object instance, expect change notification + state.set(stateValue); + expect(derived()).toEqual('object:2'); + + // reset signal value to a different object instance, expect change notification + stateValue = {}; + state.set(stateValue); + expect(derived()).toEqual('object:3'); + + // reset signal value to a different object type, expect change notification + stateValue = []; + state.set(stateValue); + expect(derived()).toEqual('object:4'); + + // reset signal value to the same array instance, expect change notification + state.set(stateValue); + expect(derived()).toEqual('object:5'); + }); +});