diff --git a/src/cdk/overlay/overlay-directives.spec.ts b/src/cdk/overlay/overlay-directives.spec.ts index 09c94ff22556..5d0adcb139dd 100644 --- a/src/cdk/overlay/overlay-directives.spec.ts +++ b/src/cdk/overlay/overlay-directives.spec.ts @@ -1,9 +1,11 @@ import { + ApplicationRef, Component, ElementRef, Injector, signal, ViewChild, + viewChild, WritableSignal, ChangeDetectionStrategy, } from '@angular/core'; @@ -219,6 +221,29 @@ describe('Overlay directives', () => { }).not.toThrow(); }); + it('should not throw when the origin disappears behind an `@if` while the overlay is still open', () => { + fixture.destroy(); + + const originFixture = TestBed.createComponent(ConnectedOverlayOriginDisappearsTest); + // The initial render legitimately settles the `viewChild()` signal one tick after the + // template bindings that read it, which trips Angular's own (unrelated) + // `ExpressionChangedAfterItHasBeenChecked` check; skip that verification pass here so it + // doesn't mask the actual thing under test below. + originFixture.detectChanges(false); + + expect(overlayContainerElement.textContent).toContain('Menu content'); + + // Mirrors a real app where the trigger sits behind `*ngIf`/`@if` and disappears (route + // navigation, parent condition flipping) while `[cdkConnectedOverlayOpen]` is still `true` + // for that same change-detection pass. `CdkConnectedOverlay.ngOnChanges` reacts to the + // `origin` input becoming `undefined` by calling `setOrigin(undefined)` and, since `open` + // is still `true`, synchronously calling `_position.apply()`. + originFixture.componentInstance.showTrigger.set(false); + + expect(() => TestBed.inject(ApplicationRef).tick()).not.toThrow(); + expect(originFixture.componentInstance.trigger()).toBeUndefined(); + }); + describe('inputs', () => { it('should set the width', () => { fixture.componentInstance.width = 250; @@ -847,3 +872,25 @@ class ConnectedOverlayPropertyInitOrder { @ViewChild(CdkConnectedOverlay) connectedOverlayDirective!: CdkConnectedOverlay; @ViewChild('trigger') trigger!: CdkOverlayOrigin; } + +@Component({ + template: ` + @if (showTrigger()) { + + } + + +

Menu content

+
+ `, + imports: [OverlayModule], +}) +class ConnectedOverlayOriginDisappearsTest { + @ViewChild(CdkConnectedOverlay) connectedOverlayDirective!: CdkConnectedOverlay; + trigger = viewChild>('trigger'); + showTrigger = signal(true); + isOpen = true; +} diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts index 897e205915dc..4b1fd74ccd63 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -65,9 +65,7 @@ export function createFlexibleConnectedPositionStrategy( /** Supported locations in the DOM for connected overlays. */ export type FlexibleOverlayPopoverLocation = - | 'global' - | 'inline' - | {type: 'parent'; element: Element}; + 'global' | 'inline' | {type: 'parent'; element: Element}; /** * A strategy for positioning overlays. Using this strategy, an overlay is given an @@ -230,8 +228,10 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { * @docs-private */ apply(): void { - // We shouldn't do anything if the strategy was disposed or we're on the server. - if (this._isDisposed || !this._platform.isBrowser) { + // We shouldn't do anything if the strategy was disposed, we're on the server, or the + // origin is no longer usable (e.g. it was removed from the view while the overlay + // hasn't been disposed of yet). There's nothing meaningful to position against in that case. + if (this._isDisposed || !this._platform.isBrowser || !this._isOriginUsable()) { return; } @@ -391,7 +391,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { * allows one to re-align the panel without changing the orientation of the panel. */ reapplyLastPosition(): void { - if (this._isDisposed || !this._platform.isBrowser) { + if (this._isDisposed || !this._platform.isBrowser || !this._isOriginUsable()) { return; } @@ -1271,6 +1271,31 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { return this._viewportMargin?.bottom ?? 0; } + /** + * Checks whether the current origin can actually be measured. The origin can become + * unusable while the overlay is still open and hasn't been disposed of yet, e.g. an + * `ElementRef` whose `nativeElement` was cleared out, or a virtual (point) origin + * whose owner set it to `null`/`undefined` via `setOrigin` after the trigger it was + * tracking disappeared. + */ + private _isOriginUsable(): boolean { + const origin = this._origin; + + if (origin == null) { + return false; + } + + if (origin instanceof ElementRef) { + return origin.nativeElement != null; + } + + if (origin instanceof Element) { + return true; + } + + return typeof origin.x === 'number' && typeof origin.y === 'number'; + } + /** Returns the DOMRect of the current origin. */ private _getOriginRect(): Dimensions { const origin = this._origin;