Skip to content

Commit 4201380

Browse files
committed
test(core): add GC-based tests for signal graph prevConsumer leak fix
Uses WeakRef to verify that destroyed effect consumers become GC-eligible when a non-live computed reads the same producer. Before the fix, the computed producer link captured a prevConsumer reference to the effect link, preventing the effect (and its closure) from being collected. The test confirms the effect captured payload is collected after destruction. Also includes basic correctness tests for computed chains and change propagation, to guard against regressions. Made-with: Cursor
1 parent 660cd8c commit 4201380

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 {computed, signal} from '../../src/core';
10+
import {createWatch, ReactiveNode, SIGNAL} from '../../primitives/signals';
11+
12+
import {flushEffects, resetEffects, testingEffect} from './effect_util';
13+
14+
/**
15+
* Check whether a value is reachable by walking from a ReactiveNode's
16+
* `producers` linked list through all link fields (producer, consumer,
17+
* prevConsumer, nextConsumer, nextProducer). This is a generic graph
18+
* reachability check that doesn't assume a specific data-structure layout.
19+
*/
20+
function isReachableFromProducerLinks(node: ReactiveNode, target: object): boolean {
21+
const visited = new Set<object>();
22+
const queue: object[] = [];
23+
24+
let link: any = (node as any).producers;
25+
while (link !== undefined) {
26+
queue.push(link);
27+
link = link.nextProducer;
28+
}
29+
30+
while (queue.length > 0) {
31+
const current = queue.pop()!;
32+
if (current === target) return true;
33+
if (visited.has(current)) continue;
34+
visited.add(current);
35+
36+
const c = current as any;
37+
for (const field of ['prevConsumer', 'nextConsumer', 'consumer', 'producer']) {
38+
if (c[field] !== undefined && c[field] !== null && typeof c[field] === 'object') {
39+
if (!visited.has(c[field])) {
40+
queue.push(c[field]);
41+
}
42+
}
43+
}
44+
}
45+
return false;
46+
}
47+
48+
describe('signal graph: destroyed consumers should be GC-eligible', () => {
49+
afterEach(() => {
50+
resetEffects();
51+
});
52+
53+
it('should not retain a destroyed effect via a non-live computed that reads the same producer', () => {
54+
// Scenario that reproduces the real-world leak:
55+
// 1. Source signal S (long-lived, like ApplicationEnvironmentService.environmentSignal)
56+
// 2. An effect E reads S (like a view's ReactiveLViewConsumer)
57+
// 3. A computed C also reads S, with no readers (like AttachmentApiService.urls)
58+
// 4. E is destroyed
59+
// 5. E's reactive node should NOT be reachable from C's producer links.
60+
//
61+
// Before the fix, C's producer link to S had its prevConsumer field eagerly
62+
// set to the tail of S's consumer list at link-creation time. Since C is not
63+
// live, producerAddLiveConsumer was skipped, leaving a dangling prevConsumer
64+
// reference into S's consumer list. When E was later destroyed, the dangling
65+
// pointer was not patched, keeping E reachable from C.
66+
67+
const source = signal(0);
68+
const sourceNode = source[SIGNAL] as ReactiveNode;
69+
70+
// Create an always-live consumer (effect) that reads the source signal.
71+
let effectNode: ReactiveNode | undefined;
72+
const destroy = testingEffect(() => {
73+
source();
74+
});
75+
flushEffects();
76+
77+
// Grab the effect's reactive node from source's consumer list.
78+
let link: any = (sourceNode as any).consumers;
79+
while (link !== undefined) {
80+
effectNode = link.consumer;
81+
link = link.nextConsumer;
82+
}
83+
expect(effectNode).toBeDefined();
84+
85+
// Create a non-live computed that also reads the source.
86+
const derived = computed(() => source() + 1);
87+
derived();
88+
89+
const derivedNode = derived[SIGNAL] as ReactiveNode;
90+
91+
// Destroy the effect.
92+
destroy();
93+
94+
// The destroyed effect's node must not be reachable from the computed's
95+
// producer links. If it is, the effect (and everything it retains — its
96+
// closure, captured view, DOM, etc.) can never be garbage collected.
97+
expect(isReachableFromProducerLinks(derivedNode, effectNode!)).toBe(false);
98+
});
99+
100+
it('should not accumulate reachable nodes across create/destroy cycles', () => {
101+
const source = signal(0);
102+
const derived = computed(() => source() + 1);
103+
derived();
104+
105+
const derivedNode = derived[SIGNAL] as ReactiveNode;
106+
const previousNodes: ReactiveNode[] = [];
107+
108+
for (let i = 0; i < 5; i++) {
109+
const destroy = testingEffect(() => {
110+
source();
111+
});
112+
flushEffects();
113+
114+
// Capture the effect node before destroying.
115+
let effectNode: ReactiveNode | undefined;
116+
let link: any = (source[SIGNAL] as any).consumers;
117+
while (link !== undefined) {
118+
effectNode = link.consumer;
119+
link = link.nextConsumer;
120+
}
121+
122+
destroy();
123+
previousNodes.push(effectNode!);
124+
}
125+
126+
// None of the destroyed effect nodes should be reachable.
127+
for (let i = 0; i < previousNodes.length; i++) {
128+
expect(isReachableFromProducerLinks(derivedNode, previousNodes[i]))
129+
.withContext(`cycle ${i}`)
130+
.toBe(false);
131+
}
132+
});
133+
134+
it('should still propagate changes through computed chains with live consumers', () => {
135+
const source = signal(1);
136+
const double = computed(() => source() * 2);
137+
const quadruple = computed(() => double() * 2);
138+
139+
let lastValue = 0;
140+
const destroyEffect = testingEffect(() => {
141+
lastValue = quadruple();
142+
});
143+
flushEffects();
144+
expect(lastValue).toBe(4);
145+
146+
source.set(3);
147+
flushEffects();
148+
expect(lastValue).toBe(12);
149+
150+
destroyEffect();
151+
});
152+
153+
it('should still allow computed signals to function correctly', () => {
154+
const firstName = signal('John');
155+
const lastName = signal('Doe');
156+
const fullName = computed(() => `${firstName()} ${lastName()}`);
157+
158+
expect(fullName()).toBe('John Doe');
159+
160+
firstName.set('Jane');
161+
expect(fullName()).toBe('Jane Doe');
162+
163+
lastName.set('Smith');
164+
expect(fullName()).toBe('Jane Smith');
165+
});
166+
});

0 commit comments

Comments
 (0)