Skip to content

Commit 30f5181

Browse files
authored
fix(material-experimental/mdc-snack-bar): avoid multiple snack bars on the page if opened in quick succession (#24757)
Fixes an issue where opening a snack bar while the previous one was being animated caused the former to remain in the DOM. The problem was that MDC always waits for an animation, even if it is interrupted.
1 parent f86faf5 commit 30f5181

File tree

4 files changed

+68
-23
lines changed

4 files changed

+68
-23
lines changed

src/material-experimental/mdc-snack-bar/snack-bar-container.ts

+29-14
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from '@angular/core';
2929
import {MatSnackBarConfig, _SnackBarContainer} from '@angular/material/snack-bar';
3030
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
31-
import {MDCSnackbarAdapter, MDCSnackbarFoundation} from '@material/snackbar';
31+
import {MDCSnackbarAdapter, MDCSnackbarFoundation, cssClasses} from '@material/snackbar';
3232
import {Platform} from '@angular/cdk/platform';
3333
import {Observable, Subject} from 'rxjs';
3434

@@ -97,10 +97,7 @@ export class MatSnackBarContainer
9797
addClass: (className: string) => this._setClass(className, true),
9898
removeClass: (className: string) => this._setClass(className, false),
9999
announce: () => {},
100-
notifyClosed: () => {
101-
this._onExit.next();
102-
this._mdcFoundation.destroy();
103-
},
100+
notifyClosed: () => this._finishExit(),
104101
notifyClosing: () => {},
105102
notifyOpened: () => this._onEnter.next(),
106103
notifyOpening: () => {},
@@ -172,16 +169,24 @@ export class MatSnackBarContainer
172169
}
173170

174171
exit(): Observable<void> {
175-
// It's common for snack bars to be opened by random outside calls like HTTP requests or
176-
// errors. Run inside the NgZone to ensure that it functions correctly.
177-
this._ngZone.run(() => {
178-
this._exiting = true;
179-
this._mdcFoundation.close();
172+
const classList = this._elementRef.nativeElement.classList;
173+
174+
// MDC won't complete the closing sequence if it starts while opening hasn't finished.
175+
// If that's the case, destroy immediately to ensure that our stream emits as expected.
176+
if (classList.contains(cssClasses.OPENING) || !classList.contains(cssClasses.OPEN)) {
177+
this._finishExit();
178+
} else {
179+
// It's common for snack bars to be opened by random outside calls like HTTP requests or
180+
// errors. Run inside the NgZone to ensure that it functions correctly.
181+
this._ngZone.run(() => {
182+
this._exiting = true;
183+
this._mdcFoundation.close();
184+
});
185+
}
180186

181-
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
182-
// long enough to visually read it either, so clear the timeout for announcing.
183-
clearTimeout(this._announceTimeoutId);
184-
});
187+
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
188+
// long enough to visually read it either, so clear the timeout for announcing.
189+
clearTimeout(this._announceTimeoutId);
185190

186191
return this._onExit;
187192
}
@@ -228,6 +233,16 @@ export class MatSnackBarContainer
228233
}
229234
}
230235

236+
/** Finishes the exit sequence of the container. */
237+
private _finishExit() {
238+
this._onExit.next();
239+
this._onExit.complete();
240+
241+
if (this._platform.isBrowser) {
242+
this._mdcFoundation.destroy();
243+
}
244+
}
245+
231246
/**
232247
* Starts a timeout to move the snack bar content to the live region so screen readers will
233248
* announce it.

src/material-experimental/mdc-snack-bar/snack-bar.spec.ts

+22-5
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,16 @@ describe('MatSnackBar', () => {
288288

289289
let snackBarRef = snackBar.open(simpleMessage, undefined, config);
290290
viewContainerFixture.detectChanges();
291+
flush();
291292
expect(overlayContainerElement.childElementCount)
292293
.withContext('Expected overlay container element to have at least one child')
293294
.toBeGreaterThan(0);
294295

295296
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
297+
const messageElement = overlayContainerElement.querySelector('mat-snack-bar-container')!;
296298

297299
snackBarRef.dismiss();
298300
viewContainerFixture.detectChanges();
299-
const messageElement = overlayContainerElement.querySelector('mat-snack-bar-container')!;
300301
expect(messageElement.hasAttribute('mat-exit'))
301302
.withContext('Expected the snackbar container to have the "exit" attribute upon dismiss')
302303
.toBe(true);
@@ -412,23 +413,29 @@ describe('MatSnackBar', () => {
412413
}));
413414

414415
it('should dismiss the snackbar when the action is called, notifying of both action and dismiss', fakeAsync(() => {
416+
const dismissNextSpy = jasmine.createSpy('dismiss next spy');
415417
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');
418+
const actionNextSpy = jasmine.createSpy('action next spy');
416419
const actionCompleteSpy = jasmine.createSpy('action complete spy');
417420
const snackBarRef = snackBar.open('Some content', 'Dismiss');
418421
viewContainerFixture.detectChanges();
419422

420-
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
421-
snackBarRef.onAction().subscribe({complete: actionCompleteSpy});
423+
snackBarRef.afterDismissed().subscribe({next: dismissNextSpy, complete: dismissCompleteSpy});
424+
snackBarRef.onAction().subscribe({next: actionNextSpy, complete: actionCompleteSpy});
422425

423-
let actionButton = overlayContainerElement.querySelector(
426+
const actionButton = overlayContainerElement.querySelector(
424427
'button.mat-mdc-button',
425428
) as HTMLButtonElement;
426429
actionButton.click();
427430
viewContainerFixture.detectChanges();
428-
flush();
431+
tick();
429432

433+
expect(dismissNextSpy).toHaveBeenCalled();
430434
expect(dismissCompleteSpy).toHaveBeenCalled();
435+
expect(actionNextSpy).toHaveBeenCalled();
431436
expect(actionCompleteSpy).toHaveBeenCalled();
437+
438+
tick(500);
432439
}));
433440

434441
it('should allow manually dismissing with an action', fakeAsync(() => {
@@ -587,6 +594,16 @@ describe('MatSnackBar', () => {
587594
flush();
588595
}));
589596

597+
it('should only keep one snack bar in the DOM if multiple are opened at the same time', fakeAsync(() => {
598+
for (let i = 0; i < 10; i++) {
599+
snackBar.open('Snack time!', 'Chew');
600+
viewContainerFixture.detectChanges();
601+
}
602+
603+
flush();
604+
expect(overlayContainerElement.querySelectorAll('mat-snack-bar-container').length).toBe(1);
605+
}));
606+
590607
describe('with custom component', () => {
591608
it('should open a custom component', () => {
592609
const snackBarRef = snackBar.openFromComponent(BurritosNotification);

src/material/snack-bar/snack-bar-ref.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ export class MatSnackBarRef<T> {
5252

5353
constructor(containerInstance: _SnackBarContainer, private _overlayRef: OverlayRef) {
5454
this.containerInstance = containerInstance;
55-
// Dismiss snackbar on action.
56-
this.onAction().subscribe(() => this.dismiss());
5755
containerInstance._onExit.subscribe(() => this._finishDismiss());
5856
}
5957

@@ -71,6 +69,7 @@ export class MatSnackBarRef<T> {
7169
this._dismissedByAction = true;
7270
this._onAction.next();
7371
this._onAction.complete();
72+
this.dismiss();
7473
}
7574
clearTimeout(this._durationTimeoutId);
7675
}

src/material/snack-bar/snack-bar.spec.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -465,13 +465,15 @@ describe('MatSnackBar', () => {
465465
}));
466466

467467
it('should dismiss the snackbar when the action is called, notifying of both action and dismiss', fakeAsync(() => {
468+
const dismissNextSpy = jasmine.createSpy('dismiss next spy');
468469
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');
470+
const actionNextSpy = jasmine.createSpy('action next spy');
469471
const actionCompleteSpy = jasmine.createSpy('action complete spy');
470472
const snackBarRef = snackBar.open('Some content', 'Dismiss');
471473
viewContainerFixture.detectChanges();
472474

473-
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
474-
snackBarRef.onAction().subscribe({complete: actionCompleteSpy});
475+
snackBarRef.afterDismissed().subscribe({next: dismissNextSpy, complete: dismissCompleteSpy});
476+
snackBarRef.onAction().subscribe({next: actionNextSpy, complete: actionCompleteSpy});
475477

476478
const actionButton = overlayContainerElement.querySelector(
477479
'button.mat-button',
@@ -480,7 +482,9 @@ describe('MatSnackBar', () => {
480482
viewContainerFixture.detectChanges();
481483
tick();
482484

485+
expect(dismissNextSpy).toHaveBeenCalled();
483486
expect(dismissCompleteSpy).toHaveBeenCalled();
487+
expect(actionNextSpy).toHaveBeenCalled();
484488
expect(actionCompleteSpy).toHaveBeenCalled();
485489

486490
tick(500);
@@ -651,6 +655,16 @@ describe('MatSnackBar', () => {
651655
flush();
652656
}));
653657

658+
it('should only keep one snack bar in the DOM if multiple are opened at the same time', fakeAsync(() => {
659+
for (let i = 0; i < 10; i++) {
660+
snackBar.open('Snack time!', 'Chew');
661+
viewContainerFixture.detectChanges();
662+
}
663+
664+
flush();
665+
expect(overlayContainerElement.querySelectorAll('snack-bar-container').length).toBe(1);
666+
}));
667+
654668
describe('with custom component', () => {
655669
it('should open a custom component', () => {
656670
const snackBarRef = snackBar.openFromComponent(BurritosNotification);

0 commit comments

Comments
 (0)