Skip to content

DropdownToggle: visibleChange subscription not torn down (leak on destroy) #270

Description

@mrholek

Summary

DropdownToggleDirective subscribes to the parent dropdown's visibleChange output in ngAfterViewInit() without a teardown, so the subscription is never unsubscribed when the directive is destroyed. Affects free and pro (projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts:109, identical in both).

Details

export class DropdownToggleDirective implements AfterViewInit {
  readonly #destroyRef = inject(DestroyRef);   // already available
  ...
  ngAfterViewInit(): void {
    ...
    if (this.dropdown) {
      const dropdown = <DropdownComponent>this.dropdown;
      dropdown?.visibleChange?.subscribe((visible) => {   // ← no teardown
        this.#ariaExpanded.set(visible);
      });
    }
  }
}

The subscription keeps the toggler's aria-expanded in sync with the dropdown's visibility, but it is not piped through takeUntilDestroyed. When the toggler is destroyed (e.g. an *ngIf/@if navbar, a dropdown removed from the DOM, route changes), the subscription — and the closure capturing the directive — stays alive on the still-living DropdownComponent's visibleChange emitter. Over a long session with dropdowns being created/destroyed this accumulates dead subscriptions.

Note: everything needed is already in the file — #destroyRef is injected (line 49) and takeUntilDestroyed is already imported and used for the other subscription in DropdownComponent (line 292). This one subscription was just missed.

Suggested fix

Pipe the subscription through takeUntilDestroyed:

dropdown?.visibleChange
  ?.pipe(takeUntilDestroyed(this.#destroyRef))
  .subscribe((visible) => {
    this.#ariaExpanded.set(visible);
  });

(Apply the same change in coreui-angular-pro.)

How to verify

A test that creates a DropdownToggleDirective under a dropdown, destroys the host component, then emits on the dropdown's visibleChange and asserts the toggler's aria-expanded handler is no longer invoked (subscription torn down). Alternatively assert the visibleChange observer count returns to 0 after destroy.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions