Skip to content

Commit 91acb9c

Browse files
committed
feat(forms): implement deepSignal and structuralSignal
These are new signal variants that optimize the way updates are written in large chains of `deepSignal`s. This optimization primarily avoids invalidating `deepSignal`s that aren't affected by writes, even if they derive from parents whose values are changed. See the added `README.md` for details.
1 parent a4140c0 commit 91acb9c

File tree

10 files changed

+895
-0
lines changed

10 files changed

+895
-0
lines changed

packages/core/src/core_reactivity_export_internal.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export {SIGNAL as ɵSIGNAL} from '../primitives/signals';
1010

1111
export {isSignal, Signal, ValueEqualityFn} from './render3/reactivity/api';
1212
export {computed, CreateComputedOptions} from './render3/reactivity/computed';
13+
export {
14+
deepSignal as ɵdeepSignal,
15+
DeepSignalOptions as ɵDeepSignalOptions,
16+
} from './render3/reactivity/deep_signal/deep_signal';
17+
export {
18+
structuralSignal as ɵstructuralSignal,
19+
StructuralSignalOptions as ɵStructuralSignalOptions,
20+
} from './render3/reactivity/deep_signal/structural_signal';
1321
export {
1422
CreateSignalOptions,
1523
signal,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Deep signals
2+
3+
A "deep signal" is a `WritableSignal` whose value comes from a specific property in a parent `WritableSignal`. Its value is always the value from the parent, and updating its value updates the value within the parent. This table shows the behavior of deep signals compared to other signal flavors when deriving a value from a parent property in this way.
4+
5+
| Signal Type | Reading | Writing |
6+
| -------------- | ----------------------------------------------- | ---------------------------------------- |
7+
| `computed` | Value derived from parent | 🚫 Not allowed |
8+
| `linkedSignal` | Value derived from parent OR local value if set | Does not change parent, only local value |
9+
| `deepSignal` | Value derived from parent | Updates value in parent |
10+
11+
As a code example:
12+
13+
```ts
14+
// Parent signal with complex object value.
15+
const model = signal({user: {name: 'Alex'}, company: 'Google'});
16+
17+
// Deep signal for `user` property of the model.
18+
const user = deepSignal(model, 'user');
19+
20+
console.log(user()); // {name: 'Alex'}
21+
22+
// Updating `user` updates the parent.
23+
user.set({name: 'Bob'});
24+
console.log(model()); // {user: {name: 'Bob'}, company: 'Google'}
25+
```
26+
27+
Deep signals combine the derivation of a value with the ability to update that value in the parent, making them an excellent tool for modeling hierarchical state.
28+
29+
## Performance
30+
31+
A key aspect of deep signals is their performance advantage over other methods of derivation.
32+
33+
Consider a similar setup as the example above, in plain signals:
34+
35+
```ts
36+
// Parent signal with complex object value.
37+
const model = signal({user: {name: 'Alex'}, company: 'Google'});
38+
39+
const user = computed(() => model().user);
40+
const company = computed(() => model().company);
41+
42+
effect(() => console.log(user())); // {name: 'Alex'}
43+
44+
// Update the company to 'Waymo':
45+
model.update(value => {...value, company: 'Waymo'});
46+
```
47+
48+
The update to `model` replaces its object value with a new one that stores the new `company` value. Even though `model` changes, `user`'s value is unaffected: the update to `model` does not touch the value of its `user` property.
49+
50+
In ordinary signal operations, the `user` computed would be marked "maybe dirty" from its dependency on `model`, as would the effect which depends on `user`. Eventually, rerunning the `user` derivation would detect that its value didn't actually change, and so the effect would short-circuit and not actually run. Semantically, this is correct behavior.
51+
52+
In a large graph, however, changing the signal at the top which is the source of truth for the entire graph would schedule _all_ effects depending on any part of the model this way. This is still a non-trivial cost, even with the short-circuiting.
53+
54+
Deep signals address this issue by leveraging their knowledge about _what_ is changing in the parent's value. Since a deep signal only changes its own property in the parent, other deep signals deriving other properties are guaranteed to be unaffected and do not need to be notified. When a deep signal write occurs in a large graph, only those signals which _actually observe the write_ are notified, meaning only effects that could potentially need to rerun are marked dirty.
55+
56+
## Algorithm
57+
58+
`deepSignal` is a hybrid, building on the `computed` reactive node type but in some ways behaving like a plain `signal`. It has a computation function that reads `parent()[ property ]` (where `property` is optionally reactive).
59+
60+
`deepSignal`'s write path is deeply specialized. The algorithm is as follows:
61+
62+
1. When a `deepSignal` write occurs, the `deepSignal` marks itself dirty and notifies its consumers of its change, as if it were a plain `signal`. A difference here is that it temporarily uses the value of `COMPUTING` for its value, which guards against `deepSignal` cycles.
63+
64+
2. It then pushes its own reactive node to a global stack of `deepSignal` writers.
65+
66+
3. It updates the `parent` writable signal, building a new value based on the `parent`'s current value and the `deepSignal`'s `property`.
67+
68+
4. It updates its own value and marks itself clean.
69+
70+
5. It pops itself from the `deepSignal` writer stack.
71+
72+
The `deepSignal` writer stack is used to implement the dirty notification short-circuiting mechanism described in the Performance section. The `DEEP_SIGNAL_NODE` type overrides the behavior of `node.dirty`, and will additionally report itself dirty when there is an active `deepSignal` writer that meets the criteria:
73+
74+
- When the active `deepSignal` writer shares its same parent (they're both derived from the same parent signal).
75+
- The active `deepSignal` writer is writing to a _different_ key than the current signal is monitoring.
76+
77+
If both of these conditions are true, the `deepSignal`'s node will report itself as `dirty`, which short circuits any dirty notifications being sent when the active writer updates the parent.
78+
79+
# Structural signals
80+
81+
The `deepSignal` mechanism allows the construction of another useful type of signal, which we call `structuralSignal`. A `structuralSignal` is derived from another `WritableSignal` and returns the same value. It behaves like `computed(() => parent())` with one exception: it does not get notified when the parent changes via a `deepSignal` write. Since a `deepSignal` write only changes an existing property, a `structuralSignal` can be used when a consumer is only interested in values that might have different shapes.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {untracked} from '../untracked';
10+
import {isSignal, type Signal} from '../api';
11+
import {type WritableSignal, signalAsReadonlyFn} from '../signal';
12+
13+
import {
14+
ERRORED,
15+
producerAccessed,
16+
producerIncrementEpoch,
17+
producerMarkClean,
18+
producerNotifyConsumers,
19+
producerUpdatesAllowed,
20+
producerUpdateValueVersion,
21+
type ReactiveNode,
22+
runPostProducerCreatedFn,
23+
throwInvalidWriteToSignalError,
24+
SIGNAL,
25+
COMPUTING,
26+
} from '../../../../primitives/signals';
27+
28+
import {DEEP_SIGNAL_NODE, type DeepSignalNode, setDeepSignalWriter} from './internal';
29+
30+
export interface DeepSignalOptions {
31+
debugName?: string;
32+
}
33+
34+
export function deepSignal<T extends {}, K extends keyof T>(
35+
parent: WritableSignal<T>,
36+
key: K | Signal<K>,
37+
options?: DeepSignalOptions,
38+
): WritableSignal<T[K]> {
39+
type V = T[K];
40+
41+
let keySignal: Signal<K> | undefined;
42+
43+
// Create our `DEEP_SIGNAL_NODE` and initialize it.
44+
const node: DeepSignalNode = Object.create(DEEP_SIGNAL_NODE);
45+
if (isSignal(key)) {
46+
keySignal = key;
47+
node.propertyNode = keySignal[SIGNAL] as ReactiveNode;
48+
} else {
49+
node.lastProperty = key;
50+
}
51+
52+
node.parentNode = parent[SIGNAL] as ReactiveNode;
53+
// Our value derives from the parent's value at our specific key.
54+
node.computation = () => {
55+
// Save the key we observe here as `lastKey`. We can short circuit later notifications as long
56+
// as the write was not to this key.
57+
if (keySignal) {
58+
node.lastProperty = keySignal();
59+
node.lastPropertyValueVersion = node.propertyNode!.version;
60+
}
61+
62+
return parent()[node.lastProperty as K];
63+
};
64+
if (options?.debugName) {
65+
node.debugName = options.debugName;
66+
}
67+
68+
// Next define the signal getter, which uses the same flow as `computed`.
69+
const getter = (() => {
70+
// Check if the value needs updating before returning it.
71+
producerUpdateValueVersion(node);
72+
73+
// Record that someone looked at this signal.
74+
producerAccessed(node);
75+
76+
if (node.value === ERRORED) {
77+
throw node.error;
78+
}
79+
80+
return node.value;
81+
}) as WritableSignal<V>;
82+
getter[SIGNAL] = node;
83+
84+
// The deep signal specific write path.
85+
getter.set = (value: V) => {
86+
if (!producerUpdatesAllowed()) {
87+
throwInvalidWriteToSignalError(node);
88+
}
89+
90+
if (node.equal(node.value, value)) {
91+
return;
92+
}
93+
94+
node.version++;
95+
// We record a value of `COMPUTING` during the write to the parent, in case somehow the parent
96+
// depends on us.
97+
node.value = COMPUTING;
98+
99+
// Temporarily mark ourselves dirty during the write. Later on, we'll mark ourselves clean.
100+
// This prevents the notification when we write to our parent from marking our children dirty.
101+
node.rawDirty = true;
102+
103+
producerIncrementEpoch();
104+
producerNotifyConsumers(node);
105+
106+
// Update the name of the property we're writing to, if it's reactive.
107+
if (keySignal) {
108+
node.lastProperty = untracked(keySignal);
109+
node.lastPropertyValueVersion = node.propertyNode!.version;
110+
}
111+
112+
const current = untracked(parent);
113+
114+
// We make one concession to `structuralSignal` here: if we're adding a property that does not
115+
// exist, we don't use the `deepSignal` write path. This ensures any `structuralSignal`s will
116+
// get notified of this change.
117+
let prevWriter: DeepSignalNode | null;
118+
if ((node.lastProperty as K) in current) {
119+
prevWriter = setDeepSignalWriter(node);
120+
} else {
121+
prevWriter = setDeepSignalWriter(null);
122+
}
123+
124+
try {
125+
if (isArray<T, V>(current)) {
126+
const newSource = [...current] as unknown as T & Array<V>;
127+
newSource[node.lastProperty as number] = value;
128+
parent.set(newSource);
129+
} else {
130+
parent.set({...current, [node.lastProperty as K]: value});
131+
}
132+
// TODO: today, `postSignalSetFn` happens when the top-most `parent` in a write path of
133+
// `deepSignal`s is updated. We probably want to delay that until we come back up the stack
134+
// and finish the write path of every `deepSignal` along the way (marking clean, etc).
135+
136+
// Unlike normal computed nodes, deep signals handle updating their own value. Therefore, once
137+
// we set the value we can consider ourselves clean. We could set our value earlier.
138+
node.value = value;
139+
producerMarkClean(node);
140+
} finally {
141+
setDeepSignalWriter(prevWriter);
142+
}
143+
};
144+
145+
getter.update = (fn: (value: V) => V): void => {
146+
getter.set(fn(untracked(getter)));
147+
};
148+
149+
getter.asReadonly = signalAsReadonlyFn;
150+
151+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
152+
const debugName = node.debugName ? ' (' + node.debugName + ')' : '';
153+
getter.toString = () => `[DeepSignal${debugName}: ${node.value}]`;
154+
}
155+
156+
runPostProducerCreatedFn(node);
157+
return getter;
158+
}
159+
160+
function isArray<T extends {}, V>(value: T): value is T & Array<V> {
161+
return Array.isArray(value);
162+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ReactiveNode, COMPUTED_NODE, ComputedNode, Version} from '../../../../primitives/signals';
10+
11+
export interface DeepSignalNode extends ComputedNode<unknown> {
12+
rawDirty: boolean;
13+
propertyNode: ReactiveNode | undefined;
14+
parentNode: ReactiveNode;
15+
lastProperty: PropertyKey | undefined;
16+
lastPropertyValueVersion: Version;
17+
}
18+
19+
export const DEEP_SIGNAL_NODE: Omit<DeepSignalNode, 'computation' | 'parentNode'> =
20+
/* @__PURE__ */ (() => {
21+
return {
22+
...COMPUTED_NODE,
23+
kind: 'deepSignal',
24+
propertyNode: undefined,
25+
lastProperty: undefined,
26+
rawDirty: false,
27+
get dirty(): boolean {
28+
if (isSafeToShortCircuitNotifications(this as DeepSignalNode)) {
29+
// Short-circuit notifications by reporting ourself as already dirty.
30+
return true;
31+
}
32+
33+
return this.rawDirty;
34+
},
35+
set dirty(value: boolean) {
36+
this.rawDirty = value;
37+
},
38+
lastPropertyValueVersion: 0 as Version,
39+
};
40+
})();
41+
42+
/**
43+
* Determines whether `node` is definitely not affected by whatever write is currently ongoing and
44+
* generating signal graph notifications.
45+
*/
46+
function isSafeToShortCircuitNotifications(node: DeepSignalNode): boolean {
47+
// If the current write path doesn't involve any deep signal writes, then we're potentially affected.
48+
if (deepSignalWriter === null) {
49+
return false;
50+
}
51+
52+
// `deepParentNode` is the most recent deep signal being written. If this node isn't also a child
53+
// of that parent, then we can't guarantee we're not impacted by the write.
54+
if (deepSignalWriter.parentNode !== node.parentNode) {
55+
return false;
56+
}
57+
58+
// Check whether the property signal we're interested in has potentially changed since we last
59+
// read it. If it's dirty, or its version has been updated since our last read, then there's a
60+
// chance our property of interest has changed. Since we need to know for sure which property
61+
// we're interested in to check if we're affected, we have to assume we are.
62+
if (
63+
node.propertyNode !== undefined &&
64+
(node.propertyNode.dirty || node.propertyNode.version !== node.lastPropertyValueVersion)
65+
) {
66+
return false;
67+
}
68+
69+
// We know for sure that `node.lastProperty` is the property we care about. If the same property
70+
// got changed in our parent, though, we might still be affected.
71+
if (deepSignalWriter.lastProperty === node.lastProperty) {
72+
return false;
73+
}
74+
75+
// We've proven that:
76+
// * another deep signal is changing a specific property of our parent signal
77+
// * it's not the property that we're reading
78+
//
79+
// There is the chance that our property signal changed since `lastProperty` was computed. If
80+
// that were the case, we'd already be dirty from that notification, so it's safe to assume that
81+
// under this circumstance, we're not affected.
82+
return true;
83+
}
84+
85+
export function setDeepSignalWriter(node: DeepSignalNode | null): DeepSignalNode | null {
86+
const prev = deepSignalWriter;
87+
deepSignalWriter = node;
88+
return prev;
89+
}
90+
91+
let deepSignalWriter: DeepSignalNode | null = null;

0 commit comments

Comments
 (0)