Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
When an effect calls destroy() on its own EffectRef and then reads a signal in the same run, the destroyed effect is added back as a consumer of that signal. If the signal lives longer than the component, for example in a root service, it keeps the destroyed effect reachable, and through it the view of the destroyed component.
This shows up with "run once" effects, when destroy() comes before the last signal read:
@Component({selector: 'app-profile-page', templateUrl: './profile-page.html'})
export class ProfilePage {
private readonly store = inject(UserStore); // providedIn: 'root'
readonly form = new FormGroup({name: new FormControl(''), theme: new FormControl('')});
private readonly fillOnce = effect(() => {
const user = this.store.user();
if (!user) return;
this.fillOnce.destroy(); // fill the form only once
this.form.patchValue({name: user.name, theme: this.store.theme()}); // read after destroy()
});
}
Every time the page is opened and closed, one more ProfilePage (its view, its form, its DOM nodes) stays attached to store.theme. Nothing else is visible: the effect doesn't run again and there's no error. The next write to store.theme marks the dead effect dirty, which also schedules one change detection for nothing.
To check the impact, I built a page that follows this pattern and holds about 1 MB of data, opened and closed it 30 times, then forced garbage collection (Chrome with --js-flags=--expose-gc), counting collected pages with a FinalizationRegistry:
|
Pages collected |
Heap growth |
| Angular 22.2.1 |
0 / 30 |
+30.4 MB |
| With the fix in #71221 |
29 / 30 |
+1.4 MB |
destroy() calls consumerDestroy(), which removes the effect's links, but the effect is still the active consumer until the run ends. Effect nodes are always live, so producerAccessed creates a live link again for every signal read after destroy(). createWatch keeps a destroyed state so a destroyed watch never runs again; effect nodes don't have one.
Reading the signals before calling destroy(), or wrapping the rest of the run in untracked(), avoids it.
Please provide a link to a minimal reproduction of the bug
No hosted reproduction, but this is self-contained:
import {AfterViewInit, ApplicationRef, Component, effect, EffectRef, inject, signal, viewChild, ViewContainerRef} from '@angular/core';
import {SIGNAL} from '@angular/core/primitives/signals';
import {bootstrapApplication} from '@angular/platform-browser';
const theme = signal('dark');
@Component({selector: 'app-widget', template: 'widget'})
class Widget {
readonly ready = signal(false);
private readonly effectRef: EffectRef = effect(() => {
if (!this.ready()) return;
this.effectRef.destroy();
theme(); // read after destroy()
});
}
@Component({selector: 'app-root', template: '<ng-container #host />'})
class App implements AfterViewInit {
private readonly host = viewChild.required('host', {read: ViewContainerRef});
private readonly appRef = inject(ApplicationRef);
async ngAfterViewInit() {
const widget = this.host().createComponent(Widget);
await this.appRef.whenStable();
widget.instance.ready.set(true);
await this.appRef.whenStable();
widget.destroy();
// Logs the widget's effect node, whose `view` is the destroyed widget's LView.
console.log((theme as any)[SIGNAL].consumers?.consumer);
}
}
bootstrapApplication(App);
I expected undefined, since the only consumer of theme was destroyed. Opening and closing the widget N times leaves N effects linked to theme.
Please provide the exception or error you saw
None. The destroyed effect and its view just stay reachable from the signal.
Please provide the environment you discovered this bug in (run ng version)
Angular CLI : 22.2.1
Angular : 22.2.1
Node.js : 24.21.0
Package Manager : npm 11.19.0
Operating System : darwin arm64
@angular/build 22.2.1
@angular/cli 22.2.1
@angular/core 22.2.1
typescript 6.0.3
Anything else?
#71221 disconnects the effect again when a run ends on a destroyed effect.
A related case isn't changed by that PR: a cleanup function registered with onCleanup after destroy(), in the same run, never runs. Running it when the run ends would match what happens when onCleanup is called before destroy(), but it would also close resources that some code currently keeps open because of this, so I left it out.
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
When an effect calls
destroy()on its ownEffectRefand then reads a signal in the same run, the destroyed effect is added back as a consumer of that signal. If the signal lives longer than the component, for example in a root service, it keeps the destroyed effect reachable, and through it the view of the destroyed component.This shows up with "run once" effects, when
destroy()comes before the last signal read:Every time the page is opened and closed, one more
ProfilePage(its view, its form, its DOM nodes) stays attached tostore.theme. Nothing else is visible: the effect doesn't run again and there's no error. The next write tostore.thememarks the dead effect dirty, which also schedules one change detection for nothing.To check the impact, I built a page that follows this pattern and holds about 1 MB of data, opened and closed it 30 times, then forced garbage collection (Chrome with
--js-flags=--expose-gc), counting collected pages with aFinalizationRegistry:destroy()callsconsumerDestroy(), which removes the effect's links, but the effect is still the active consumer until the run ends. Effect nodes are always live, soproducerAccessedcreates a live link again for every signal read afterdestroy().createWatchkeeps a destroyed state so a destroyed watch never runs again; effect nodes don't have one.Reading the signals before calling
destroy(), or wrapping the rest of the run inuntracked(), avoids it.Please provide a link to a minimal reproduction of the bug
No hosted reproduction, but this is self-contained:
I expected
undefined, since the only consumer ofthemewas destroyed. Opening and closing the widget N times leaves N effects linked totheme.Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
#71221 disconnects the effect again when a run ends on a destroyed effect.
A related case isn't changed by that PR: a cleanup function registered with
onCleanupafterdestroy(), in the same run, never runs. Running it when the run ends would match what happens whenonCleanupis called beforedestroy(), but it would also close resources that some code currently keeps open because of this, so I left it out.