Visitar URL original
An effect that destroys itself keeps tracking the signals it reads afterwards · Issue #71220 · angular/angular · GitHub
Skip to content

An effect that destroys itself keeps tracking the signals it reads afterwards #71220

Description

@aminesbdev

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.

Activity

  1. added
    area: coreIssues related to the framework runtime
    gemini-triagedLabel noting that an issue has been triaged by gemini
    on Oct 7, 2026
  2. added this to the needsTriage milestone on Oct 7, 2026
  3. added 2 commits that reference this issue on Oct 7, 2026
    a5d1251
    40023fe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimegemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions