Description
Is this a regression?
- Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
TreeItem and TreeItemGroup throw NG0950 when a component containing them is destroyed synchronously after TestBed.createComponent(), before its first change detection.
The required inputs are bound in the template. However, ngOnInit has not run and the input bindings have not been applied, while ngOnDestroy still reads them when attempting to unregister.
This affects early teardown, for example a unit test that creates a component and destroys it without running change detection. I reproduced this in the repository Chromium test runner. I have not verified how frequently it occurs in production applications; ordinary initialized trees are not affected by this reproduction.
Reproduction
The minimal failing operation, using either fixture defined below, is:
const fixture = TestBed.createComponent(StaticFlatTree);
fixture.destroy(); // NG0950: required input "parent" has not been set yet.With StaticNestedTree, the same operation reports the required ownedBy input.
This is a synchronous creation/teardown lifecycle reproduction rather than a visible interaction, so I am including complete TestBed fixtures and the exact repository test command instead of an interactive StackBlitz.
Repository setup
- Check out angular/components at
e59fce84b. - Install the Node.js version in
.nvmrcand pnpm frompackage.json#packageManager, then runpnpm install --frozen-lockfile. - In
src/aria/tree/tree.spec.ts, add the following fixtures and tests as a separate top-leveldescribe, outside the existingdescribe('Tree')(itsafterEachexpects its own initialized fixture). The imports listed below are already present; reuse them instead of adding duplicate imports. - Run
pnpm test src/aria/tree --no-watch.
// Already imported in tree.spec.ts:
// import {Component, ChangeDetectionStrategy} from "@angular/core";
// import {TestBed} from "@angular/core/testing";
// import {Tree} from "./tree";
// import {TreeItem} from "./tree-item";
// import {TreeItemGroup} from "./tree-item-group";
@Component({
template: `
<ul ngTree #tree="ngTree">
<li ngTreeItem [parent]="tree" value="item">Item</li>
</ul>
`,
imports: [Tree, TreeItem],
changeDetection: ChangeDetectionStrategy.Eager,
})
class StaticFlatTree {}
@Component({
template: `
<ul ngTree #tree="ngTree">
<li ngTreeItem [parent]="tree" value="parent" #parentItem="ngTreeItem">
Parent
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="parentItem" #group="ngTreeItemGroup">
<li ngTreeItem [parent]="group" value="child">Child</li>
</ng-template>
</ul>
</li>
</ul>
`,
imports: [Tree, TreeItem, TreeItemGroup],
changeDetection: ChangeDetectionStrategy.Eager,
})
class StaticNestedTree {}
describe("Tree destruction", () => {
it("destroys a flat tree before first change detection", () => {
const fixture = TestBed.createComponent(StaticFlatTree);
expect(() => fixture.destroy()).not.toThrow();
});
it("destroys a nested tree before first change detection", () => {
const fixture = TestBed.createComponent(StaticNestedTree);
expect(() => fixture.destroy()).not.toThrow();
});
it("destroys an initialized flat tree (control)", async () => {
const fixture = TestBed.createComponent(StaticFlatTree);
await fixture.whenStable();
expect(() => fixture.destroy()).not.toThrow();
});
it("destroys an initialized nested tree (control)", async () => {
const fixture = TestBed.createComponent(StaticNestedTree);
await fixture.whenStable();
expect(() => fixture.destroy()).not.toThrow();
});
});Do not call fixture.detectChanges() or await fixture.whenStable() before destroy() in the first two tests: that initializes the bindings and bypasses the reproduction. Use static template elements; putting the items inside @if/@for changes when they are created.
The ngTreeItemGroup directive itself is created with the static template, even though its child item inside ng-template is deferred.
Expected Behavior
Destroying before initialization should complete without throwing. Nothing was registered in ngOnInit, so there should be nothing to unregister. Destruction after initialization should continue to unregister normally.
Actual Behavior
The flat-tree test throws:
NG0950: Input "parent" is required but no value is available yet.
The nested-tree test throws:
NG0950: Input "ownedBy" is required but no value is available yet.
With the four tests in the reproduction, the Chromium suite reports 144 passed, 2 failed. Only the two pre-initialization tests fail; both initialized control tests pass.
Relevant cleanup code
TreeItem.ngOnDestroy() reads parent() and tree(), and TreeItemGroup.ngOnDestroy() reads ownedBy(). Registration happens in ngOnInit.
Related issue and PR
This appears to be the same lifecycle issue as #33972, fixed for combobox in #33974. A corresponding tree fix can retain the references used during registration and only unregister them if initialization registered them. The implementation and regression tests are in #34059.
This report covers @angular/aria/tree only.
Environment
- Components source: angular/components at
e59fce84b(2026-11-04) - Angular framework packages used by the repository: 22.3.0-next.3
- Browser: Chromium provided by the repository Bazel browser test setup
- Operating system: macOS, Darwin 25.5.0, arm64
- Node.js: 22.23.4 (
.nvmrc) - pnpm: 12.16.1 (
packageManager) - Command:
pnpm test src/aria/tree --no-watch - Regression status: unknown; older releases have not been tested.
Activity
fix(aria/tree): avoid errors when destroyed before initialization