Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/signals/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
]),
)
162 changes: 162 additions & 0 deletions packages/core/src/signals/README.md
Original file line number Diff line number Diff line change
@@ -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<Todo[])([]);

todoList.mutate(list => {
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);
Copy link

@ivanivanyuk1993 ivanivanyuk1993 Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alxhub If we don't preprocess function in computed at compile-time or use some form of reflection to atomically notify only affected signals, won't it require O(n) change checks, where n is a quantity of signals in application, effectively making it have same algorithmic complexity as ChangeDetectionStrategy.Default and making it harmful for angular ecosystem?(with signals there will be 2 ways to run change detection in O(n), using signals or ChangeDetectionStrategy.Default(which is simpler to do than signals), and 1 way to run change detection in ~O(1) - using rxjs and async pipe)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alxhub Oh, sorry, now I understand. We know for sure who is current consumer(because we track it in activeConsumer) and current producer(we can track it in code), so we have all the information needed to create dependency graph which will run atomically in O(1). I think we should mention explicitly for people like me why it runs in O(1)

```

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.
15 changes: 15 additions & 0 deletions packages/core/src/signals/index.ts
Original file line number Diff line number Diff line change
@@ -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';
89 changes: 89 additions & 0 deletions packages/core/src/signals/src/api.ts
Original file line number Diff line number Diff line change
@@ -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> = (() => T)&{
[SIGNAL]: true;
};

/**
* Checks if the given `value` function is a reactive `Signal`.
*/
export function isSignal(value: Function): value is Signal<unknown> {
return (value as Signal<unknown>)[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<T>(fn: () => T): Signal<T>;

/**
* 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<T, U extends Record<string, unknown>>(
fn: () => T, extraApi: U): Signal<T>&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<T, U extends Record<string, unknown> = {}>(
fn: () => T, extraApi: U = ({} as U)): Signal<T>&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<T>& U);
}

/**
* A comparison function which can determine if two values are equal.
*
* @developerPreview
*/
export type ValueEqualityFn<T> = (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<T>(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);
}
Loading