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;