Skip to content

Commit 327744a

Browse files
authored
perf(core): detect existing signal dependency without checking all producer links
This commit addresses a scaling issue in the signal dependency graph where the detection of duplicate dependency links would perform a linear scan across all consumer links of all producers. The linear scan is replaced with a version comparison of the dependency edge against the current epoch; if they are equal the existing dependency edge is known to be valid in this epoch. This means that the link won't be eligible for removal and therefore doesn't have to be recreated.
1 parent ea1a3ed commit 327744a

7 files changed

Lines changed: 131 additions & 28 deletions

File tree

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

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export const REACTIVE_NODE: ReactiveNode = {
8282
interface ReactiveLink {
8383
producer: ReactiveNode;
8484
consumer: ReactiveNode;
85+
86+
/**
87+
* Stores the epoch that holds when this link was observed, allowing subsequent observations of the same producer to
88+
* realize that there's an existing link, avoiding the creation of a new, redundant link. A value of `null` indicates
89+
* that the link cannot be assumed to be valid based on the epoch counter.
90+
*/
91+
knownValidAtEpoch: Version | null;
8592
lastReadVersion: number;
8693
prevConsumer: ReactiveLink | undefined;
8794
nextConsumer: ReactiveLink | undefined;
@@ -240,27 +247,26 @@ export function producerAccessed(node: ReactiveNode): void {
240247
// last read version, update the tail of the producers list of this rerun, and return.
241248
activeConsumer.producersTail = nextProducerLink;
242249
nextProducerLink.lastReadVersion = node.version;
250+
nextProducerLink.knownValidAtEpoch = epoch;
243251
return;
244252
}
245253
}
246254

247255
const prevConsumerLink = node.consumersTail;
248256

249257
// If the producer we're accessing already has a link to this consumer, we can skip adding a new
250-
// link. This can short circuit the creation of a new link in the case where the consumer reads alternating ReeactiveNodes
258+
// link. This can short circuit the creation of a new link in the case where the consumer reads alternating ReactiveNodes
251259
if (
252260
prevConsumerLink !== undefined &&
253261
prevConsumerLink.consumer === activeConsumer &&
254-
// However, we have to make sure that the link we've discovered isn't from a node that is incrementally rebuilding its producer list
255-
(!isRecomputing || isValidLink(prevConsumerLink, activeConsumer))
262+
(!isRecomputing || prevConsumerLink.knownValidAtEpoch === epoch)
256263
) {
257-
// If we found an existing link to the consumer we can just return.
258264
return;
259265
}
260266

261267
// If we got here, it means that we need to create a new link between the producer and the consumer.
262268
const isLive = consumerIsLive(activeConsumer);
263-
const newLink = {
269+
const newLink: ReactiveLink = {
264270
producer: node,
265271
consumer: activeConsumer,
266272
// instead of eagerly destroying the previous link, we delay until we've finished recomputing
@@ -271,6 +277,7 @@ export function producerAccessed(node: ReactiveNode): void {
271277
// the link is actually inserted. Setting it eagerly would create a dangling
272278
// reference into the consumer list that prevents GC of removed entries.
273279
prevConsumer: undefined,
280+
knownValidAtEpoch: epoch,
274281
lastReadVersion: node.version,
275282
nextConsumer: undefined,
276283
};
@@ -393,6 +400,18 @@ export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNo
393400
* `consumerBeforeComputation` instead of calling this directly.
394401
*/
395402
export function resetConsumerBeforeComputation(node: ReactiveNode): void {
403+
// Clear link validity state before running a computation, such that links that were captured in a prior computation
404+
// (which may have happened in the same epoch) are not mistakenly considered valid. This is only necessary if any of
405+
// the links has `knownValidAtEpoch` equal to the current epoch, for which the producer that was accessed last is used
406+
// as proxy: any earlier producers cannot exceed its epoch.
407+
if (node.producersTail?.knownValidAtEpoch === epoch) {
408+
let producer = node.producers;
409+
while (producer !== undefined) {
410+
producer.knownValidAtEpoch = null;
411+
producer = producer.nextProducer;
412+
}
413+
}
414+
396415
node.producersTail = undefined;
397416
node.recomputing = true;
398417
}
@@ -562,22 +581,3 @@ export function setPostProducerCreatedFn(fn: ReactiveHookFn | null): ReactiveHoo
562581
postProducerCreatedFn = fn;
563582
return prev;
564583
}
565-
566-
// While a ReactiveNode is recomputing, it may not have destroyed previous links
567-
// This allows us to check if a given link will be destroyed by a reactivenode if it were to finish running immediately without accesing any more producers
568-
function isValidLink(checkLink: ReactiveLink, consumer: ReactiveNode): boolean {
569-
const producersTail = consumer.producersTail;
570-
if (producersTail !== undefined) {
571-
let link = consumer.producers!;
572-
do {
573-
if (link === checkLink) {
574-
return true;
575-
}
576-
if (link === producersTail) {
577-
break;
578-
}
579-
link = link.nextProducer!;
580-
} while (link !== undefined);
581-
}
582-
return false;
583-
}

packages/core/test/bundling/create_component/bundle.golden_symbols.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,6 @@
546546
"isSubscription",
547547
"isTemplateNode",
548548
"isTypeProvider",
549-
"isValidLink",
550549
"isValueProvider",
551550
"lastNodeWasCreated",
552551
"lastSelectedElementIdx",

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,6 @@
787787
"isSubscription",
788788
"isTemplateNode",
789789
"isTypeProvider",
790-
"isValidLink",
791790
"isValidatorFn",
792791
"isValueProvider",
793792
"iterateListLike",

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,6 @@
781781
"isSubscription",
782782
"isTemplateNode",
783783
"isTypeProvider",
784-
"isValidLink",
785784
"isValidatorFn",
786785
"isValueProvider",
787786
"isWritableSignal",

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,6 @@
865865
"isTemplateNode",
866866
"isTypeProvider",
867867
"isUrlTree",
868-
"isValidLink",
869868
"isValueProvider",
870869
"isWrappedDefaultExport",
871870
"iterator",

packages/core/test/render3/reactivity_spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,4 +1010,37 @@ describe('reactivity', () => {
10101010
});
10111011
});
10121012
});
1013+
1014+
describe('graph', () => {
1015+
it('should keep links alive in a dynamic graph', () => {
1016+
const source = signal('initial');
1017+
1018+
@Component({
1019+
selector: 'test-cmp',
1020+
template: `{{ dynamic() }}{{ source() }}`,
1021+
})
1022+
class TestCmp {
1023+
@Input()
1024+
nonreactive: unknown;
1025+
1026+
get dynamic() {
1027+
return signal('');
1028+
}
1029+
1030+
source = source;
1031+
}
1032+
1033+
const fixture = TestBed.createComponent(TestCmp);
1034+
fixture.detectChanges();
1035+
1036+
fixture.componentRef.setInput('nonreactive', 'force_check');
1037+
fixture.detectChanges();
1038+
expect(fixture.nativeElement.textContent.trim()).toBe('initial');
1039+
1040+
source.set('updated');
1041+
1042+
fixture.detectChanges();
1043+
expect(fixture.nativeElement.textContent.trim()).toBe('updated');
1044+
});
1045+
});
10131046
});

packages/core/test/signals/watch_spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,80 @@ describe('watchers', () => {
161161
expect(seenCounterValues).toEqual([2, 3]);
162162
});
163163

164+
it('should keep links alive in a dynamic graph', () => {
165+
// This test verifies that reactive links between producer <> consumers are correctly maintained in a dynamic graph.
166+
const decoy = signal(0);
167+
const dynamic = signal(0);
168+
const trigger = signal(false);
169+
170+
let executed = 0;
171+
testingEffect(() => {
172+
// Let the initial execution of the effect evaluate the decoy signal, incurring a consumer edge in the graph.
173+
if (executed === 0) {
174+
decoy();
175+
}
176+
177+
// Evaluate a second signal; in the first execution this is the second consumer while it will be the first
178+
// consumer in subsequent executions. The dynamic nature of this consumer means that its reactive link from the
179+
// initial execution is not being reused in the second execution, as it is masked by the presence of the by-then
180+
// stale link of `decoy`. Since the decoy is set to be unlinked, so will its followers as a mismatch in
181+
// consumer ordering cause the entire chain of consumers to become invalid.
182+
dynamic();
183+
184+
// Evaluate another signal last such that `dynamic` is not at the tail end of the effect's producer links, as that
185+
// would also allow the consumer link of `dynamic` to be found and reused.
186+
trigger();
187+
188+
executed++;
189+
});
190+
flushEffects();
191+
expect(executed).toEqual(1);
192+
193+
// Initiate a change through the trigger signal, causing the removal of `decoy` to be noticed without touching the
194+
// value of `dynamic`.
195+
trigger.set(true);
196+
flushEffects();
197+
expect(executed).toEqual(2);
198+
199+
// Verify that updates to the decoy no longer cause the effect to run.
200+
decoy.update((v) => v + 1);
201+
flushEffects();
202+
expect(executed).toEqual(2);
203+
204+
// Also verify that updates of the dynamic consumer are still tracked, causing the effect to rerun.
205+
dynamic.update((v) => v + 1);
206+
flushEffects();
207+
expect(executed).toEqual(3);
208+
});
209+
210+
it('should keep links alive when retriggered by a signal write', () => {
211+
const a = signal(0);
212+
const trigger = signal(0);
213+
214+
let executed = 0;
215+
testingEffect(() => {
216+
if (executed === 0) {
217+
a();
218+
219+
// Update `a`, incrementing the epoch. This marks the effect dirty, causing it to be rerun again.
220+
a.update((v) => v + 1);
221+
}
222+
223+
// Read `trigger` when the epoch has already been incremented. The effect's reevaluation will therefore execute
224+
// with the same epoch counter when reading `trigger`. Despite the epoch being equal, the read of `trigger` has
225+
// to be recorded as reactive link during the reevaluation.
226+
trigger();
227+
228+
executed++;
229+
});
230+
flushEffects();
231+
expect(executed).toEqual(2);
232+
233+
trigger.update((v) => v + 1);
234+
flushEffects();
235+
expect(executed).toEqual(3);
236+
});
237+
164238
it('should throw an error when reading a signal during the notification phase', () => {
165239
const source = signal(0);
166240
let ranScheduler = false;

0 commit comments

Comments
 (0)