Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/cdk/overlay/overlay-directives.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
ApplicationRef,
Component,
ElementRef,
Injector,
signal,
ViewChild,
viewChild,
WritableSignal,
ChangeDetectionStrategy,
} from '@angular/core';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -847,3 +872,25 @@ class ConnectedOverlayPropertyInitOrder {
@ViewChild(CdkConnectedOverlay) connectedOverlayDirective!: CdkConnectedOverlay;
@ViewChild('trigger') trigger!: CdkOverlayOrigin;
}

@Component({
template: `
@if (showTrigger()) {
<button #trigger>Toggle</button>
}

<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger()!"
[cdkConnectedOverlayOpen]="isOpen">
<p>Menu content</p>
</ng-template>
`,
imports: [OverlayModule],
})
class ConnectedOverlayOriginDisappearsTest {
@ViewChild(CdkConnectedOverlay) connectedOverlayDirective!: CdkConnectedOverlay;
trigger = viewChild<ElementRef<HTMLButtonElement>>('trigger');
showTrigger = signal(true);
isOpen = true;
}
37 changes: 31 additions & 6 deletions src/cdk/overlay/position/flexible-connected-position-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expectation is that the consumer should call dispose when the trigger is destroyed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought that too at first, but I don't think it actually works here.

The problem is CdkConnectedOverlay calls apply() itself, right when its own origin input changes to undefined (e.g. the trigger disappears behind an *ngIf), and open is still true at that point:

if (changes['origin'] && this.open) {
  this._position.apply();
}

It all happens in the same change detection cycle, so there's no real point where the consumer could've called dispose() in time — CDK gets there first.

Also, open being true isn't really a mistake on the consumer's side — the overlay is supposed to still be open, its trigger just happened to not exist for a moment. Forcing people to dispose every time that happens seems like it'd cause more harm than good (overlays closing themselves any time their trigger re-renders).

Let me know if you'd rather I move the guard into CdkConnectedOverlay instead of apply(), but I don't think "should've disposed" is something the consumer could actually act on here.

return;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
Loading