Skip to content

Commit b5f15f4

Browse files
authored
fix(cdk/a11y): clean up list key manager on destroy (#25715)
Historically we haven't needed to do any cleanup for the `ListKeyManager`, because it didn't contain any rxjs subscriptions. Over time we've accumulated more functionality which now needs to be cleaned up. These changes introduce a `destroy` method and calls it in all the relevant places. I also removed any places where we previously had to do manual cleanup. Fixes #25537.
1 parent 5246431 commit b5f15f4

File tree

15 files changed

+76
-38
lines changed

15 files changed

+76
-38
lines changed

src/cdk/a11y/key-manager/list-key-manager.spec.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ describe('Key managers', () => {
8282
spyOn(keyManager, 'setActiveItem').and.callThrough();
8383
});
8484

85+
afterEach(() => {
86+
keyManager.destroy();
87+
keyManager = null!;
88+
});
89+
8590
it('should maintain the active item if the amount of items changes', () => {
8691
expect(keyManager.activeItemIndex).toBe(0);
8792
expect(keyManager.activeItem!.getLabel()).toBe('one');
@@ -110,6 +115,14 @@ describe('Key managers', () => {
110115
expect(spy).toHaveBeenCalled();
111116
});
112117

118+
it('should complete the tabOut stream on destroy', () => {
119+
const spy = jasmine.createSpy('complete spy');
120+
keyManager.tabOut.pipe(take(1)).subscribe({complete: spy});
121+
keyManager.destroy();
122+
123+
expect(spy).toHaveBeenCalled();
124+
});
125+
113126
it('should emit tabOut when the tab key is pressed with a modifier', () => {
114127
const spy = jasmine.createSpy('tabOut spy');
115128
keyManager.tabOut.pipe(take(1)).subscribe(spy);
@@ -122,27 +135,33 @@ describe('Key managers', () => {
122135

123136
it('should emit an event whenever the active item changes', () => {
124137
const spy = jasmine.createSpy('change spy');
125-
const subscription = keyManager.change.subscribe(spy);
138+
keyManager.change.subscribe(spy);
126139

127140
keyManager.onKeydown(fakeKeyEvents.downArrow);
128141
expect(spy).toHaveBeenCalledTimes(1);
129142

130143
keyManager.onKeydown(fakeKeyEvents.upArrow);
131144
expect(spy).toHaveBeenCalledTimes(2);
132-
133-
subscription.unsubscribe();
134145
});
135146

136147
it('should emit if the active item changed, but not the active index', () => {
137148
const spy = jasmine.createSpy('change spy');
138-
const subscription = keyManager.change.subscribe(spy);
149+
keyManager.change.subscribe(spy);
139150

140151
keyManager.setActiveItem(0);
141152
itemList.reset([new FakeFocusable('zero'), ...itemList.toArray()]);
142153
keyManager.setActiveItem(0);
143154

144155
expect(spy).toHaveBeenCalledTimes(1);
145-
subscription.unsubscribe();
156+
});
157+
158+
it('should complete the change stream on destroy', () => {
159+
const spy = jasmine.createSpy('change spy');
160+
161+
keyManager.change.subscribe({complete: spy});
162+
keyManager.destroy();
163+
164+
expect(spy).toHaveBeenCalled();
146165
});
147166

148167
it('should activate the first item when pressing down on a clean key manager', () => {
@@ -448,7 +467,7 @@ describe('Key managers', () => {
448467
) {
449468
const initialActiveIndex = keyManager.activeItemIndex;
450469
const spy = jasmine.createSpy('change spy');
451-
const subscription = keyManager.change.subscribe(spy);
470+
keyManager.change.subscribe(spy);
452471

453472
expect(context.nextKeyEvent.defaultPrevented).toBe(false);
454473
expect(context.prevKeyEvent.defaultPrevented).toBe(false);
@@ -465,8 +484,6 @@ describe('Key managers', () => {
465484
expect(context.prevKeyEvent.defaultPrevented).toBe(false);
466485
expect(keyManager.activeItemIndex).toBe(initialActiveIndex);
467486
expect(spy).not.toHaveBeenCalled();
468-
469-
subscription.unsubscribe();
470487
}
471488
});
472489

@@ -495,16 +512,14 @@ describe('Key managers', () => {
495512

496513
it('should be able to set the active item without emitting an event', () => {
497514
const spy = jasmine.createSpy('change spy');
498-
const subscription = keyManager.change.subscribe(spy);
515+
keyManager.change.subscribe(spy);
499516

500517
expect(keyManager.activeItemIndex).toBe(0);
501518

502519
keyManager.updateActiveItem(2);
503520

504521
expect(keyManager.activeItemIndex).toBe(2);
505522
expect(spy).not.toHaveBeenCalled();
506-
507-
subscription.unsubscribe();
508523
});
509524

510525
it('should expose the active item correctly', () => {
@@ -629,14 +644,12 @@ describe('Key managers', () => {
629644

630645
it('should not emit an event if the item did not change', () => {
631646
const spy = jasmine.createSpy('change spy');
632-
const subscription = keyManager.change.subscribe(spy);
647+
keyManager.change.subscribe(spy);
633648

634649
keyManager.setActiveItem(2);
635650
keyManager.setActiveItem(2);
636651

637652
expect(spy).toHaveBeenCalledTimes(1);
638-
639-
subscription.unsubscribe();
640653
});
641654
});
642655

@@ -935,6 +948,16 @@ describe('Key managers', () => {
935948

936949
expect(keyManager.isTyping()).toBe(false);
937950
}));
951+
952+
it('should reset isTyping if the key manager is destroyed', fakeAsync(() => {
953+
expect(keyManager.isTyping()).toBe(false);
954+
955+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
956+
expect(keyManager.isTyping()).toBe(true);
957+
958+
keyManager.destroy();
959+
expect(keyManager.isTyping()).toBe(false);
960+
}));
938961
});
939962
});
940963

@@ -953,6 +976,11 @@ describe('Key managers', () => {
953976
spyOn(itemList.toArray()[2], 'focus');
954977
});
955978

979+
afterEach(() => {
980+
keyManager.destroy();
981+
keyManager = null!;
982+
});
983+
956984
it('should focus subsequent items when down arrow is pressed', () => {
957985
keyManager.onKeydown(fakeKeyEvents.downArrow);
958986

src/cdk/a11y/key-manager/list-key-manager.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
4848
private _wrap = false;
4949
private readonly _letterKeyStream = new Subject<string>();
5050
private _typeaheadSubscription = Subscription.EMPTY;
51+
private _itemChangesSubscription?: Subscription;
5152
private _vertical = true;
5253
private _horizontal: 'ltr' | 'rtl' | null;
5354
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
@@ -68,7 +69,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
6869
// not have access to a QueryList of the items they want to manage (e.g. when the
6970
// items aren't being collected via `ViewChildren` or `ContentChildren`).
7071
if (_items instanceof QueryList) {
71-
_items.changes.subscribe((newItems: QueryList<T>) => {
72+
this._itemChangesSubscription = _items.changes.subscribe((newItems: QueryList<T>) => {
7273
if (this._activeItem) {
7374
const itemArray = newItems.toArray();
7475
const newIndex = itemArray.indexOf(this._activeItem);
@@ -392,6 +393,16 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
392393
this._activeItemIndex = index;
393394
}
394395

396+
/** Cleans up the key manager. */
397+
destroy() {
398+
this._typeaheadSubscription.unsubscribe();
399+
this._itemChangesSubscription?.unsubscribe();
400+
this._letterKeyStream.complete();
401+
this.tabOut.complete();
402+
this.change.complete();
403+
this._pressedLetters = [];
404+
}
405+
395406
/**
396407
* This method sets the active item, given a list of items and the delta between the
397408
* currently active item and the new active item. It will calculate differently

src/cdk/listbox/listbox.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ export class CdkListbox<T = unknown>
494494
}
495495

496496
ngOnDestroy() {
497-
this.listKeyManager.change.complete();
497+
this.listKeyManager?.destroy();
498498
this.destroyed.next();
499499
this.destroyed.complete();
500500
}
@@ -869,9 +869,7 @@ export class CdkListbox<T = unknown>
869869
this.listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr');
870870
}
871871

872-
this.listKeyManager.change
873-
.pipe(takeUntil(this.destroyed))
874-
.subscribe(() => this._focusActiveOption());
872+
this.listKeyManager.change.subscribe(() => this._focusActiveOption());
875873
}
876874

877875
/** Focus the active option. */

src/cdk/menu/menu-base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export abstract class CdkMenuBase
111111
}
112112

113113
ngOnDestroy() {
114+
this.keyManager?.destroy();
114115
this.destroyed.next();
115116
this.destroyed.complete();
116117
this.pointerTracker?.destroy();

src/cdk/stepper/stepper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
411411
}
412412

413413
ngOnDestroy() {
414+
this._keyManager?.destroy();
414415
this.steps.destroy();
415416
this._sortedHeaders.destroy();
416417
this._destroyed.next();

src/material/autocomplete/autocomplete.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export abstract class _MatAutocompleteBase
249249
}
250250

251251
ngOnDestroy() {
252+
this._keyManager?.destroy();
252253
this._activeOptionChanges.unsubscribe();
253254
}
254255

src/material/chips/chip-set.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export class MatChipSet
147147
}
148148

149149
ngOnDestroy() {
150+
this._keyManager?.destroy();
150151
this._chipActions.destroy();
151152
this._destroyed.next();
152153
this._destroyed.complete();

src/material/expansion/accordion.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export class MatAccordion
104104

105105
override ngOnDestroy() {
106106
super.ngOnDestroy();
107+
this._keyManager?.destroy();
107108
this._ownHeaders.destroy();
108109
}
109110
}

src/material/legacy-chips/chip-list.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,7 @@ export class MatLegacyChipList
411411
.subscribe(dir => this._keyManager.withHorizontalOrientation(dir));
412412
}
413413

414-
this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => {
415-
this._allowFocusEscape();
416-
});
414+
this._keyManager.tabOut.subscribe(() => this._allowFocusEscape());
417415

418416
// When the list changes, re-subscribe
419417
this.chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
@@ -459,10 +457,10 @@ export class MatLegacyChipList
459457
}
460458

461459
ngOnDestroy() {
460+
this._keyManager?.destroy();
462461
this._destroyed.next();
463462
this._destroyed.complete();
464463
this.stateChanges.complete();
465-
466464
this._dropSubscriptions();
467465
}
468466

src/material/legacy-list/selection-list.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,7 @@ export class MatLegacySelectionList
468468
}
469469

470470
// If the user attempts to tab out of the selection list, allow focus to escape.
471-
this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => {
472-
this._allowFocusEscape();
473-
});
471+
this._keyManager.tabOut.subscribe(() => this._allowFocusEscape());
474472

475473
// When the number of options change, update the tabindex of the selection list.
476474
this.options.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
@@ -522,6 +520,7 @@ export class MatLegacySelectionList
522520
}
523521

524522
ngOnDestroy() {
523+
this._keyManager?.destroy();
525524
this._focusMonitor.stopMonitoring(this._element);
526525
this._destroyed.next();
527526
this._destroyed.complete();

src/material/list/selection-list.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export class MatSelectionList
176176
}
177177

178178
ngOnDestroy() {
179+
this._keyManager?.destroy();
179180
this._element.nativeElement.removeEventListener('focusin', this._handleFocusin);
180181
this._element.nativeElement.removeEventListener('focusout', this._handleFocusout);
181182
this._destroyed.next();
@@ -377,9 +378,7 @@ export class MatSelectionList
377378
this._resetActiveOption();
378379

379380
// Move the tabindex to the currently-focused list item.
380-
this._keyManager.change
381-
.pipe(takeUntil(this._destroyed))
382-
.subscribe(activeItemIndex => this._setActiveOption(activeItemIndex));
381+
this._keyManager.change.subscribe(activeItemIndex => this._setActiveOption(activeItemIndex));
383382

384383
// If the active item is removed from the list, reset back to the first one.
385384
this._items.changes.pipe(takeUntil(this._destroyed)).subscribe(() => {

src/material/menu/menu.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
UP_ARROW,
4141
hasModifierKey,
4242
} from '@angular/cdk/keycodes';
43-
import {merge, Observable, Subject, Subscription} from 'rxjs';
43+
import {merge, Observable, Subject} from 'rxjs';
4444
import {startWith, switchMap, take} from 'rxjs/operators';
4545
import {MatMenuItem} from './menu-item';
4646
import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel';
@@ -112,9 +112,6 @@ export class _MatMenuBase
112112
/** Only the direct descendant menu items. */
113113
_directDescendantItems = new QueryList<MatMenuItem>();
114114

115-
/** Subscription to tab events on the menu panel */
116-
private _tabSubscription = Subscription.EMPTY;
117-
118115
/** Config object to be passed into the menu's ngClass */
119116
_classList: {[key: string]: boolean} = {};
120117

@@ -305,7 +302,7 @@ export class _MatMenuBase
305302
.withWrap()
306303
.withTypeAhead()
307304
.withHomeAndEnd();
308-
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
305+
this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
309306

310307
// If a user manually (programmatically) focuses a menu item, we need to reflect that focus
311308
// change back to the key manager. Note that we don't need to unsubscribe here because _focused
@@ -337,8 +334,8 @@ export class _MatMenuBase
337334
}
338335

339336
ngOnDestroy() {
337+
this._keyManager?.destroy();
340338
this._directDescendantItems.destroy();
341-
this._tabSubscription.unsubscribe();
342339
this.closed.complete();
343340
}
344341

src/material/select/select.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ export abstract class _MatSelectBase<C>
581581
}
582582

583583
ngOnDestroy() {
584+
this._keyManager?.destroy();
584585
this._destroy.next();
585586
this._destroy.complete();
586587
this.stateChanges.complete();
@@ -917,7 +918,7 @@ export abstract class _MatSelectBase<C>
917918
.withPageUpDown()
918919
.withAllowedModifierKeys(['shiftKey']);
919920

920-
this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => {
921+
this._keyManager.tabOut.subscribe(() => {
921922
if (this.panelOpen) {
922923
// Select the active item when tabbing away. This is consistent with how the native
923924
// select behaves. Note that we only want to do this in single selection mode.
@@ -932,7 +933,7 @@ export abstract class _MatSelectBase<C>
932933
}
933934
});
934935

935-
this._keyManager.change.pipe(takeUntil(this._destroy)).subscribe(() => {
936+
this._keyManager.change.subscribe(() => {
936937
if (this._panelOpen && this.panel) {
937938
this._scrollOptionIntoView(this._keyManager.activeItemIndex || 0);
938939
} else if (!this._panelOpen && !this.multiple && this._keyManager.activeItem) {

src/material/tabs/paginated-tab-header.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export abstract class MatPaginatedTabHeader
249249
// If there is a change in the focus key manager we need to emit the `indexFocused`
250250
// event in order to provide a public event that notifies about focus changes. Also we realign
251251
// the tabs container by scrolling the new focused tab into the visible section.
252-
this._keyManager.change.pipe(takeUntil(this._destroyed)).subscribe(newFocusIndex => {
252+
this._keyManager.change.subscribe(newFocusIndex => {
253253
this.indexFocused.emit(newFocusIndex);
254254
this._setTabFocus(newFocusIndex);
255255
});
@@ -313,6 +313,7 @@ export abstract class MatPaginatedTabHeader
313313
}
314314

315315
ngOnDestroy() {
316+
this._keyManager?.destroy();
316317
this._destroyed.next();
317318
this._destroyed.complete();
318319
this._stopScrolling.complete();

tools/public_api_guard/cdk/a11y.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
343343
get activeItem(): T | null;
344344
get activeItemIndex(): number | null;
345345
readonly change: Subject<number>;
346+
destroy(): void;
346347
isTyping(): boolean;
347348
onKeydown(event: KeyboardEvent): void;
348349
setActiveItem(index: number): void;

0 commit comments

Comments
 (0)