Skip to content

Commit 4eb1ca1

Browse files
arturovtdylhunn
authored andcommitted
fix(animations): cleanup DOM elements when the root view is removed (#45143)
Currently, when importing `BrowserAnimationsModule`, Angular provides `AnimationRendererFactory` as the `RendererFactory2`. The `AnimationRendererFactory` relies on the `AnimationEngine`. The `AnimationEngine` may be created earlier than the `ApplicationRef` (e.g. if it's requested within the `APP_INITIALIZER` before the `ApplicationRef` is created). This means that Angular will add the `AnimationEngine` to `R3Injector.onDestroy` before the `ApplicationRef`. The `R3Injector` will call `ngOnDestroy()` on the `AnimationEngine` before the `ApplicationRef`, which means the `flush()` will be called earlier before views are destroyed. PR Close #45108 PR Close #45143
1 parent d24d54b commit 4eb1ca1

3 files changed

Lines changed: 109 additions & 7 deletions

File tree

packages/core/src/application_ref.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -732,8 +732,10 @@ export class ApplicationRef {
732732

733733
/** @internal */
734734
constructor(
735-
private _zone: NgZone, private _injector: Injector, private _exceptionHandler: ErrorHandler,
736-
private _initStatus: ApplicationInitStatus) {
735+
private _zone: NgZone,
736+
private _injector: Injector,
737+
private _exceptionHandler: ErrorHandler,
738+
) {
737739
this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({
738740
next: () => {
739741
this._zone.run(() => {
@@ -914,8 +916,9 @@ export class ApplicationRef {
914916
ComponentRef<C> {
915917
NG_DEV_MODE && this.warnIfDestroyed();
916918
const isComponentFactory = componentOrFactory instanceof ComponentFactory;
919+
const initStatus = this._injector.get(ApplicationInitStatus);
917920

918-
if (!this._initStatus.done) {
921+
if (!initStatus.done) {
919922
const standalone = !isComponentFactory && isStandalone(componentOrFactory);
920923
const errorMessage =
921924
'Cannot bootstrap as there are still asynchronous initializers running.' +

packages/platform-browser/animations/src/providers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
import {AnimationBuilder} from '@angular/animations';
1010
import {AnimationDriver, ɵAnimationEngine as AnimationEngine, ɵAnimationStyleNormalizer as AnimationStyleNormalizer, ɵNoopAnimationDriver as NoopAnimationDriver, ɵWebAnimationsDriver as WebAnimationsDriver, ɵWebAnimationsStyleNormalizer as WebAnimationsStyleNormalizer} from '@angular/animations/browser';
1111
import {DOCUMENT} from '@angular/common';
12-
import {ANIMATION_MODULE_TYPE, Inject, Injectable, InjectionToken, NgZone, OnDestroy, Provider, RendererFactory2} from '@angular/core';
12+
import {ANIMATION_MODULE_TYPE, ApplicationRef, Inject, Injectable, NgZone, OnDestroy, Provider, RendererFactory2} from '@angular/core';
1313
import {ɵDomRendererFactory2 as DomRendererFactory2} from '@angular/platform-browser';
1414

1515
import {BrowserAnimationBuilder} from './animation_builder';
1616
import {AnimationRendererFactory} from './animation_renderer';
1717

1818
@Injectable()
1919
export class InjectableAnimationEngine extends AnimationEngine implements OnDestroy {
20+
// The `ApplicationRef` is injected here explicitly to force the dependency ordering.
21+
// Since the `ApplicationRef` should be created earlier before the `AnimationEngine`, they
22+
// both have `ngOnDestroy` hooks and `flush()` must be called after all views are destroyed.
2023
constructor(
21-
@Inject(DOCUMENT) doc: any, driver: AnimationDriver, normalizer: AnimationStyleNormalizer) {
24+
@Inject(DOCUMENT) doc: any, driver: AnimationDriver, normalizer: AnimationStyleNormalizer,
25+
appRef: ApplicationRef) {
2226
super(doc.body, driver, normalizer);
2327
}
2428

packages/platform-browser/animations/test/animation_renderer_spec.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
*/
88
import {animate, AnimationPlayer, AnimationTriggerMetadata, state, style, transition, trigger} from '@angular/animations';
99
import {ɵAnimationEngine as AnimationEngine} from '@angular/animations/browser';
10-
import {Component, destroyPlatform, Injectable, NgModule, NgZone, RendererFactory2, RendererType2, ViewChild} from '@angular/core';
10+
import {APP_INITIALIZER, Component, destroyPlatform, importProvidersFrom, Injectable, NgModule, NgZone, RendererFactory2, RendererType2, ViewChild} from '@angular/core';
1111
import {TestBed} from '@angular/core/testing';
12+
import {bootstrapApplication} from '@angular/platform-browser';
1213
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
1314
import {BrowserAnimationsModule, ɵAnimationRendererFactory as AnimationRendererFactory, ɵInjectableAnimationEngine as InjectableAnimationEngine} from '@angular/platform-browser/animations';
1415
import {DomRendererFactory2} from '@angular/platform-browser/src/dom/dom_renderer';
@@ -330,7 +331,8 @@ describe('destroy', () => {
330331
beforeEach(destroyPlatform);
331332
afterEach(destroyPlatform);
332333

333-
it('should clear bootstrapped component contents',
334+
// See https://github.com/angular/angular/issues/39955
335+
it('should clear bootstrapped component contents when the `BrowserAnimationsModule` is imported',
334336
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
335337
@Component({selector: 'app-root', template: 'app-root content'})
336338
class AppComponent {
@@ -355,6 +357,99 @@ describe('destroy', () => {
355357
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
356358
expect(document.body.childNodes.length).toEqual(2); // other elements are preserved
357359
}));
360+
361+
// See https://github.com/angular/angular/issues/45108
362+
it('should clear bootstrapped component contents when the animation engine is requested during initialization',
363+
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
364+
@Injectable({providedIn: 'root'})
365+
class AppService {
366+
// The `RendererFactory2` is injected here explicitly because we import the
367+
// `BrowserAnimationsModule`. The `RendererFactory2` will be provided with
368+
// `AnimationRendererFactory` which relies on the `AnimationEngine`. We want to ensure that
369+
// `ApplicationRef` is created earlier before the `AnimationEngine`, because previously the
370+
// `AnimationEngine` was created before the `ApplicationRef` (see the link above to the
371+
// original issue). The `ApplicationRef` was created after `APP_INITIALIZER` has been run
372+
// but before the root module is bootstrapped.
373+
constructor(rendererFactory: RendererFactory2) {}
374+
}
375+
376+
@Component({selector: 'app-root', template: 'app-root content'})
377+
class AppComponent {
378+
}
379+
380+
@NgModule({
381+
imports: [BrowserAnimationsModule],
382+
declarations: [AppComponent],
383+
bootstrap: [AppComponent],
384+
providers: [
385+
// The `APP_INITIALIZER` token is requested before the root module is bootstrapped, the
386+
// `useFactory` just injects the `AppService` that injects the `RendererFactory2`.
387+
{
388+
provide: APP_INITIALIZER,
389+
useFactory: () => (appService: AppService) => appService,
390+
deps: [AppService],
391+
multi: true
392+
}
393+
]
394+
})
395+
class AppModule {
396+
}
397+
398+
const ngModuleRef = await platformBrowserDynamic().bootstrapModule(AppModule);
399+
400+
const root = document.body.querySelector('app-root')!;
401+
expect(root.textContent).toEqual('app-root content');
402+
expect(document.body.childNodes.length).toEqual(3);
403+
404+
ngModuleRef.destroy();
405+
406+
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
407+
expect(document.body.childNodes.length).toEqual(2); // other elements are preserved
408+
}));
409+
410+
// See https://github.com/angular/angular/issues/45108
411+
it('should clear standalone bootstrapped component contents when the animation engine is requested during initialization',
412+
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
413+
@Injectable({providedIn: 'root'})
414+
class AppService {
415+
// The `RendererFactory2` is injected here explicitly because we import the
416+
// `BrowserAnimationsModule`. The `RendererFactory2` will be provided with
417+
// `AnimationRendererFactory` which relies on the `AnimationEngine`. We want to ensure
418+
// that `ApplicationRef` is created earlier before the `AnimationEngine`, because
419+
// previously the `AnimationEngine` was created before the `ApplicationRef` (see the link
420+
// above to the original issue). The `ApplicationRef` was created after `APP_INITIALIZER`
421+
// has been run but before the root module is bootstrapped.
422+
constructor(rendererFactory: RendererFactory2) {}
423+
}
424+
425+
@Component({selector: 'app-root', template: 'app-root content', standalone: true})
426+
class AppComponent {
427+
}
428+
429+
const appRef = await bootstrapApplication(AppComponent, {
430+
providers: [
431+
importProvidersFrom(BrowserAnimationsModule),
432+
// The `APP_INITIALIZER` token is requested before the standalone component is
433+
// bootstrapped, the `useFactory` just injects the `AppService` that injects the
434+
// `RendererFactory2`.
435+
{
436+
provide: APP_INITIALIZER,
437+
useFactory: () => (appService: AppService) => appService,
438+
deps: [AppService],
439+
multi: true
440+
}
441+
]
442+
});
443+
444+
const root = document.body.querySelector('app-root')!;
445+
expect(root.textContent).toEqual('app-root content');
446+
expect(document.body.childNodes.length).toEqual(3);
447+
448+
appRef.destroy();
449+
450+
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
451+
expect(document.body.childNodes.length).toEqual(2); // other elements are
452+
}));
358453
});
359454
})();
360455

0 commit comments

Comments
 (0)