From da9388c6ecc0fe39f4be170fb457dea7126bdc73 Mon Sep 17 00:00:00 2001 From: nagahara-naoki <118710021+nagahara-naoki@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:49:21 +0900 Subject: [PATCH 1/2] fix(aria/accordion): avoid errors when destroyed before initialization Retain the panel associated during initialization so destruction does not read an unset required input. Cover early destruction and verify that initialized triggers still clear their panel association. Fixes #34060 --- src/aria/accordion/accordion-trigger.ts | 10 +++++-- src/aria/accordion/accordion.spec.ts | 38 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/aria/accordion/accordion-trigger.ts b/src/aria/accordion/accordion-trigger.ts index 041f28224f9f..99c5f0bb1ec3 100644 --- a/src/aria/accordion/accordion-trigger.ts +++ b/src/aria/accordion/accordion-trigger.ts @@ -70,6 +70,9 @@ export class AccordionTrigger implements OnInit, OnDestroy { /** The parent AccordionGroup. */ private readonly _accordionGroup = inject(ACCORDION_GROUP); + /** The panel that this trigger was registered with. */ + private _registeredPanel: AccordionPanel | undefined; + /** The associated AccordionPanel. */ readonly panel = input.required(); @@ -126,13 +129,16 @@ export class AccordionTrigger implements OnInit, OnDestroy { } ngOnInit() { - this.panel()._pattern = this._pattern; + this._registeredPanel = this.panel(); + this._registeredPanel._pattern = this._pattern; this._accordionGroup._collection.register(this); } ngOnDestroy() { - this.panel()._pattern = undefined; + if (this._registeredPanel) { + this._registeredPanel._pattern = undefined; + } this._accordionGroup._collection.unregister(this); } diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index c4ee7ca9f0ec..c84cc63806da 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -825,3 +825,41 @@ class AccordionPanelWithoutTrigger {} changeDetection: ChangeDetectionStrategy.Eager, }) class AccordionWithMultipleExpandedItems {} + +describe('Accordion destruction', () => { + it('should destroy a trigger before the first change detection', () => { + const fixture = TestBed.createComponent(StaticAccordion); + + expect(() => fixture.destroy()).not.toThrow(); + }); + + it('should clear the panel association when destroying an initialized trigger', async () => { + const fixture = TestBed.createComponent(StaticAccordion); + await fixture.whenStable(); + + const panel = fixture.debugElement + .query(By.directive(AccordionPanel)) + .injector.get(AccordionPanel); + const trigger = fixture.debugElement + .query(By.directive(AccordionTrigger)) + .injector.get(AccordionTrigger); + expect(panel._pattern).toBe(trigger._pattern); + + expect(() => fixture.destroy()).not.toThrow(); + expect(panel._pattern).toBeUndefined(); + }); +}); + +@Component({ + template: ` +
+ +
+ Content +
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class StaticAccordion {} From 06febca448fab5e011e1fb218796757fa09b4fc6 Mon Sep 17 00:00:00 2001 From: nagahara-naoki <118710021+nagahara-naoki@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:32:09 +0900 Subject: [PATCH 2/2] fix(aria/accordion): manage registration with effects Keep panel registration and cleanup together using an effect instead of a retained reference. Cover panel input changes and update the API golden for removed lifecycle hooks. --- goldens/aria/accordion/index.api.md | 7 +--- src/aria/accordion/accordion-trigger.ts | 36 +++++++--------- src/aria/accordion/accordion.spec.ts | 55 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/goldens/aria/accordion/index.api.md b/goldens/aria/accordion/index.api.md index 98049f6ad2d4..21f5c56ea0a6 100644 --- a/goldens/aria/accordion/index.api.md +++ b/goldens/aria/accordion/index.api.md @@ -8,7 +8,6 @@ import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; import { InjectionToken } from '@angular/core'; import { OnDestroy } from '@angular/core'; -import { OnInit } from '@angular/core'; import { Signal } from '@angular/core'; // @public @@ -60,7 +59,7 @@ export class AccordionPanel { } // @public -export class AccordionTrigger implements OnInit, OnDestroy { +export class AccordionTrigger { constructor(); readonly active: _angular_core.Signal; collapse(): void; @@ -69,10 +68,6 @@ export class AccordionTrigger implements OnInit, OnDestroy { expand(): void; readonly expanded: _angular_core.ModelSignal; readonly id: _angular_core.InputSignal; - // (undocumented) - ngOnDestroy(): void; - // (undocumented) - ngOnInit(): void; readonly panel: _angular_core.InputSignal; readonly panelId: _angular_core.Signal; readonly _pattern: AccordionTriggerPattern; diff --git a/src/aria/accordion/accordion-trigger.ts b/src/aria/accordion/accordion-trigger.ts index 99c5f0bb1ec3..d53faab44f9a 100644 --- a/src/aria/accordion/accordion-trigger.ts +++ b/src/aria/accordion/accordion-trigger.ts @@ -9,8 +9,8 @@ import { Directive, ElementRef, - OnDestroy, - OnInit, + effect, + untracked, booleanAttribute, computed, inject, @@ -60,7 +60,7 @@ import {AccordionPanel} from './accordion-panel'; 'ngAccordionTrigger': '', }, }) -export class AccordionTrigger implements OnInit, OnDestroy { +export class AccordionTrigger { /** A reference to the trigger element. */ private readonly _elementRef = inject(ElementRef); @@ -70,9 +70,6 @@ export class AccordionTrigger implements OnInit, OnDestroy { /** The parent AccordionGroup. */ private readonly _accordionGroup = inject(ACCORDION_GROUP); - /** The panel that this trigger was registered with. */ - private _registeredPanel: AccordionPanel | undefined; - /** The associated AccordionPanel. */ readonly panel = input.required(); @@ -100,6 +97,18 @@ export class AccordionTrigger implements OnInit, OnDestroy { readonly active = computed(() => this._pattern.active()); constructor() { + effect(onCleanup => { + const panel = this.panel(); + untracked(() => { + panel._pattern = this._pattern; + this._accordionGroup._collection.register(this); + }); + onCleanup(() => { + panel._pattern = undefined; + this._accordionGroup._collection.unregister(this); + }); + }); + // Automatically prevent form submission. if (this.element.tagName === 'BUTTON' && !this.element.hasAttribute('type')) { this.element.setAttribute('type', 'button'); @@ -128,21 +137,6 @@ export class AccordionTrigger implements OnInit, OnDestroy { } } - ngOnInit() { - this._registeredPanel = this.panel(); - this._registeredPanel._pattern = this._pattern; - - this._accordionGroup._collection.register(this); - } - - ngOnDestroy() { - if (this._registeredPanel) { - this._registeredPanel._pattern = undefined; - } - - this._accordionGroup._collection.unregister(this); - } - /** Expands this item. */ expand() { this._pattern.open(); diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index c84cc63806da..da40f7e1c066 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -827,6 +827,42 @@ class AccordionPanelWithoutTrigger {} class AccordionWithMultipleExpandedItems {} describe('Accordion destruction', () => { + it('should update the panel association when the panel input changes', async () => { + const fixture = TestBed.createComponent(AccordionWithChangingPanel); + await fixture.whenStable(); + const panels = fixture.debugElement + .queryAll(By.directive(AccordionPanel)) + .map(el => el.injector.get(AccordionPanel)); + const trigger = fixture.debugElement + .query(By.directive(AccordionTrigger)) + .injector.get(AccordionTrigger); + const group = fixture.debugElement + .query(By.directive(AccordionGroup)) + .injector.get(AccordionGroup); + + expect(panels[0]._pattern).toBe(trigger._pattern); + expect(panels[1]._pattern).toBeUndefined(); + expect(group._collection.orderedItems()).toEqual([trigger]); + + fixture.componentInstance.panel.set(panels[1]); + await fixture.whenStable(); + + expect(panels[0]._pattern).toBeUndefined(); + expect(panels[1]._pattern).toBe(trigger._pattern); + expect(group._collection.orderedItems()).toEqual([trigger]); + + trigger.expand(); + await fixture.whenStable(); + expect(trigger.expanded()).toBeTrue(); + panels[1].collapse(); + await fixture.whenStable(); + expect(trigger.expanded()).toBeFalse(); + + fixture.destroy(); + expect(panels[1]._pattern).toBeUndefined(); + expect(group._collection.orderedItems()).toEqual([]); + }); + it('should destroy a trigger before the first change detection', () => { const fixture = TestBed.createComponent(StaticAccordion); @@ -863,3 +899,22 @@ describe('Accordion destruction', () => { changeDetection: ChangeDetectionStrategy.Eager, }) class StaticAccordion {} + +@Component({ + template: ` +
+ +
+ First +
+
+ Second +
+
+ `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionWithChangingPanel { + panel = signal(undefined); +}