Skip to content

Commit 7f9f62a

Browse files
committed
fix(cdk/dialog): not emitting closed event on external detachments (#26608)
Fixes that the CDK dialog wasn't emitting to the `closed` event when it is detached externally, e.g. by a scroll strategy or a navigation. We had unit tests for this on the Material side, but we had special logic to handle it there. Fixes #26581. (cherry picked from commit 8f29413)
1 parent f53c6c0 commit 7f9f62a

File tree

7 files changed

+66
-6
lines changed

7 files changed

+66
-6
lines changed

src/cdk/dialog/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ ng_test_library(
4545
"//src/cdk/testing/private",
4646
"@npm//@angular/common",
4747
"@npm//@angular/platform-browser",
48+
"@npm//rxjs",
4849
],
4950
)
5051

src/cdk/dialog/dialog-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
132132
*/
133133
closeOnDestroy?: boolean = true;
134134

135+
/**
136+
* Whether the dialog should close when the underlying overlay is detached. This is useful if
137+
* another service is wrapping the dialog and is managing the destruction instead. E.g. an
138+
* external detachment can happen as a result of a scroll strategy triggering it or when the
139+
* browser location changes.
140+
*/
141+
closeOnOverlayDetachments?: boolean = true;
142+
135143
/** Alternate `ComponentFactoryResolver` to use when resolving the associated component. */
136144
componentFactoryResolver?: ComponentFactoryResolver;
137145

src/cdk/dialog/dialog-ref.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {OverlayRef} from '@angular/cdk/overlay';
1010
import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes';
11-
import {Observable, Subject} from 'rxjs';
11+
import {Observable, Subject, Subscription} from 'rxjs';
1212
import {DialogConfig} from './dialog-config';
1313
import {FocusOrigin} from '@angular/cdk/a11y';
1414
import {BasePortalOutlet} from '@angular/cdk/portal';
@@ -50,6 +50,9 @@ export class DialogRef<R = unknown, C = unknown> {
5050
/** Unique ID for the dialog. */
5151
readonly id: string;
5252

53+
/** Subscription to external detachments of the dialog. */
54+
private _detachSubscription: Subscription;
55+
5356
constructor(
5457
readonly overlayRef: OverlayRef,
5558
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>,
@@ -72,6 +75,13 @@ export class DialogRef<R = unknown, C = unknown> {
7275
this.close(undefined, {focusOrigin: 'mouse'});
7376
}
7477
});
78+
79+
this._detachSubscription = overlayRef.detachments().subscribe(() => {
80+
// Check specifically for `false`, because we want `undefined` to be treated like `true`.
81+
if (config.closeOnOverlayDetachments !== false) {
82+
this.close();
83+
}
84+
});
7585
}
7686

7787
/**
@@ -83,6 +93,9 @@ export class DialogRef<R = unknown, C = unknown> {
8393
if (this.containerInstance) {
8494
const closedSubject = this.closed as Subject<R | undefined>;
8595
this.containerInstance._closeInteractionType = options?.focusOrigin || 'program';
96+
// Drop the detach subscription first since it can be triggered by the
97+
// `dispose` call and override the result of this closing sequence.
98+
this._detachSubscription.unsubscribe();
8699
this.overlayRef.dispose();
87100
closedSubject.next(result);
88101
closedSubject.complete();

src/cdk/dialog/dialog.spec.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ import {By} from '@angular/platform-browser';
2323
import {Location} from '@angular/common';
2424
import {SpyLocation} from '@angular/common/testing';
2525
import {Directionality} from '@angular/cdk/bidi';
26-
import {Overlay, OverlayContainer} from '@angular/cdk/overlay';
26+
import {Overlay, OverlayContainer, ScrollDispatcher} from '@angular/cdk/overlay';
2727
import {A, ESCAPE} from '@angular/cdk/keycodes';
2828
import {_supportsShadowDom} from '@angular/cdk/platform';
2929
import {
3030
dispatchKeyboardEvent,
3131
createKeyboardEvent,
3232
dispatchEvent,
3333
} from '@angular/cdk/testing/private';
34+
import {Subject} from 'rxjs';
3435
import {DIALOG_DATA, Dialog, DialogModule, DialogRef} from './index';
3536

3637
describe('Dialog', () => {
@@ -41,6 +42,7 @@ describe('Dialog', () => {
4142
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
4243
let mockLocation: SpyLocation;
4344
let overlay: Overlay;
45+
let scrolledSubject = new Subject();
4446

4547
beforeEach(fakeAsync(() => {
4648
TestBed.configureTestingModule({
@@ -59,6 +61,10 @@ describe('Dialog', () => {
5961
providers: [
6062
{provide: Location, useClass: SpyLocation},
6163
{provide: TEMPLATE_INJECTOR_TEST_TOKEN, useValue: 'hello from test module'},
64+
{
65+
provide: ScrollDispatcher,
66+
useFactory: () => ({scrolled: () => scrolledSubject}),
67+
},
6268
],
6369
});
6470

@@ -504,24 +510,28 @@ describe('Dialog', () => {
504510
}));
505511

506512
it('should close all dialogs when the user goes forwards/backwards in history', fakeAsync(() => {
507-
dialog.open(PizzaMsg);
513+
const closeSpy = jasmine.createSpy('closed');
514+
dialog.open(PizzaMsg).closed.subscribe(closeSpy);
508515
viewContainerFixture.detectChanges();
509-
dialog.open(PizzaMsg);
516+
dialog.open(PizzaMsg).closed.subscribe(closeSpy);
510517
viewContainerFixture.detectChanges();
511518

512519
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2);
520+
expect(closeSpy).not.toHaveBeenCalled();
513521

514522
mockLocation.simulateUrlPop('');
515523
viewContainerFixture.detectChanges();
516524
flush();
517525

518526
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0);
527+
expect(closeSpy).toHaveBeenCalledTimes(2);
519528
}));
520529

521530
it('should close all open dialogs when the location hash changes', fakeAsync(() => {
522-
dialog.open(PizzaMsg);
531+
const closeSpy = jasmine.createSpy('closed');
532+
dialog.open(PizzaMsg).closed.subscribe(closeSpy);
523533
viewContainerFixture.detectChanges();
524-
dialog.open(PizzaMsg);
534+
dialog.open(PizzaMsg).closed.subscribe(closeSpy);
525535
viewContainerFixture.detectChanges();
526536

527537
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2);
@@ -533,6 +543,28 @@ describe('Dialog', () => {
533543
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0);
534544
}));
535545

546+
it('should close the dialog when detached externally', fakeAsync(() => {
547+
const closeSpy = jasmine.createSpy('closed');
548+
dialog
549+
.open(PizzaMsg, {scrollStrategy: overlay.scrollStrategies.close()})
550+
.closed.subscribe(closeSpy);
551+
viewContainerFixture.detectChanges();
552+
dialog
553+
.open(PizzaMsg, {scrollStrategy: overlay.scrollStrategies.close()})
554+
.closed.subscribe(closeSpy);
555+
viewContainerFixture.detectChanges();
556+
557+
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2);
558+
expect(closeSpy).not.toHaveBeenCalled();
559+
560+
scrolledSubject.next();
561+
viewContainerFixture.detectChanges();
562+
flush();
563+
564+
expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0);
565+
expect(closeSpy).toHaveBeenCalledTimes(2);
566+
}));
567+
536568
it('should have the componentInstance available in the afterClosed callback', fakeAsync(() => {
537569
let dialogRef = dialog.open(PizzaMsg);
538570
let spy = jasmine.createSpy('afterClosed spy');

src/material/bottom-sheet/bottom-sheet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export class MatBottomSheet implements OnDestroy {
9595
..._config,
9696
// Disable closing since we need to sync it up to the animation ourselves.
9797
disableClose: true,
98+
// Disable closing on detachments so that we can sync up the animation.
99+
closeOnOverlayDetachments: false,
98100
maxWidth: '100%',
99101
container: MatBottomSheetContainer,
100102
scrollStrategy: _config.scrollStrategy || this._overlay.scrollStrategies.block(),

src/material/dialog/dialog.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ export abstract class _MatDialogBase<C extends _MatDialogContainerBase> implemen
170170
// We want to do the cleanup here, rather than the CDK service, because the CDK destroys
171171
// the dialogs immediately whereas we want it to wait for the animations to finish.
172172
closeOnDestroy: false,
173+
// Disable closing on detachments so that we can sync up the animation.
174+
// The Material dialog ref handles this manually.
175+
closeOnOverlayDetachments: false,
173176
container: {
174177
type: this._dialogContainerType,
175178
providers: () => [

tools/public_api_guard/cdk/dialog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
127127
backdropClass?: string | string[];
128128
closeOnDestroy?: boolean;
129129
closeOnNavigation?: boolean;
130+
closeOnOverlayDetachments?: boolean;
130131
componentFactoryResolver?: ComponentFactoryResolver;
131132
container?: Type<C> | {
132133
type: Type<C>;

0 commit comments

Comments
 (0)