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).
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 frombuckets. IfApplicationRef._tick()in the same drain creates a new@deferblock with anon idletrigger,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 inscheduleBucketruns(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 atif (!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
triggerDeferBlockfor a view that may be gone. If the environment injector was destroyed,shouldTriggerDeferBlockthrowsNG0205: 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 toon idle— and a second idle-triggered@deferinside an@if. The first registers during the creation pass inTestBed.createComponent, the second only in the first change detection. In a zoneless TestBed on happy-dom (norequestIdleCallback, so thesetTimeoutfallback), 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. AfterTestBed.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 throughprovideIdleServiceWithruns its callbacks only when the test says so, which takes the timing out of the race:With 22.1.3 the last assertion fails: one request is still pending after
fixture.destroy()and afterTestBed.resetTestingModule(), and running it throws NG0205. With the change suggested below, nothing is pending afterfixture.destroy()— checked by swapping the changedscheduleBucketinto the prototype at runtime in the same spec.Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)The code in question is unchanged on main at 846c73d.
Anything else?
Suggested fix — delete the bucket only if the map still holds it:
Workaround in tests:
deferBlockBehavior: DeferBlockBehavior.Manualfor 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).