Skip to content

Commit 4b228ab

Browse files
committed
fix(cdk/overlay): use MutationObserver to detach overlay
1 parent d4cccb7 commit 4b228ab

File tree

5 files changed

+59
-69
lines changed

5 files changed

+59
-69
lines changed

.husky/commit-msg

-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,2 @@
1-
#!/bin/sh
2-
# commit-msg git hook, see https://typicode.github.io/husky/#/ for more on how husky works.
3-
. "$(dirname $0)/_/husky.sh"
4-
51
# Ahead of creating the commit, validate the commit message.
62
yarn -s ng-dev commit-message pre-commit-validate --file $1;

.husky/pre-commit

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
#!/bin/sh
2-
. "$(dirname "$0")/_/husky.sh"
3-
41
# Allow for the formatting command to fail without exiting the script.
52
set +e
63

74
yarn -s ng-dev format staged 2>/dev/null
85

96
if [ $? -ne 0 ]; then
107
echo "WARNING: failed to run file formatting (ng-dev format staged)"
11-
fi
8+
fi

.husky/prepare-commit-msg

-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,2 @@
1-
#!/bin/sh
2-
# prepare-commit-msg git hook, see https://typicode.github.io/husky/#/ for more on how husky works.
3-
. "$(dirname $0)/_/husky.sh"
4-
51
# When a commit is started, restore the previous commit message draft if it exists.
62
yarn -s ng-dev commit-message restore-commit-message-draft $1 $2;

src/cdk/overlay/overlay-ref.ts

+45-53
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Direction, Directionality} from '../bidi';
10-
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '../portal';
9+
import {Location} from '@angular/common';
1110
import {
1211
AfterRenderRef,
1312
ComponentRef,
@@ -16,19 +15,17 @@ import {
1615
NgZone,
1716
Renderer2,
1817
afterNextRender,
19-
afterRender,
20-
untracked,
2118
} from '@angular/core';
22-
import {Location} from '@angular/common';
23-
import {Observable, Subject, merge, SubscriptionLike, Subscription} from 'rxjs';
24-
import {takeUntil} from 'rxjs/operators';
19+
import {Observable, Subject, Subscription, SubscriptionLike} from 'rxjs';
20+
import {Direction, Directionality} from '../bidi';
21+
import {coerceArray, coerceCssPixelValue} from '../coercion';
22+
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '../portal';
23+
import {BackdropRef} from './backdrop-ref';
2524
import {OverlayKeyboardDispatcher} from './dispatchers/overlay-keyboard-dispatcher';
2625
import {OverlayOutsideClickDispatcher} from './dispatchers/overlay-outside-click-dispatcher';
2726
import {OverlayConfig} from './overlay-config';
28-
import {coerceCssPixelValue, coerceArray} from '../coercion';
2927
import {PositionStrategy} from './position/position-strategy';
3028
import {ScrollStrategy} from './scroll';
31-
import {BackdropRef} from './backdrop-ref';
3229

3330
/** An object where all of its properties cannot be written. */
3431
export type ImmutableObject<T> = {
@@ -47,6 +44,8 @@ export class OverlayRef implements PortalOutlet {
4744
private _scrollStrategy: ScrollStrategy | undefined;
4845
private _locationChanges: SubscriptionLike = Subscription.EMPTY;
4946
private _backdropRef: BackdropRef | null = null;
47+
private _detachContentMutationObserver: MutationObserver | undefined;
48+
private _detachContentAfterRenderRef: AfterRenderRef | undefined;
5049

5150
/**
5251
* Reference to the parent of the `_host` at the time it was detached. Used to restore
@@ -60,10 +59,6 @@ export class OverlayRef implements PortalOutlet {
6059
/** Stream of mouse outside events dispatched to this overlay. */
6160
readonly _outsidePointerEvents = new Subject<MouseEvent>();
6261

63-
private _renders = new Subject<void>();
64-
65-
private _afterRenderRef: AfterRenderRef;
66-
6762
/** Reference to the currently-running `afterNextRender` call. */
6863
private _afterNextRenderRef: AfterRenderRef | undefined;
6964

@@ -87,18 +82,6 @@ export class OverlayRef implements PortalOutlet {
8782
}
8883

8984
this._positionStrategy = _config.positionStrategy;
90-
91-
// Users could open the overlay from an `effect`, in which case we need to
92-
// run the `afterRender` as `untracked`. We don't recommend that users do
93-
// this, but we also don't want to break users who are doing it.
94-
this._afterRenderRef = untracked(() =>
95-
afterRender(
96-
() => {
97-
this._renders.next();
98-
},
99-
{injector: this._injector},
100-
),
101-
);
10285
}
10386

10487
/** The overlay's HTML element */
@@ -182,6 +165,7 @@ export class OverlayRef implements PortalOutlet {
182165

183166
// Only emit the `attachments` event once all other setup is done.
184167
this._attachments.next();
168+
this._completeDetachContent();
185169

186170
// Track this overlay by the keyboard dispatcher
187171
this._keyboardDispatcher.add(this);
@@ -242,6 +226,7 @@ export class OverlayRef implements PortalOutlet {
242226

243227
// Only emit after everything is detached.
244228
this._detachments.next();
229+
this._completeDetachContent();
245230

246231
// Remove this overlay from keyboard dispatcher tracking.
247232
this._keyboardDispatcher.remove(this);
@@ -281,8 +266,7 @@ export class OverlayRef implements PortalOutlet {
281266
}
282267

283268
this._detachments.complete();
284-
this._afterRenderRef.destroy();
285-
this._renders.complete();
269+
this._completeDetachContent();
286270
}
287271

288272
/** Whether the overlay has attached content. */
@@ -488,34 +472,42 @@ export class OverlayRef implements PortalOutlet {
488472
}
489473
}
490474

491-
/** Detaches the overlay content next time the zone stabilizes. */
475+
/** Detaches the overlay once the content finishes animating and is removed from the DOM. */
492476
private _detachContentWhenEmpty() {
493-
// Normally we wouldn't have to explicitly run this outside the `NgZone`, however
494-
// if the consumer is using `zone-patch-rxjs`, the `Subscription.unsubscribe` call will
495-
// be patched to run inside the zone, which will throw us into an infinite loop.
496-
this._ngZone.runOutsideAngular(() => {
497-
// We can't remove the host here immediately, because the overlay pane's content
498-
// might still be animating. This stream helps us avoid interrupting the animation
499-
// by waiting for the pane to become empty.
500-
const subscription = this._renders
501-
.pipe(takeUntil(merge(this._attachments, this._detachments)))
502-
.subscribe(() => {
503-
// Needs a couple of checks for the pane and host, because
504-
// they may have been removed by the time the zone stabilizes.
505-
if (!this._pane || !this._host || this._pane.children.length === 0) {
506-
if (this._pane && this._config.panelClass) {
507-
this._toggleClasses(this._pane, this._config.panelClass, false);
508-
}
509-
510-
if (this._host && this._host.parentElement) {
511-
this._previousHostParent = this._host.parentElement;
512-
this._host.remove();
513-
}
514-
515-
subscription.unsubscribe();
516-
}
517-
});
477+
// Attempt to detach on the next render.
478+
this._detachContentAfterRenderRef = afterNextRender(() => this._detachContent(), {
479+
injector: this._injector,
518480
});
481+
// Otherwise wait until the content finishes animating out and detach.
482+
if (globalThis.MutationObserver && this._pane) {
483+
this._detachContentMutationObserver ||= new globalThis.MutationObserver(() => {
484+
this._detachContent();
485+
});
486+
this._detachContentMutationObserver.observe(this._pane, {childList: true});
487+
}
488+
}
489+
490+
private _detachContent() {
491+
// Needs a couple of checks for the pane and host, because
492+
// they may have been removed by the time the zone stabilizes.
493+
if (!this._pane || !this._host || this._pane.children.length === 0) {
494+
if (this._pane && this._config.panelClass) {
495+
this._toggleClasses(this._pane, this._config.panelClass, false);
496+
}
497+
498+
if (this._host && this._host.parentElement) {
499+
this._previousHostParent = this._host.parentElement;
500+
this._host.remove();
501+
}
502+
503+
this._completeDetachContent();
504+
}
505+
}
506+
507+
private _completeDetachContent() {
508+
this._detachContentAfterRenderRef?.destroy();
509+
this._detachContentAfterRenderRef = undefined;
510+
this._detachContentMutationObserver?.disconnect();
519511
}
520512

521513
/** Disposes of a scroll strategy. */

src/cdk/overlay/overlay.spec.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import {Direction, Directionality} from '../bidi';
2-
import {CdkPortal, ComponentPortal, TemplatePortal} from '../portal';
31
import {Location} from '@angular/common';
42
import {SpyLocation} from '@angular/common/testing';
53
import {
@@ -21,6 +19,8 @@ import {
2119
waitForAsync,
2220
} from '@angular/core/testing';
2321
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
22+
import {Direction, Directionality} from '../bidi';
23+
import {CdkPortal, ComponentPortal, TemplatePortal} from '../portal';
2424
import {dispatchFakeEvent} from '../testing/private';
2525
import {
2626
Overlay,
@@ -380,8 +380,9 @@ describe('Overlay', () => {
380380
expect(overlayRef.getDirection()).toBe('ltr');
381381
});
382382

383-
it('should add and remove the overlay host as the ref is being attached and detached', () => {
383+
it('should add and remove the overlay host as the ref is being attached and detached', async () => {
384384
const overlayRef = overlay.create();
385+
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
385386

386387
overlayRef.attach(componentPortal);
387388
viewContainerFixture.detectChanges();
@@ -390,13 +391,17 @@ describe('Overlay', () => {
390391
.withContext('Expected host element to be in the DOM.')
391392
.toBeTruthy();
392393

394+
// Simulate animating element that hasn't been removed yet.
395+
pane.appendChild(document.createElement('div'));
393396
overlayRef.detach();
394397

395398
expect(overlayRef.hostElement.parentElement)
396399
.withContext('Expected host element not to have been removed immediately.')
397400
.toBeTruthy();
398401

399402
viewContainerFixture.detectChanges();
403+
pane.children[0].remove();
404+
await new Promise(r => setTimeout(r));
400405

401406
expect(overlayRef.hostElement.parentElement)
402407
.withContext('Expected host element to have been removed once the zone stabilizes.')
@@ -922,7 +927,7 @@ describe('Overlay', () => {
922927
expect(pane.classList).toContain('custom-class-two');
923928
});
924929

925-
it('should remove the custom panel class when the overlay is detached', () => {
930+
it('should remove the custom panel class when the overlay is detached', async () => {
926931
const config = new OverlayConfig({panelClass: 'custom-panel-class'});
927932
const overlayRef = overlay.create(config);
928933

@@ -957,12 +962,16 @@ describe('Overlay', () => {
957962
.withContext('Expected class to be added')
958963
.toContain('custom-panel-class');
959964

965+
// Simulate animating element that hasn't been removed yet.
966+
pane.appendChild(document.createElement('div'));
960967
overlayRef.detach();
961968
expect(pane.classList)
962969
.withContext('Expected class not to be removed immediately')
963970
.toContain('custom-panel-class');
964971
await viewContainerFixture.whenStable();
965972

973+
pane.children[0].remove();
974+
await new Promise(r => setTimeout(r));
966975
expect(pane.classList)
967976
.not.withContext('Expected class to be removed on stable')
968977
.toContain('custom-panel-class');

0 commit comments

Comments
 (0)