Skip to content

Commit 59a3475

Browse files
committed
refactor(core): global epoch to optimize non-live signal reads (angular#52420)
This commit adds a global epoch to the reactive graph, which can optimize non-live reads. When a non-live read occurs, a computed must poll its dependencies to check if they've changed, and this operation is transitive and not cacheable. Since non-live computeds don't receive dirty notifications, they're forced to assume potential dirtiness on each and every read. Using a global epoch, we can add an important optimization: if *no* signals have been set globally since the last time it polled its dependencies, then we *can* assume a clean state. This significantly improves performance of large unwatched graphs when repeatedly reading values. PR Close angular#52420
1 parent 6e82ae5 commit 59a3475

File tree

4 files changed

+69
-2
lines changed

4 files changed

+69
-2
lines changed

goldens/public-api/core/primitives/signals/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface ReactiveNode {
6464
consumerMarkedDirty(node: unknown): void;
6565
consumerOnSignalRead(node: unknown): void;
6666
dirty: boolean;
67+
lastCleanEpoch: Version;
6768
liveConsumerIndexOfThis: number[] | undefined;
6869
liveConsumerNode: ReactiveNode[] | undefined;
6970
nextProducerIndex: number;

packages/core/primitives/signals/src/graph.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ let inNotificationPhase = false;
2121

2222
type Version = number&{__brand: 'Version'};
2323

24+
/**
25+
* Global epoch counter. Incremented whenever a source signal is set.
26+
*/
27+
let epoch: Version = 1 as Version;
28+
2429
/**
2530
* Symbol used to tell `Signal`s apart from other functions.
2631
*
@@ -52,6 +57,7 @@ export function isReactive(value: unknown): value is Reactive {
5257

5358
export const REACTIVE_NODE: ReactiveNode = {
5459
version: 0 as Version,
60+
lastCleanEpoch: 0 as Version,
5561
dirty: false,
5662
producerNode: undefined,
5763
producerLastReadVersion: undefined,
@@ -88,6 +94,14 @@ export interface ReactiveNode {
8894
*/
8995
version: Version;
9096

97+
/**
98+
* Epoch at which this node is verified to be clean.
99+
*
100+
* This allows skipping of some polling operations in the case where no signals have been set
101+
* since this node was last read.
102+
*/
103+
lastCleanEpoch: Version;
104+
91105
/**
92106
* Whether this node (in its consumer capacity) is dirty.
93107
*
@@ -231,6 +245,15 @@ export function producerAccessed(node: ReactiveNode): void {
231245
activeConsumer.producerLastReadVersion[idx] = node.version;
232246
}
233247

248+
/**
249+
* Increment the global epoch counter.
250+
*
251+
* Called by source producers (that is, not computeds) whenever their values change.
252+
*/
253+
export function producerIncrementEpoch(): void {
254+
epoch++;
255+
}
256+
234257
/**
235258
* Ensure this producer's `version` is up-to-date.
236259
*/
@@ -241,17 +264,26 @@ export function producerUpdateValueVersion(node: ReactiveNode): void {
241264
return;
242265
}
243266

267+
if (!node.dirty && node.lastCleanEpoch === epoch) {
268+
// Even non-live consumers can skip polling if they previously found themselves to be clean at
269+
// the current epoch, since their dependencies could not possibly have changed (such a change
270+
// would've increased the epoch).
271+
return;
272+
}
273+
244274
if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
245275
// None of our producers report a change since the last time they were read, so no
246276
// recomputation of our value is necessary, and we can consider ourselves clean.
247277
node.dirty = false;
278+
node.lastCleanEpoch = epoch;
248279
return;
249280
}
250281

251282
node.producerRecomputeValue(node);
252283

253284
// After recomputing the value, we're no longer dirty.
254285
node.dirty = false;
286+
node.lastCleanEpoch = epoch;
255287
}
256288

257289
/**

packages/core/primitives/signals/src/signal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {defaultEquals, ValueEqualityFn} from './equality';
1010
import {throwInvalidWriteToSignalError} from './errors';
11-
import {producerAccessed, producerNotifyConsumers, producerUpdatesAllowed, REACTIVE_NODE, ReactiveNode, SIGNAL} from './graph';
11+
import {producerAccessed, producerIncrementEpoch, producerNotifyConsumers, producerUpdatesAllowed, REACTIVE_NODE, ReactiveNode, SIGNAL} from './graph';
1212

1313
/**
1414
* If set, called after `WritableSignal`s are updated.
@@ -98,6 +98,7 @@ const SIGNAL_NODE: object = /* @__PURE__ */ (() => {
9898

9999
function signalValueChanged<T>(node: SignalNode<T>): void {
100100
node.version++;
101+
producerIncrementEpoch();
101102
producerNotifyConsumers(node);
102103
postSignalSetFn?.();
103104
}

packages/core/test/signals/signal_spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {computed, signal} from '@angular/core';
10-
import {setPostSignalSetFn} from '@angular/core/primitives/signals';
10+
import {ReactiveNode, setPostSignalSetFn, SIGNAL} from '@angular/core/primitives/signals';
1111

1212
describe('signals', () => {
1313
it('should be a getter which reflects the set value', () => {
@@ -104,6 +104,39 @@ describe('signals', () => {
104104
expect(double()).toBe(4);
105105
});
106106

107+
describe('optimizations', () => {
108+
it('should not repeatedly poll status of a non-live node if no signals have changed', () => {
109+
const unrelated = signal(0);
110+
const source = signal(1);
111+
let computations = 0;
112+
const derived = computed(() => {
113+
computations++;
114+
return source() * 2;
115+
});
116+
117+
expect(derived()).toBe(2);
118+
expect(computations).toBe(1);
119+
120+
const sourceNode = source[SIGNAL] as ReactiveNode;
121+
// Forcibly increment the version of the source signal. This will cause a mismatch during
122+
// polling, and will force the derived signal to recompute if polled (which we should observe
123+
// in this test).
124+
sourceNode.version++;
125+
126+
// Read the derived signal again. This should not recompute (even with the forced version
127+
// update) as no signals have been set since the last read.
128+
expect(derived()).toBe(2);
129+
expect(computations).toBe(1);
130+
131+
// Set the `unrelated` signal, which now means that `derived` should poll if read again.
132+
// Because of the forced version, that poll will cause a recomputation which we will observe.
133+
unrelated.set(1);
134+
135+
expect(derived()).toBe(2);
136+
expect(computations).toBe(2);
137+
});
138+
});
139+
107140
describe('post-signal-set functions', () => {
108141
let prevPostSignalSetFn: (() => void)|null = null;
109142
let log: number;

0 commit comments

Comments
 (0)