Skip to content

Commit 70507b8

Browse files
crisbetokirjs
authored andcommitted
fix(core): debug data causing memory leak for root effects
We track all effects that are created for debugging purposes in the `resolverToEffects` map. This ends up leaking memory for effects registered on long-living resolvers (e.g. on the root injector), because they stay in the array, even if the effect itself has been destroyed. These changes add a callback to clean up the references. Fixes #65265. (cherry picked from commit ca6ab6c)
1 parent 4845a33 commit 70507b8

File tree

4 files changed

+77
-9
lines changed

4 files changed

+77
-9
lines changed

packages/core/src/render3/debug/framework_injector_profiler.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {TNode} from '../interfaces/node';
1818
import {LView} from '../interfaces/view';
1919
import {AfterRenderPhaseEffectNode} from '../reactivity/after_render_effect';
2020
import {EffectRefImpl} from '../reactivity/effect';
21+
import {SIGNAL} from '../../../primitives/signals';
2122

2223
import {
2324
InjectedService,
@@ -136,12 +137,23 @@ function handleEffectCreatedEvent(
136137
}
137138

138139
const {resolverToEffects} = frameworkDIDebugData;
140+
const cleanupContainer = effect instanceof EffectRefImpl ? effect[SIGNAL] : effect.sequence;
141+
let trackedEffects = resolverToEffects.get(diResolver);
139142

140-
if (!resolverToEffects.has(diResolver)) {
141-
resolverToEffects.set(diResolver, []);
143+
if (!trackedEffects) {
144+
trackedEffects = [];
145+
resolverToEffects.set(diResolver, trackedEffects);
142146
}
143147

144-
resolverToEffects.get(diResolver)!.push(effect);
148+
trackedEffects.push(effect);
149+
cleanupContainer.onDestroyFns ??= [];
150+
cleanupContainer.onDestroyFns.push(() => {
151+
const index = trackedEffects!.indexOf(effect);
152+
153+
if (index > -1) {
154+
trackedEffects!.splice(index, 1);
155+
}
156+
});
145157
}
146158

147159
/**

packages/core/src/render3/reactivity/after_render_effect.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ export class AfterRenderEffectSequence extends AfterRenderSequence {
177177
AfterRenderPhaseEffectNode | undefined,
178178
] = [undefined, undefined, undefined, undefined];
179179

180+
/** Function to be called when the effect is destroyed. */
181+
onDestroyFns: (() => void)[] | null = null;
182+
180183
constructor(
181184
impl: AfterRenderImpl,
182185
effectHooks: Array<AfterRenderPhaseEffectHook | undefined>,
@@ -234,6 +237,12 @@ export class AfterRenderEffectSequence extends AfterRenderSequence {
234237
}
235238

236239
override destroy(): void {
240+
if (this.onDestroyFns !== null) {
241+
for (const fn of this.onDestroyFns) {
242+
fn();
243+
}
244+
}
245+
237246
super.destroy();
238247

239248
// Run the cleanup functions for each node.

packages/core/src/render3/reactivity/effect.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {assertNotInReactiveContext} from './asserts';
2323
import {assertInInjectionContext} from '../../di/contextual';
2424
import {DestroyRef, NodeInjectorDestroyRef} from '../../linker/destroy_ref';
2525
import {ViewContext} from '../view_context';
26-
import {noop} from '../../util/noop';
2726
import {
2827
ChangeDetectionScheduler,
2928
NotificationSource,
@@ -177,7 +176,7 @@ export function effect(
177176

178177
if (destroyRef !== null) {
179178
// If we need to register for cleanup, do that here.
180-
node.onDestroyFn = destroyRef.onDestroy(() => node.destroy());
179+
node.onDestroyFns = [destroyRef.onDestroy(() => node.destroy())];
181180
}
182181

183182
const effectRef = new EffectRefImpl(node);
@@ -200,7 +199,7 @@ export interface EffectNode extends BaseEffectNode, SchedulableEffect {
200199
injector: Injector;
201200
notifier: ChangeDetectionScheduler;
202201

203-
onDestroyFn: () => void;
202+
onDestroyFns: (() => void)[] | null;
204203
}
205204

206205
export interface ViewEffectNode extends EffectNode {
@@ -216,7 +215,7 @@ export const EFFECT_NODE: Omit<EffectNode, 'fn' | 'destroy' | 'injector' | 'noti
216215
...BASE_EFFECT_NODE,
217216
cleanupFns: undefined,
218217
zone: null,
219-
onDestroyFn: noop,
218+
onDestroyFns: null,
220219
run(this: EffectNode): void {
221220
if (ngDevMode && isInNotificationPhase()) {
222221
throw new Error(`Schedulers cannot synchronously execute watches while scheduling.`);
@@ -259,7 +258,13 @@ export const ROOT_EFFECT_NODE: Omit<RootEffectNode, 'fn' | 'scheduler' | 'notifi
259258
},
260259
destroy(this: RootEffectNode) {
261260
consumerDestroy(this);
262-
this.onDestroyFn();
261+
262+
if (this.onDestroyFns !== null) {
263+
for (const fn of this.onDestroyFns) {
264+
fn();
265+
}
266+
}
267+
263268
this.cleanup();
264269
this.scheduler.remove(this);
265270
},
@@ -275,7 +280,13 @@ export const VIEW_EFFECT_NODE: Omit<ViewEffectNode, 'fn' | 'view' | 'injector' |
275280
},
276281
destroy(this: ViewEffectNode): void {
277282
consumerDestroy(this);
278-
this.onDestroyFn();
283+
284+
if (this.onDestroyFns !== null) {
285+
for (const fn of this.onDestroyFns) {
286+
fn();
287+
}
288+
}
289+
279290
this.cleanup();
280291
this.view[EFFECTS]?.delete(this);
281292
},

packages/core/test/acceptance/signal_debug_spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
Injectable,
1818
signal,
1919
Injector,
20+
ApplicationRef,
21+
afterRenderEffect,
2022
} from '../../src/core';
2123
import {
2224
getFrameworkDIDebugData,
@@ -445,4 +447,38 @@ describe('getSignalGraph', () => {
445447
expect(edgesWithNodes).toContain({consumer: effectBNode!, producer: signalANode!});
446448
expect(edgesWithNodes).toContain({consumer: effectDNode!, producer: signalCNode!});
447449
});
450+
451+
it('should stop tracking effect when ref is destroyed', () => {
452+
@Component({template: ''})
453+
class App {}
454+
455+
const fixture = TestBed.createComponent(App);
456+
fixture.detectChanges();
457+
458+
const injector = TestBed.inject(ApplicationRef).injector;
459+
expect(getFrameworkDIDebugData().resolverToEffects.has(injector)).toBe(false);
460+
461+
const ref = effect(() => {}, {injector});
462+
expect(getFrameworkDIDebugData().resolverToEffects.get(injector)?.length).toBe(1);
463+
464+
ref.destroy();
465+
expect(getFrameworkDIDebugData().resolverToEffects.get(injector)?.length).toBe(0);
466+
});
467+
468+
it('should stop tracking afterRenderEffect when ref is destroyed', () => {
469+
@Component({template: ''})
470+
class App {}
471+
472+
const fixture = TestBed.createComponent(App);
473+
fixture.detectChanges();
474+
475+
const injector = TestBed.inject(ApplicationRef).injector;
476+
expect(getFrameworkDIDebugData().resolverToEffects.has(injector)).toBe(false);
477+
478+
const ref = afterRenderEffect(() => {}, {injector});
479+
expect(getFrameworkDIDebugData().resolverToEffects.get(injector)?.length).toBe(1);
480+
481+
ref.destroy();
482+
expect(getFrameworkDIDebugData().resolverToEffects.get(injector)?.length).toBe(0);
483+
});
448484
});

0 commit comments

Comments
 (0)