Skip to content

core: IdleScheduler drops a bucket created during another bucket's drain, so its idle request can never be cancelled (NG0205 after teardown) #70941

Description

@berchtoldmarketing

Which @angular/* package(s) are the source of the bug?

core

Is this a regression?

No

The unconditional delete described below is already in the parent of c2b14b7 (the commit discussed in #70728) and is still on main at 846c73d.

Description

While a bucket drains, a trigger's cleanup can empty it, and remove() then deletes it from buckets. If ApplicationRef._tick() in the same drain creates a new @defer block with an on idle trigger, add() finds no bucket under that key, creates a new one and requests a new idle callback. When the drain finishes, the old bucket's queue is empty, so the callback in scheduleBucket runs

this.buckets.delete(key);

(idle_scheduler.ts#L128) — and that deletes the new bucket, not its own.

From then on the new request can no longer be cancelled:

  • remove() returns at if (!bucket) return; (L86), so destroying the view leaves it pending;
  • ngOnDestroy() only cancels the buckets still in the map (L147), so destroying the injector leaves it pending too.

When it finally runs, it calls triggerDeferBlock for a view that may be gone. If the environment injector was destroyed, shouldTriggerDeferBlock throws NG0205: Injector has already been destroyed; if it is still alive, the dependencies of a destroyed block are loaded.

How we ran into it. A component whose template has a top-level @defer (hydrate never) — in a client render its main trigger defaults to on idle — and a second idle-triggered @defer inside an @if. The first registers during the creation pass in TestBed.createComponent, the second only in the first change detection. In a zoneless TestBed on happy-dom (no requestIdleCallback, so the setTimeout fallback), the idle timeout sometimes fires before the scheduled change detection. The idle callback's _tick() then is the first change detection, and the second block's request is orphaned. After TestBed.resetTestingModule() it fires during a later test, and Vitest reports an unhandled NG0205 although every test passes — in about one run out of five for us.

Please provide a link to a minimal reproduction of the bug

A deterministic TestBed spec (Vitest via @angular/build:unit-test, zoneless). An idle service provided through provideIdleServiceWith runs its callbacks only when the test says so, which takes the timing out of the race:

import { Component, Injectable, provideIdleServiceWith } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { expect, it } from 'vitest';

/** An idle service whose callbacks run only when the test says so. */
@Injectable()
class ManualIdle {
  private next = 0;
  readonly pending = new Map<number, () => void>();

  requestOnIdle(callback: () => void): number {
    this.pending.set(++this.next, callback);
    return this.next;
  }

  cancelOnIdle(id: number): void {
    this.pending.delete(id);
  }

  flush(): void {
    const callbacks = [...this.pending.values()];
    this.pending.clear();
    callbacks.forEach((callback) => callback());
  }
}

@Component({ selector: 'app-a', template: 'A' })
class A {}

@Component({ selector: 'app-b', template: 'B' })
class B {}

@Component({
  imports: [A, B],
  template: `
    @defer (on idle) {
      <app-a />
    }
    @if (true) {
      @defer (on idle) {
        <app-b />
      }
    }
  `,
})
class Host {}

it('cancels every idle request when the view is destroyed', () => {
  TestBed.configureTestingModule({ providers: [ManualIdle, provideIdleServiceWith(ManualIdle)] });
  const idle = TestBed.inject(ManualIdle);

  // Creation pass only: the first @defer requests #1. The @if is created in the first change detection.
  const fixture = TestBed.createComponent(Host);
  expect(idle.pending.size).toBe(1);

  // #1 runs before any change detection. Its _tick() creates the @if, whose @defer requests #2
  // in a new bucket, and the end of the drain deletes that bucket from `buckets`.
  idle.flush();
  expect(idle.pending.size).toBe(1);

  fixture.destroy();
  expect(idle.pending.size).toBe(0); // fails on 22.1.3: #2 is still pending

  // It also survives TestBed.resetTestingModule(); running it afterwards (idle.flush()) throws NG0205.
});

With 22.1.3 the last assertion fails: one request is still pending after fixture.destroy() and after TestBed.resetTestingModule(), and running it throws NG0205. With the change suggested below, nothing is pending after fixture.destroy() — checked by swapping the changed scheduleBucket into the prototype at runtime in the same spec.

Please provide the exception or error you saw

Error: NG0205: Injector has already been destroyed. Find more at https://v22.angular.dev/errors/NG0205
 ❯ assertNotDestroyed
 ❯ R3Injector.get
 ❯ ChainedInjector.get
 ❯ shouldTriggerDeferBlock
 ❯ triggerDeferBlock
 ❯ (the callback registered by scheduleDelayedTrigger)
 ❯ callback (IdleScheduler.scheduleBucket)
 ❯ NoopNgZone.run
 ❯ Timeout._onTimeout

Please provide the environment you discovered this bug in (run ng version)

@angular/core   22.1.3
@angular/build  22.1.5 (unit-test builder, Vitest 4.1.11)
happy-dom       20.11.6
Node.js         26.6.0
Zoneless (default)

The code in question is unchanged on main at 846c73d.

Anything else?

Suggested fix — delete the bucket only if the map still holds it:

bucket.idleId = null;

if (bucket.queue.size > 0) {
  this.scheduleBucket(bucket, options);
} else if (this.buckets.get(key) === bucket) {
  this.buckets.delete(key);
}

Workaround in tests: deferBlockBehavior: DeferBlockBehavior.Manual for specs that render such a tree, so that no idle trigger is scheduled at all.

Related, with a different trigger in the same function: #70728 (a throw during the drain).

Activity

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