From 06bff69640bda08c4f622cecc242316d5e84436d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 24 Jan 2025 07:08:03 +0100 Subject: [PATCH 1/5] fix(material/snack-bar): switch away from animations module Reworks the snack bar so it animates using CSS instead of the animations module. --- .../snack-bar/snack-bar-animations.ts | 2 + .../snack-bar/snack-bar-container.scss | 30 +++++ src/material/snack-bar/snack-bar-container.ts | 103 +++++++++--------- src/material/snack-bar/snack-bar.spec.ts | 64 +---------- src/material/snack-bar/snack-bar.ts | 10 +- tools/public_api_guard/material/snack-bar.md | 8 +- 6 files changed, 94 insertions(+), 123 deletions(-) diff --git a/src/material/snack-bar/snack-bar-animations.ts b/src/material/snack-bar/snack-bar-animations.ts index 4b9486075cbe..e836f75f78c9 100644 --- a/src/material/snack-bar/snack-bar-animations.ts +++ b/src/material/snack-bar/snack-bar-animations.ts @@ -17,6 +17,8 @@ import { /** * Animations used by the Material snack bar. * @docs-private + * @deprecated No longer used, will be removed. + * @breaking-change 21.0.0 */ export const matSnackBarAnimations: { readonly snackBarState: AnimationTriggerMetadata; diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 87aecb67f3f2..332a08775f63 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -7,6 +7,28 @@ $_side-padding: 8px; +@keyframes _mat-snack-bar-enter { + from { + transform: scale(0.8); + opacity: 0; + } + + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes _mat-snack-bar-exit { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + .mat-mdc-snack-bar-container { display: flex; align-items: center; @@ -20,6 +42,14 @@ $_side-padding: 8px; } } +.mat-snack-bar-animations-enabled { + animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1); + + &[mat-exit] { + animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1); + } +} + .mat-mdc-snackbar-surface { @include elevation.elevation(6); display: flex; diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 9180b4aaefe9..9ab0c21c1aa8 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -7,8 +7,8 @@ */ import { + ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, - ChangeDetectorRef, Component, ComponentRef, ElementRef, @@ -20,7 +20,6 @@ import { ViewEncapsulation, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {matSnackBarAnimations} from './snack-bar-animations'; import { BasePortalOutlet, CdkPortalOutlet, @@ -31,7 +30,6 @@ import { import {Observable, Subject} from 'rxjs'; import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y'; import {Platform} from '@angular/cdk/platform'; -import {AnimationEvent} from '@angular/animations'; import {MatSnackBarConfig} from './snack-bar-config'; /** @@ -48,19 +46,21 @@ import {MatSnackBarConfig} from './snack-bar-config'; // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, encapsulation: ViewEncapsulation.None, - animations: [matSnackBarAnimations.snackBarState], imports: [CdkPortalOutlet], host: { 'class': 'mdc-snackbar mat-mdc-snack-bar-container', - '[@state]': '_animationState', - '(@state.done)': 'onAnimationEnd($event)', + '[class.mat-snack-bar-animations-enabled]': '!_animationsDisabled', + '(animationend)': '_animationDone($event)', }, }) export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { private _ngZone = inject(NgZone); private _elementRef = inject>(ElementRef); - private _changeDetectorRef = inject(ChangeDetectorRef); private _platform = inject(Platform); + private _enterFallback: ReturnType | undefined; + private _exitFallback: ReturnType | undefined; + protected _animationsDisabled = + inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; snackBarConfig = inject(MatSnackBarConfig); private _document = inject(DOCUMENT); @@ -70,7 +70,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy private readonly _announceDelay: number = 150; /** The timeout for announcing the snack bar's content. */ - private _announceTimeoutId: ReturnType; + private _announceTimeoutId: ReturnType | undefined; /** Whether the component has been destroyed. */ private _destroyed = false; @@ -87,9 +87,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy /** Subject for notifying that the snack bar has finished entering the view. */ readonly _onEnter: Subject = new Subject(); - /** The state of the snack bar animations. */ - _animationState = 'void'; - /** aria-live value for the live region. */ _live: AriaLivePoliteness; @@ -166,78 +163,82 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy }; /** Handle end of animations, updating the state of the snackbar. */ - onAnimationEnd(event: AnimationEvent) { - const {fromState, toState} = event; - - if ((toState === 'void' && fromState !== 'void') || toState === 'hidden') { + protected _animationDone(event: AnimationEvent) { + if (event.animationName === '_mat-snack-bar-enter') { + this._completeEnter(); + } else if (event.animationName === '_mat-snack-bar-exit') { this._completeExit(); } - - if (toState === 'visible') { - // Note: we shouldn't use `this` inside the zone callback, - // because it can cause a memory leak. - const onEnter = this._onEnter; - - this._ngZone.run(() => { - onEnter.next(); - onEnter.complete(); - }); - } } /** Begin animation of snack bar entrance into view. */ enter(): void { if (!this._destroyed) { - this._animationState = 'visible'; - // _animationState lives in host bindings and `detectChanges` does not refresh host bindings - // so we have to call `markForCheck` to ensure the host view is refreshed eventually. - this._changeDetectorRef.markForCheck(); - this._changeDetectorRef.detectChanges(); this._screenReaderAnnounce(); + + if (this._animationsDisabled) { + this._completeEnter(); + } else { + // Guarantees that the animation-related events will + // fire even if something interrupts the animation. + clearTimeout(this._enterFallback); + this._enterFallback = setTimeout(this._completeEnter, 200); + } } } /** Begin animation of the snack bar exiting from view. */ exit(): Observable { - // It's common for snack bars to be opened by random outside calls like HTTP requests or - // errors. Run inside the NgZone to ensure that it functions correctly. - this._ngZone.run(() => { - // Note: this one transitions to `hidden`, rather than `void`, in order to handle the case - // where multiple snack bars are opened in quick succession (e.g. two consecutive calls to - // `MatSnackBar.open`). - this._animationState = 'hidden'; - this._changeDetectorRef.markForCheck(); - - // Mark this element with an 'exit' attribute to indicate that the snackbar has - // been dismissed and will soon be removed from the DOM. This is used by the snackbar - // test harness. - this._elementRef.nativeElement.setAttribute('mat-exit', ''); - - // If the snack bar hasn't been announced by the time it exits it wouldn't have been open - // long enough to visually read it either, so clear the timeout for announcing. - clearTimeout(this._announceTimeoutId); - }); + // Mark this element with an 'exit' attribute to indicate that the snackbar has + // been dismissed and will soon be removed from the DOM. This is used by the snackbar + // test harness. + this._elementRef.nativeElement.setAttribute('mat-exit', ''); + + // If the snack bar hasn't been announced by the time it exits it wouldn't have been open + // long enough to visually read it either, so clear the timeout for announcing. + clearTimeout(this._announceTimeoutId); + + if (this._animationsDisabled) { + // It's common for snack bars to be opened by random outside calls like HTTP requests or + // errors. Run inside the NgZone to ensure that it functions correctly. + this._ngZone.run(this._completeExit); + } else { + // Guarantees that the animation-related events will + // fire even if something interrupts the animation. + clearTimeout(this._exitFallback); + this._exitFallback = setTimeout(this._completeExit, 150); + } return this._onExit; } /** Makes sure the exit callbacks have been invoked when the element is destroyed. */ ngOnDestroy() { + clearTimeout(this._enterFallback); this._destroyed = true; this._clearFromModals(); this._completeExit(); } + private _completeEnter = () => { + clearTimeout(this._enterFallback); + this._ngZone.run(() => { + this._onEnter.next(); + this._onEnter.complete(); + }); + }; + /** * Removes the element in a microtask. Helps prevent errors where we end up * removing an element which is in the middle of an animation. */ - private _completeExit() { + private _completeExit = () => { + clearTimeout(this._exitFallback); queueMicrotask(() => { this._onExit.next(); this._onExit.complete(); }); - } + }; /** * Called after the portal contents have been attached. Can be diff --git a/src/material/snack-bar/snack-bar.spec.ts b/src/material/snack-bar/snack-bar.spec.ts index 17a6b72b5915..451b1e29ba30 100644 --- a/src/material/snack-bar/snack-bar.spec.ts +++ b/src/material/snack-bar/snack-bar.spec.ts @@ -17,7 +17,6 @@ import { MAT_SNACK_BAR_DATA, MatSnackBar, MatSnackBarConfig, - MatSnackBarContainer, MatSnackBarModule, MatSnackBarRef, SimpleSnackBar, @@ -360,67 +359,6 @@ describe('MatSnackBar', () => { .toBe(0); })); - it('should set the animation state to visible on entry', () => { - const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef}; - const snackBarRef = snackBar.open(simpleMessage, undefined, config); - - viewContainerFixture.detectChanges(); - const container = snackBarRef.containerInstance as MatSnackBarContainer; - expect(container._animationState) - .withContext(`Expected the animation state would be 'visible'.`) - .toBe('visible'); - snackBarRef.dismiss(); - - viewContainerFixture.detectChanges(); - expect(container._animationState) - .withContext(`Expected the animation state would be 'hidden'.`) - .toBe('hidden'); - }); - - it('should set the animation state to complete on exit', () => { - const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef}; - const snackBarRef = snackBar.open(simpleMessage, undefined, config); - snackBarRef.dismiss(); - - viewContainerFixture.detectChanges(); - const container = snackBarRef.containerInstance as MatSnackBarContainer; - expect(container._animationState) - .withContext(`Expected the animation state would be 'hidden'.`) - .toBe('hidden'); - }); - - it(`should set the old snack bar animation state to complete and the new snack bar animation - state to visible on entry of new snack bar`, fakeAsync(() => { - const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef}; - const snackBarRef = snackBar.open(simpleMessage, undefined, config); - const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy'); - - viewContainerFixture.detectChanges(); - - const containerElement = document.querySelector('mat-snack-bar-container')!; - expect(containerElement.classList).toContain('ng-animating'); - const container1 = snackBarRef.containerInstance as MatSnackBarContainer; - expect(container1._animationState) - .withContext(`Expected the animation state would be 'visible'.`) - .toBe('visible'); - - const config2 = {viewContainerRef: testViewContainerRef}; - const snackBarRef2 = snackBar.open(simpleMessage, undefined, config2); - - viewContainerFixture.detectChanges(); - snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy}); - flush(); - - expect(dismissCompleteSpy).toHaveBeenCalled(); - const container2 = snackBarRef2.containerInstance as MatSnackBarContainer; - expect(container1._animationState) - .withContext(`Expected the animation state would be 'hidden'.`) - .toBe('hidden'); - expect(container2._animationState) - .withContext(`Expected the animation state would be 'visible'.`) - .toBe('visible'); - })); - it('should open a new snackbar after dismissing a previous snackbar', fakeAsync(() => { let config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef}; let snackBarRef = snackBar.open(simpleMessage, 'Dismiss', config); @@ -610,9 +548,9 @@ describe('MatSnackBar', () => { it('should cap the timeout to the maximum accepted delay in setTimeout', fakeAsync(() => { const config = new MatSnackBarConfig(); config.duration = Infinity; + spyOn(window, 'setTimeout').and.callThrough(); snackBar.open('content', 'test', config); viewContainerFixture.detectChanges(); - spyOn(window, 'setTimeout').and.callThrough(); tick(100); expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), Math.pow(2, 31) - 1); diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts index 8947692b8779..80031dbe5469 100644 --- a/src/material/snack-bar/snack-bar.ts +++ b/src/material/snack-bar/snack-bar.ts @@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy { } }); + // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. + if (config.duration && config.duration > 0) { + snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); + } + if (this._openedSnackBarRef) { // If a snack bar is already in view, dismiss it and enter the // new snack bar after exit animation is complete. @@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy { // If no snack bar is in view, enter the new snack bar. snackBarRef.containerInstance.enter(); } - - // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. - if (config.duration && config.duration > 0) { - snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); - } } /** diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index b42922741409..bbd1d6c01c33 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -4,7 +4,6 @@ ```ts -import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { AriaLivePoliteness } from '@angular/cdk/a11y'; import { BasePortalOutlet } from '@angular/cdk/portal'; @@ -75,7 +74,7 @@ export class MatSnackBarActions { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export const matSnackBarAnimations: { readonly snackBarState: AnimationTriggerMetadata; }; @@ -96,7 +95,9 @@ export class MatSnackBarConfig { // @public export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { constructor(...args: unknown[]); - _animationState: string; + protected _animationDone(event: AnimationEvent): void; + // (undocumented) + protected _animationsDisabled: boolean; attachComponentPortal(portal: ComponentPortal): ComponentRef; // @deprecated attachDomPortal: (portal: DomPortal) => void; @@ -107,7 +108,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy _live: AriaLivePoliteness; readonly _liveElementId: string; ngOnDestroy(): void; - onAnimationEnd(event: AnimationEvent_2): void; readonly _onAnnounce: Subject; readonly _onEnter: Subject; readonly _onExit: Subject; From f2a3fc34085ddc783d970ccfee19c453c3456759 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 24 Jan 2025 13:14:54 +0100 Subject: [PATCH 2/5] fixup! fix(material/snack-bar): switch away from animations module --- src/material/snack-bar/snack-bar-container.ts | 63 ++++++++++++------- src/material/snack-bar/snack-bar.ts | 10 +-- tools/public_api_guard/material/snack-bar.md | 6 +- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 9ab0c21c1aa8..550a96d6f023 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -9,8 +9,10 @@ import { ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, + ChangeDetectorRef, Component, ComponentRef, + DoCheck, ElementRef, EmbeddedViewRef, inject, @@ -53,16 +55,18 @@ import {MatSnackBarConfig} from './snack-bar-config'; '(animationend)': '_animationDone($event)', }, }) -export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { +export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, DoCheck { private _ngZone = inject(NgZone); private _elementRef = inject>(ElementRef); private _platform = inject(Platform); + private _changeDetectorRef = inject(ChangeDetectorRef); private _enterFallback: ReturnType | undefined; private _exitFallback: ReturnType | undefined; protected _animationsDisabled = inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; - snackBarConfig = inject(MatSnackBarConfig); + private _scheduleDelayedEnter: boolean; + snackBarConfig = inject(MatSnackBarConfig); private _document = inject(DOCUMENT); private _trackedModals = new Set(); @@ -174,15 +178,23 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy /** Begin animation of snack bar entrance into view. */ enter(): void { if (!this._destroyed) { + // Previously this was used to ensure that the change-detection-based animation runs. + // Now the animation doesn't require change detection, but there seem to be some internal + // usages depending on it. + this._changeDetectorRef.markForCheck(); + this._changeDetectorRef.detectChanges(); this._screenReaderAnnounce(); if (this._animationsDisabled) { - this._completeEnter(); + // Delay the enter until the next change detection in an attempt to mimic the timing of + // the old animation-based events. Ideally we would use an `afterNextRender` here, but + // some tests throw a "Injector has already been destroyed" error. + this._scheduleDelayedEnter = true; } else { // Guarantees that the animation-related events will // fire even if something interrupts the animation. clearTimeout(this._enterFallback); - this._enterFallback = setTimeout(this._completeEnter, 200); + this._enterFallback = setTimeout(() => this._completeEnter(), 200); } } } @@ -194,51 +206,54 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy // test harness. this._elementRef.nativeElement.setAttribute('mat-exit', ''); - // If the snack bar hasn't been announced by the time it exits it wouldn't have been open - // long enough to visually read it either, so clear the timeout for announcing. - clearTimeout(this._announceTimeoutId); - - if (this._animationsDisabled) { - // It's common for snack bars to be opened by random outside calls like HTTP requests or - // errors. Run inside the NgZone to ensure that it functions correctly. - this._ngZone.run(this._completeExit); - } else { - // Guarantees that the animation-related events will - // fire even if something interrupts the animation. - clearTimeout(this._exitFallback); - this._exitFallback = setTimeout(this._completeExit, 150); - } + // Guarantees that the animation-related events will + // fire even if something interrupts the animation. + clearTimeout(this._exitFallback); + this._exitFallback = setTimeout( + () => this._completeExit(), + this._animationsDisabled ? undefined : 150, + ); return this._onExit; } - /** Makes sure the exit callbacks have been invoked when the element is destroyed. */ + ngDoCheck(): void { + if (this._scheduleDelayedEnter) { + this._scheduleDelayedEnter = false; + this._completeEnter(); + } + } + ngOnDestroy() { clearTimeout(this._enterFallback); this._destroyed = true; this._clearFromModals(); this._completeExit(); + this._onAnnounce.complete(); } - private _completeEnter = () => { + private _completeEnter() { clearTimeout(this._enterFallback); this._ngZone.run(() => { this._onEnter.next(); this._onEnter.complete(); }); - }; + } /** * Removes the element in a microtask. Helps prevent errors where we end up * removing an element which is in the middle of an animation. */ - private _completeExit = () => { + private _completeExit() { + // If the snack bar hasn't been announced by the time it exits it wouldn't have been open + // long enough to visually read it either, so clear the timeout for announcing. + clearTimeout(this._announceTimeoutId); clearTimeout(this._exitFallback); - queueMicrotask(() => { + this._ngZone.run(() => { this._onExit.next(); this._onExit.complete(); }); - }; + } /** * Called after the portal contents have been attached. Can be diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts index 80031dbe5469..8947692b8779 100644 --- a/src/material/snack-bar/snack-bar.ts +++ b/src/material/snack-bar/snack-bar.ts @@ -242,11 +242,6 @@ export class MatSnackBar implements OnDestroy { } }); - // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. - if (config.duration && config.duration > 0) { - snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); - } - if (this._openedSnackBarRef) { // If a snack bar is already in view, dismiss it and enter the // new snack bar after exit animation is complete. @@ -258,6 +253,11 @@ export class MatSnackBar implements OnDestroy { // If no snack bar is in view, enter the new snack bar. snackBarRef.containerInstance.enter(); } + + // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. + if (config.duration && config.duration > 0) { + snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); + } } /** diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index bbd1d6c01c33..3094d79b8136 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -12,6 +12,7 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentRef } from '@angular/core'; import { ComponentType } from '@angular/cdk/overlay'; import { Direction } from '@angular/cdk/bidi'; +import { DoCheck } from '@angular/core'; import { DomPortal } from '@angular/cdk/portal'; import { ElementRef } from '@angular/core'; import { EmbeddedViewRef } from '@angular/core'; @@ -93,7 +94,7 @@ export class MatSnackBarConfig { } // @public -export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { +export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, DoCheck { constructor(...args: unknown[]); protected _animationDone(event: AnimationEvent): void; // (undocumented) @@ -107,6 +108,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy _label: ElementRef; _live: AriaLivePoliteness; readonly _liveElementId: string; + // (undocumented) + ngDoCheck(): void; + // (undocumented) ngOnDestroy(): void; readonly _onAnnounce: Subject; readonly _onEnter: Subject; From 392ae2ce0a1d026866fdf23e684dd55f0c5b7522 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 24 Jan 2025 14:37:23 +0100 Subject: [PATCH 3/5] fixup! fix(material/snack-bar): switch away from animations module --- .../snack-bar/snack-bar-container.scss | 12 ++- src/material/snack-bar/snack-bar-container.ts | 102 ++++++++++-------- src/material/snack-bar/snack-bar.spec.ts | 2 +- tools/public_api_guard/material/snack-bar.md | 6 +- 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 332a08775f63..9133beb2451a 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -42,11 +42,15 @@ $_side-padding: 8px; } } -.mat-snack-bar-animations-enabled { - animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1); +.mat-snack-bar-container-animations-enabled { + opacity: 0; - &[mat-exit] { - animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1); + &.mat-snack-bar-container-enter { + animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1) forwards; + } + + &.mat-snack-bar-container-exit { + animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1) forwards; } } diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 550a96d6f023..f23d19f1472e 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -51,30 +51,33 @@ import {MatSnackBarConfig} from './snack-bar-config'; imports: [CdkPortalOutlet], host: { 'class': 'mdc-snackbar mat-mdc-snack-bar-container', - '[class.mat-snack-bar-animations-enabled]': '!_animationsDisabled', - '(animationend)': '_animationDone($event)', + '[class.mat-snack-bar-container-enter]': '_animationState === "visible"', + '[class.mat-snack-bar-container-exit]': '_animationState === "hidden"', + '[class.mat-snack-bar-container-animations-enabled]': '!_animationsDisabled', + '(animationend)': 'onAnimationEnd($event)', + '(animationcancel)': 'onAnimationEnd($event)', }, }) -export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, DoCheck { +export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy { private _ngZone = inject(NgZone); private _elementRef = inject>(ElementRef); - private _platform = inject(Platform); private _changeDetectorRef = inject(ChangeDetectorRef); - private _enterFallback: ReturnType | undefined; - private _exitFallback: ReturnType | undefined; + private _platform = inject(Platform); protected _animationsDisabled = inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; - private _scheduleDelayedEnter: boolean; - snackBarConfig = inject(MatSnackBarConfig); + private _document = inject(DOCUMENT); private _trackedModals = new Set(); + private _enterFallback: ReturnType | undefined; + private _exitFallback: ReturnType | undefined; + private _pendingNoopAnimation: boolean; /** The number of milliseconds to wait before announcing the snack bar's content. */ private readonly _announceDelay: number = 150; /** The timeout for announcing the snack bar's content. */ - private _announceTimeoutId: ReturnType | undefined; + private _announceTimeoutId: ReturnType; /** Whether the component has been destroyed. */ private _destroyed = false; @@ -91,6 +94,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, /** Subject for notifying that the snack bar has finished entering the view. */ readonly _onEnter: Subject = new Subject(); + /** The state of the snack bar animations. */ + _animationState = 'void'; + /** aria-live value for the live region. */ _live: AriaLivePoliteness; @@ -167,32 +173,27 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, }; /** Handle end of animations, updating the state of the snackbar. */ - protected _animationDone(event: AnimationEvent) { - if (event.animationName === '_mat-snack-bar-enter') { - this._completeEnter(); - } else if (event.animationName === '_mat-snack-bar-exit') { + onAnimationEnd(event: AnimationEvent) { + if (event.animationName === '_mat-snack-bar-exit') { this._completeExit(); + } else if (event.animationName === '_mat-snack-bar-enter') { + this._completeEnter(); } } /** Begin animation of snack bar entrance into view. */ enter(): void { if (!this._destroyed) { - // Previously this was used to ensure that the change-detection-based animation runs. - // Now the animation doesn't require change detection, but there seem to be some internal - // usages depending on it. + this._animationState = 'visible'; + // _animationState lives in host bindings and `detectChanges` does not refresh host bindings + // so we have to call `markForCheck` to ensure the host view is refreshed eventually. this._changeDetectorRef.markForCheck(); this._changeDetectorRef.detectChanges(); this._screenReaderAnnounce(); if (this._animationsDisabled) { - // Delay the enter until the next change detection in an attempt to mimic the timing of - // the old animation-based events. Ideally we would use an `afterNextRender` here, but - // some tests throw a "Injector has already been destroyed" error. - this._scheduleDelayedEnter = true; + this._pendingNoopAnimation = true; } else { - // Guarantees that the animation-related events will - // fire even if something interrupts the animation. clearTimeout(this._enterFallback); this._enterFallback = setTimeout(() => this._completeEnter(), 200); } @@ -201,35 +202,55 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, /** Begin animation of the snack bar exiting from view. */ exit(): Observable { - // Mark this element with an 'exit' attribute to indicate that the snackbar has - // been dismissed and will soon be removed from the DOM. This is used by the snackbar - // test harness. - this._elementRef.nativeElement.setAttribute('mat-exit', ''); + // It's common for snack bars to be opened by random outside calls like HTTP requests or + // errors. Run inside the NgZone to ensure that it functions correctly. + this._ngZone.run(() => { + // Note: this one transitions to `hidden`, rather than `void`, in order to handle the case + // where multiple snack bars are opened in quick succession (e.g. two consecutive calls to + // `MatSnackBar.open`). + this._animationState = 'hidden'; + this._changeDetectorRef.markForCheck(); - // Guarantees that the animation-related events will - // fire even if something interrupts the animation. - clearTimeout(this._exitFallback); - this._exitFallback = setTimeout( - () => this._completeExit(), - this._animationsDisabled ? undefined : 150, - ); + // Mark this element with an 'exit' attribute to indicate that the snackbar has + // been dismissed and will soon be removed from the DOM. This is used by the snackbar + // test harness. + this._elementRef.nativeElement.setAttribute('mat-exit', ''); + + // If the snack bar hasn't been announced by the time it exits it wouldn't have been open + // long enough to visually read it either, so clear the timeout for announcing. + clearTimeout(this._announceTimeoutId); + + if (this._animationsDisabled) { + this._pendingNoopAnimation = true; + } else { + clearTimeout(this._exitFallback); + this._exitFallback = setTimeout(() => this._completeExit(), 200); + } + }); return this._onExit; } ngDoCheck(): void { - if (this._scheduleDelayedEnter) { - this._scheduleDelayedEnter = false; - this._completeEnter(); + // Aims to mimic the timing of when the snack back was using the animations + // module since many internal tests depend on the old timing. + if (this._pendingNoopAnimation) { + this._pendingNoopAnimation = false; + queueMicrotask(() => { + if (this._animationState === 'visible') { + this._completeEnter(); + } else { + this._completeExit(); + } + }); } } + /** Makes sure the exit callbacks have been invoked when the element is destroyed. */ ngOnDestroy() { - clearTimeout(this._enterFallback); this._destroyed = true; this._clearFromModals(); this._completeExit(); - this._onAnnounce.complete(); } private _completeEnter() { @@ -245,11 +266,8 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, * removing an element which is in the middle of an animation. */ private _completeExit() { - // If the snack bar hasn't been announced by the time it exits it wouldn't have been open - // long enough to visually read it either, so clear the timeout for announcing. - clearTimeout(this._announceTimeoutId); clearTimeout(this._exitFallback); - this._ngZone.run(() => { + queueMicrotask(() => { this._onExit.next(); this._onExit.complete(); }); diff --git a/src/material/snack-bar/snack-bar.spec.ts b/src/material/snack-bar/snack-bar.spec.ts index 451b1e29ba30..242722ddae20 100644 --- a/src/material/snack-bar/snack-bar.spec.ts +++ b/src/material/snack-bar/snack-bar.spec.ts @@ -564,7 +564,7 @@ describe('MatSnackBar', () => { viewContainerFixture.detectChanges(); } - flush(); + flush(50); expect(overlayContainerElement.querySelectorAll('mat-snack-bar-container').length).toBe(1); })); diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index 3094d79b8136..a477586b2b20 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -94,11 +94,11 @@ export class MatSnackBarConfig { } // @public -export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, DoCheck { +export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy { constructor(...args: unknown[]); - protected _animationDone(event: AnimationEvent): void; // (undocumented) protected _animationsDisabled: boolean; + _animationState: string; attachComponentPortal(portal: ComponentPortal): ComponentRef; // @deprecated attachDomPortal: (portal: DomPortal) => void; @@ -110,8 +110,8 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, readonly _liveElementId: string; // (undocumented) ngDoCheck(): void; - // (undocumented) ngOnDestroy(): void; + onAnimationEnd(event: AnimationEvent): void; readonly _onAnnounce: Subject; readonly _onEnter: Subject; readonly _onExit: Subject; From bf8c558608a38e01974ce2667f16f0e63a19e01c Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 27 Jan 2025 09:19:40 +0100 Subject: [PATCH 4/5] fixup! fix(material/snack-bar): switch away from animations module --- .../snack-bar/snack-bar-container.scss | 5 + src/material/snack-bar/snack-bar-container.ts | 164 ++++++++++-------- src/material/snack-bar/snack-bar.ts | 10 +- tools/public_api_guard/material/snack-bar.md | 7 +- 4 files changed, 108 insertions(+), 78 deletions(-) diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 9133beb2451a..3ba0a79709a2 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -45,6 +45,11 @@ $_side-padding: 8px; .mat-snack-bar-container-animations-enabled { opacity: 0; + // Fallback in case the animation fails. + &.mat-snack-bar-fallback-visible { + opacity: 1; + } + &.mat-snack-bar-container-enter { animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1) forwards; } diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index f23d19f1472e..0f3b3e072ff9 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -7,15 +7,16 @@ */ import { + afterNextRender, ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, - DoCheck, ElementRef, EmbeddedViewRef, inject, + Injector, NgZone, OnDestroy, ViewChild, @@ -29,11 +30,14 @@ import { DomPortal, TemplatePortal, } from '@angular/cdk/portal'; -import {Observable, Subject} from 'rxjs'; +import {Observable, Subject, of} from 'rxjs'; import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y'; import {Platform} from '@angular/cdk/platform'; import {MatSnackBarConfig} from './snack-bar-config'; +const ENTER_ANIMATION = '_mat-snack-bar-enter'; +const EXIT_ANIMATION = '_mat-snack-bar-exit'; + /** * Internal component that wraps user-provided snack bar content. * @docs-private @@ -54,15 +58,16 @@ import {MatSnackBarConfig} from './snack-bar-config'; '[class.mat-snack-bar-container-enter]': '_animationState === "visible"', '[class.mat-snack-bar-container-exit]': '_animationState === "hidden"', '[class.mat-snack-bar-container-animations-enabled]': '!_animationsDisabled', - '(animationend)': 'onAnimationEnd($event)', - '(animationcancel)': 'onAnimationEnd($event)', + '(animationend)': 'onAnimationEnd($event.animationName)', + '(animationcancel)': 'onAnimationEnd($event.animationName)', }, }) -export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy { +export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { private _ngZone = inject(NgZone); private _elementRef = inject>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); private _platform = inject(Platform); + private _injector = inject(Injector); protected _animationsDisabled = inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; snackBarConfig = inject(MatSnackBarConfig); @@ -71,7 +76,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O private _trackedModals = new Set(); private _enterFallback: ReturnType | undefined; private _exitFallback: ReturnType | undefined; - private _pendingNoopAnimation: boolean; /** The number of milliseconds to wait before announcing the snack bar's content. */ private readonly _announceDelay: number = 150; @@ -82,6 +86,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O /** Whether the component has been destroyed. */ private _destroyed = false; + /** Whether the process of exiting is currently running. */ + private _isExiting = false; + /** The portal outlet inside of this container into which the snack bar content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; @@ -173,11 +180,15 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O }; /** Handle end of animations, updating the state of the snackbar. */ - onAnimationEnd(event: AnimationEvent) { - if (event.animationName === '_mat-snack-bar-exit') { + onAnimationEnd(animationName: string) { + if (animationName === EXIT_ANIMATION) { this._completeExit(); - } else if (event.animationName === '_mat-snack-bar-enter') { - this._completeEnter(); + } else if (animationName === ENTER_ANIMATION) { + clearTimeout(this._enterFallback); + this._ngZone.run(() => { + this._onEnter.next(); + this._onEnter.complete(); + }); } } @@ -192,16 +203,43 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O this._screenReaderAnnounce(); if (this._animationsDisabled) { - this._pendingNoopAnimation = true; + afterNextRender( + () => { + this._ngZone.run(() => { + queueMicrotask(() => this.onAnimationEnd(ENTER_ANIMATION)); + }); + }, + { + injector: this._injector, + }, + ); } else { clearTimeout(this._enterFallback); - this._enterFallback = setTimeout(() => this._completeEnter(), 200); + this._enterFallback = setTimeout(() => { + // The snack bar will stay invisible if it fails to animate. Add a fallback class so it + // becomes visible. This can happen in some apps that do `* {animation: none !important}`. + this._elementRef.nativeElement.classList.add('mat-snack-bar-fallback-visible'); + this.onAnimationEnd(ENTER_ANIMATION); + }, 200); } } } /** Begin animation of the snack bar exiting from view. */ exit(): Observable { + if (this._destroyed) { + return of(undefined); + } + + // It's important not to re-enter here, because `afterNextRender` needs a non-destroyed injector + // which might happen between the time `exit` starts and the component is actually destroyed. + // This appears to happen in some internal tests when TestBed is being torn down. + if (this._isExiting) { + return this._onExit; + } + + this._isExiting = true; + // It's common for snack bars to be opened by random outside calls like HTTP requests or // errors. Run inside the NgZone to ensure that it functions correctly. this._ngZone.run(() => { @@ -221,31 +259,25 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O clearTimeout(this._announceTimeoutId); if (this._animationsDisabled) { - this._pendingNoopAnimation = true; + afterNextRender( + () => { + this._ngZone.run(() => { + queueMicrotask(() => this.onAnimationEnd(EXIT_ANIMATION)); + }); + }, + { + injector: this._injector, + }, + ); } else { clearTimeout(this._exitFallback); - this._exitFallback = setTimeout(() => this._completeExit(), 200); + this._exitFallback = setTimeout(() => this.onAnimationEnd(EXIT_ANIMATION), 200); } }); return this._onExit; } - ngDoCheck(): void { - // Aims to mimic the timing of when the snack back was using the animations - // module since many internal tests depend on the old timing. - if (this._pendingNoopAnimation) { - this._pendingNoopAnimation = false; - queueMicrotask(() => { - if (this._animationState === 'visible') { - this._completeEnter(); - } else { - this._completeExit(); - } - }); - } - } - /** Makes sure the exit callbacks have been invoked when the element is destroyed. */ ngOnDestroy() { this._destroyed = true; @@ -253,23 +285,12 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O this._completeExit(); } - private _completeEnter() { - clearTimeout(this._enterFallback); - this._ngZone.run(() => { - this._onEnter.next(); - this._onEnter.complete(); - }); - } - - /** - * Removes the element in a microtask. Helps prevent errors where we end up - * removing an element which is in the middle of an animation. - */ private _completeExit() { clearTimeout(this._exitFallback); queueMicrotask(() => { this._onExit.next(); this._onExit.complete(); + this._isExiting = true; }); } @@ -360,33 +381,40 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O * announce it. */ private _screenReaderAnnounce() { - if (!this._announceTimeoutId) { - this._ngZone.runOutsideAngular(() => { - this._announceTimeoutId = setTimeout(() => { - const inertElement = this._elementRef.nativeElement.querySelector('[aria-hidden]'); - const liveElement = this._elementRef.nativeElement.querySelector('[aria-live]'); - - if (inertElement && liveElement) { - // If an element in the snack bar content is focused before being moved - // track it and restore focus after moving to the live region. - let focusedElement: HTMLElement | null = null; - if ( - this._platform.isBrowser && - document.activeElement instanceof HTMLElement && - inertElement.contains(document.activeElement) - ) { - focusedElement = document.activeElement; - } - - inertElement.removeAttribute('aria-hidden'); - liveElement.appendChild(inertElement); - focusedElement?.focus(); - - this._onAnnounce.next(); - this._onAnnounce.complete(); - } - }, this._announceDelay); - }); + if (this._announceTimeoutId) { + return; } + + this._ngZone.runOutsideAngular(() => { + this._announceTimeoutId = setTimeout(() => { + if (this._destroyed) { + return; + } + + const element = this._elementRef.nativeElement; + const inertElement = element.querySelector('[aria-hidden]'); + const liveElement = element.querySelector('[aria-live]'); + + if (inertElement && liveElement) { + // If an element in the snack bar content is focused before being moved + // track it and restore focus after moving to the live region. + let focusedElement: HTMLElement | null = null; + if ( + this._platform.isBrowser && + document.activeElement instanceof HTMLElement && + inertElement.contains(document.activeElement) + ) { + focusedElement = document.activeElement; + } + + inertElement.removeAttribute('aria-hidden'); + liveElement.appendChild(inertElement); + focusedElement?.focus(); + + this._onAnnounce.next(); + this._onAnnounce.complete(); + } + }, this._announceDelay); + }); } } diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts index 8947692b8779..80031dbe5469 100644 --- a/src/material/snack-bar/snack-bar.ts +++ b/src/material/snack-bar/snack-bar.ts @@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy { } }); + // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. + if (config.duration && config.duration > 0) { + snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); + } + if (this._openedSnackBarRef) { // If a snack bar is already in view, dismiss it and enter the // new snack bar after exit animation is complete. @@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy { // If no snack bar is in view, enter the new snack bar. snackBarRef.containerInstance.enter(); } - - // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. - if (config.duration && config.duration > 0) { - snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); - } } /** diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index a477586b2b20..5dc4e6819384 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -12,7 +12,6 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentRef } from '@angular/core'; import { ComponentType } from '@angular/cdk/overlay'; import { Direction } from '@angular/cdk/bidi'; -import { DoCheck } from '@angular/core'; import { DomPortal } from '@angular/cdk/portal'; import { ElementRef } from '@angular/core'; import { EmbeddedViewRef } from '@angular/core'; @@ -94,7 +93,7 @@ export class MatSnackBarConfig { } // @public -export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy { +export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { constructor(...args: unknown[]); // (undocumented) protected _animationsDisabled: boolean; @@ -108,10 +107,8 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O _label: ElementRef; _live: AriaLivePoliteness; readonly _liveElementId: string; - // (undocumented) - ngDoCheck(): void; ngOnDestroy(): void; - onAnimationEnd(event: AnimationEvent): void; + onAnimationEnd(animationName: string): void; readonly _onAnnounce: Subject; readonly _onEnter: Subject; readonly _onExit: Subject; From c26ed7617fc981416824e1bb1ef80f9c534a36e2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 27 Jan 2025 11:23:04 +0100 Subject: [PATCH 5/5] fixup! fix(material/snack-bar): switch away from animations module --- src/material/snack-bar/snack-bar-container.ts | 54 +++++++------------ 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 0f3b3e072ff9..846d4366927e 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -7,7 +7,8 @@ */ import { - afterNextRender, + afterRender, + AfterRenderRef, ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, @@ -16,7 +17,6 @@ import { ElementRef, EmbeddedViewRef, inject, - Injector, NgZone, OnDestroy, ViewChild, @@ -34,6 +34,7 @@ import {Observable, Subject, of} from 'rxjs'; import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y'; import {Platform} from '@angular/cdk/platform'; import {MatSnackBarConfig} from './snack-bar-config'; +import {take} from 'rxjs/operators'; const ENTER_ANIMATION = '_mat-snack-bar-enter'; const EXIT_ANIMATION = '_mat-snack-bar-exit'; @@ -67,7 +68,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy private _elementRef = inject>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); private _platform = inject(Platform); - private _injector = inject(Injector); + private _rendersRef: AfterRenderRef; protected _animationsDisabled = inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; snackBarConfig = inject(MatSnackBarConfig); @@ -76,6 +77,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy private _trackedModals = new Set(); private _enterFallback: ReturnType | undefined; private _exitFallback: ReturnType | undefined; + private _renders = new Subject(); /** The number of milliseconds to wait before announcing the snack bar's content. */ private readonly _announceDelay: number = 150; @@ -86,9 +88,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy /** Whether the component has been destroyed. */ private _destroyed = false; - /** Whether the process of exiting is currently running. */ - private _isExiting = false; - /** The portal outlet inside of this container into which the snack bar content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; @@ -149,6 +148,11 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy this._role = 'alert'; } } + + // Note: ideally we'd just do an `afterNextRender` in the places where we need to delay + // something, however in some cases (TestBed teardown) the injector can be destroyed at an + // unexpected time, causing the `afterRender` to fail. + this._rendersRef = afterRender(() => this._renders.next(), {manualCleanup: true}); } /** Attach a component portal as content to this snack bar container. */ @@ -203,16 +207,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy this._screenReaderAnnounce(); if (this._animationsDisabled) { - afterNextRender( - () => { - this._ngZone.run(() => { - queueMicrotask(() => this.onAnimationEnd(ENTER_ANIMATION)); - }); - }, - { - injector: this._injector, - }, - ); + this._renders.pipe(take(1)).subscribe(() => { + this._ngZone.run(() => queueMicrotask(() => this.onAnimationEnd(ENTER_ANIMATION))); + }); } else { clearTimeout(this._enterFallback); this._enterFallback = setTimeout(() => { @@ -231,15 +228,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy return of(undefined); } - // It's important not to re-enter here, because `afterNextRender` needs a non-destroyed injector - // which might happen between the time `exit` starts and the component is actually destroyed. - // This appears to happen in some internal tests when TestBed is being torn down. - if (this._isExiting) { - return this._onExit; - } - - this._isExiting = true; - // It's common for snack bars to be opened by random outside calls like HTTP requests or // errors. Run inside the NgZone to ensure that it functions correctly. this._ngZone.run(() => { @@ -259,16 +247,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy clearTimeout(this._announceTimeoutId); if (this._animationsDisabled) { - afterNextRender( - () => { - this._ngZone.run(() => { - queueMicrotask(() => this.onAnimationEnd(EXIT_ANIMATION)); - }); - }, - { - injector: this._injector, - }, - ); + this._renders.pipe(take(1)).subscribe(() => { + this._ngZone.run(() => queueMicrotask(() => this.onAnimationEnd(EXIT_ANIMATION))); + }); } else { clearTimeout(this._exitFallback); this._exitFallback = setTimeout(() => this.onAnimationEnd(EXIT_ANIMATION), 200); @@ -283,6 +264,8 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy this._destroyed = true; this._clearFromModals(); this._completeExit(); + this._renders.complete(); + this._rendersRef.destroy(); } private _completeExit() { @@ -290,7 +273,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy queueMicrotask(() => { this._onExit.next(); this._onExit.complete(); - this._isExiting = true; }); }