diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index f88b3ce97233..439500d5b43c 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -15,6 +15,8 @@ import { NgZone, Renderer2, afterNextRender, + signal, + untracked, } from '@angular/core'; import {Observable, Subject, Subscription, SubscriptionLike} from 'rxjs'; import {Direction, Directionality} from '../bidi'; @@ -37,12 +39,10 @@ export function isElement(value: any): value is Element { return value && (value as Element).nodeType === 1; } -const attachedOverlays = new Set(); +const attachedOverlaysInternal = signal([]); -/** Gets all overlays that are currently attached. */ -export function getAttachedOverlays(): OverlayRef[] { - return Array.from(attachedOverlays); -} +/** Tracks all currently-attached overlays. */ +export const attachedOverlays = attachedOverlaysInternal.asReadonly(); /** * Reference to an overlay that has been created with the Overlay service. @@ -149,7 +149,11 @@ export class OverlayRef implements PortalOutlet { this._updateStackingOrder(); this._updateElementSize(); this._updateElementDirection(); - attachedOverlays.add(this); + + // Needs to be untracked in case an overlay is opened as a part of template rendering. + untracked(() => { + attachedOverlaysInternal.update(prev => (prev.includes(this) ? prev : [...prev, this])); + }); if (this._scrollStrategy) { this._scrollStrategy.enable(); @@ -256,7 +260,11 @@ export class OverlayRef implements PortalOutlet { this._detachContentWhenEmpty(); this._locationChanges.unsubscribe(); this._outsideClickDispatcher.remove(this); - attachedOverlays.delete(this); + + untracked(() => { + attachedOverlaysInternal.update(prev => prev.filter(o => o !== this)); + }); + return detachmentResult; } @@ -293,7 +301,10 @@ export class OverlayRef implements PortalOutlet { this._detachments.complete(); this._completeDetachContent(); this._disposed = true; - attachedOverlays.delete(this); + + untracked(() => { + attachedOverlaysInternal.update(prev => prev.filter(o => o !== this)); + }); } /** Whether the overlay has attached content. */ diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index be27634020b8..936922119657 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -13,6 +13,8 @@ import { WritableSignal, inject, signal, + computed, + effect, ChangeDetectionStrategy, } from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; @@ -30,7 +32,7 @@ import { ScrollStrategy, createOverlayRef, } from './index'; -import {getAttachedOverlays} from './overlay-ref'; +import {attachedOverlays} from './overlay-ref'; describe('Overlay', () => { let injector: Injector; @@ -479,26 +481,150 @@ describe('Overlay', () => { expect(document.querySelector('.cdk-overlay-pane')).toBeFalsy(); }); - it('should track when an overlay is attached and detached', () => { - const overlayRef = createOverlayRef(injector); - expect(getAttachedOverlays()).toEqual([]); + describe('attachedOverlays signal', () => { + it('should track when an overlay is attached and detached', () => { + const overlayRef = createOverlayRef(injector); + expect(attachedOverlays()).toEqual([]); - overlayRef.attach(componentPortal); - expect(getAttachedOverlays()).toEqual([overlayRef]); + overlayRef.attach(componentPortal); + expect(attachedOverlays()).toEqual([overlayRef]); - overlayRef.detach(); - expect(getAttachedOverlays()).toEqual([]); - }); + overlayRef.detach(); + expect(attachedOverlays()).toEqual([]); + }); - it('should track when an overlay is attached and disposed', () => { - const overlayRef = createOverlayRef(injector); - expect(getAttachedOverlays()).toEqual([]); + it('should track when an overlay is attached and disposed', () => { + const overlayRef = createOverlayRef(injector); + expect(attachedOverlays()).toEqual([]); - overlayRef.attach(componentPortal); - expect(getAttachedOverlays()).toEqual([overlayRef]); + overlayRef.attach(componentPortal); + expect(attachedOverlays()).toEqual([overlayRef]); - overlayRef.dispose(); - expect(getAttachedOverlays()).toEqual([]); + overlayRef.dispose(); + expect(attachedOverlays()).toEqual([]); + }); + + it('should update computed signals derived from the attached overlays', () => { + const count = computed(() => attachedOverlays().length); + const first = createOverlayRef(injector); + const second = createOverlayRef(injector); + expect(count()).toBe(0); + + first.attach(componentPortal); + expect(count()).toBe(1); + + second.attach(templatePortal); + expect(count()).toBe(2); + + first.detach(); + expect(count()).toBe(1); + + second.dispose(); + expect(count()).toBe(0); + }); + + it('should preserve the attachment order', () => { + const first = createOverlayRef(injector); + const second = createOverlayRef(injector); + + first.attach(componentPortal); + second.attach(templatePortal); + expect(attachedOverlays()).toEqual([first, second]); + + first.detach(); + first.attach(componentPortal); + expect(attachedOverlays()).toEqual([second, first]); + + first.dispose(); + second.dispose(); + }); + + it('should re-run effects when overlays are attached and detached', () => { + const spy = jasmine.createSpy('effect spy'); + const effectRef = effect(() => spy(attachedOverlays()), {injector}); + const overlayRef = createOverlayRef(injector); + + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith([]); + + overlayRef.attach(componentPortal); + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith([overlayRef]); + + overlayRef.detach(); + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledWith([]); + + overlayRef.attach(componentPortal); + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledWith([overlayRef]); + + overlayRef.dispose(); + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(5); + expect(spy).toHaveBeenCalledWith([]); + + effectRef.destroy(); + }); + + it('should not throw when attaching and detaching inside a computed', () => { + const overlayRef = createOverlayRef(injector); + const trigger = signal(false); + const attached = computed(() => { + if (trigger()) { + overlayRef.attach(componentPortal); + } else { + overlayRef.detach(); + } + return overlayRef.hasAttached(); + }); + + expect(() => attached()).not.toThrow(); + expect(attached()).toBe(false); + + trigger.set(true); + expect(() => attached()).not.toThrow(); + expect(attached()).toBe(true); + expect(attachedOverlays()).toEqual([overlayRef]); + + trigger.set(false); + expect(() => attached()).not.toThrow(); + expect(attached()).toBe(false); + expect(attachedOverlays()).toEqual([]); + }); + + it('should not make effects that attach overlays depend on the attached overlays', () => { + const spy = jasmine.createSpy('effect spy'); + const overlayRef = createOverlayRef(injector); + const otherOverlayRef = createOverlayRef(injector); + + const effectRef = effect( + () => { + spy(); + overlayRef.attach(componentPortal); + }, + {injector}, + ); + + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(1); + expect(attachedOverlays()).toEqual([overlayRef]); + + // Changing the attached overlays shouldn't cause the effect to re-run, + // because the write inside `attach` is untracked. + otherOverlayRef.attach(templatePortal); + TestBed.tick(); + otherOverlayRef.dispose(); + TestBed.tick(); + expect(spy).toHaveBeenCalledTimes(1); + + effectRef.destroy(); + overlayRef.dispose(); + }); }); describe('positioning', () => {