Skip to content

Commit e78451c

Browse files
fix(core): prevent animations renderer from impacting animate.leave (#63921)
This adds an optional flag to the renderer on `removeChild` called `requireSynchronousElementRemoval`, which can tell any downstream renderer that elements need to be removed synchronously. This gets passed down to the legacy animation renderer to ensure that any elements that set this flag aren't impacted by that renderers changes to timing. fixes: #63893 PR Close #63921
1 parent b04e6b1 commit e78451c

File tree

7 files changed

+124
-13
lines changed

7 files changed

+124
-13
lines changed

goldens/public-api/core/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1579,7 +1579,7 @@ export abstract class Renderer2 {
15791579
abstract nextSibling(node: any): any;
15801580
abstract parentNode(node: any): any;
15811581
abstract removeAttribute(el: any, name: string, namespace?: string | null): void;
1582-
abstract removeChild(parent: any, oldChild: any, isHostElement?: boolean): void;
1582+
abstract removeChild(parent: any, oldChild: any, isHostElement?: boolean, requireSynchronousElementRemoval?: boolean): void;
15831583
abstract removeClass(el: any, name: string): void;
15841584
abstract removeStyle(el: any, style: string, flags?: RendererStyleFlags2): void;
15851585
abstract selectRootElement(selectorOrNode: string | any, preserveContent?: boolean): any;

packages/animations/browser/src/render/renderer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,20 @@ export class BaseAnimationRenderer implements Renderer2 {
7878
this.engine.onInsert(this.namespaceId, newChild, parent, isMove);
7979
}
8080

81-
removeChild(parent: any, oldChild: any, isHostElement?: boolean): void {
81+
// TODO(thePunderWoman): remove the requireSynchronousElementRemoval flag after the
82+
// animations package has been fully deleted post v23.
83+
removeChild(
84+
parent: any,
85+
oldChild: any,
86+
isHostElement?: boolean,
87+
requireSynchronousElementRemoval?: boolean,
88+
): void {
89+
// Elements using the new `animate.leave` API require synchronous removal and should
90+
// skip the rest of the legacy animation behaviors.
91+
if (requireSynchronousElementRemoval) {
92+
this.delegate.removeChild(parent, oldChild, isHostElement);
93+
return;
94+
}
8295
// Prior to the changes in #57203, this method wasn't being called at all by `core` if the child
8396
// doesn't have a parent. There appears to be some animation-specific downstream logic that
8497
// depends on the null check happening before the animation engine. This check keeps the old

packages/core/src/render/api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,15 @@ export abstract class Renderer2 {
125125
* @param parent The parent node.
126126
* @param oldChild The child node to remove.
127127
* @param isHostElement Optionally signal to the renderer whether this element is a host element
128-
* or not
128+
* @param requireSynchronousElementRemoval Optionally signal to the renderer whether this element
129+
* needs synchronous removal
129130
*/
130-
abstract removeChild(parent: any, oldChild: any, isHostElement?: boolean): void;
131+
abstract removeChild(
132+
parent: any,
133+
oldChild: any,
134+
isHostElement?: boolean,
135+
requireSynchronousElementRemoval?: boolean,
136+
): void;
131137
/**
132138
* Implement this callback to prepare an element to be bootstrapped
133139
* as a root element, and return the element instance.

packages/core/src/render3/dom_node_manipulation.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,16 @@ export function nativeAppendOrInsertBefore(
8181
* @param renderer A renderer to be used
8282
* @param rNode The native node that should be removed
8383
* @param isHostElement A flag indicating if a node to be removed is a host of a component.
84+
* @param requireSynchronousElementRemoval A flag indicating if a node requires synchronous
85+
* removal from the DOM.
8486
*/
85-
export function nativeRemoveNode(renderer: Renderer, rNode: RNode, isHostElement?: boolean): void {
86-
renderer.removeChild(null, rNode, isHostElement);
87+
export function nativeRemoveNode(
88+
renderer: Renderer,
89+
rNode: RNode,
90+
isHostElement?: boolean,
91+
requireSynchronousElementRemoval?: boolean,
92+
): void {
93+
renderer.removeChild(null, rNode, isHostElement, requireSynchronousElementRemoval);
8794
}
8895

8996
/**

packages/core/src/render3/interfaces/renderer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ export interface Renderer {
4444
destroyNode?: ((node: RNode) => void) | null;
4545
appendChild(parent: RElement, newChild: RNode): void;
4646
insertBefore(parent: RNode, newChild: RNode, refChild: RNode | null, isMove?: boolean): void;
47-
removeChild(parent: RElement | null, oldChild: RNode, isHostElement?: boolean): void;
47+
// TODO(thePunderWoman): remove the requireSynchronousElementRemoval flag once the animations package has been deleted after v23.
48+
removeChild(
49+
parent: RElement | null,
50+
oldChild: RNode,
51+
isHostElement?: boolean,
52+
requireSynchronousElementRemoval?: boolean,
53+
): void;
4854
selectRootElement(selectorOrNode: string | any, preserveContent?: boolean): RElement;
4955

5056
parentNode(node: RNode): RElement | null;

packages/core/src/render3/node_manipulation.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@ function applyToElementOrContainer(
139139
} else if (action === WalkTNodeTreeAction.Insert && parent !== null) {
140140
nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true);
141141
} else if (action === WalkTNodeTreeAction.Detach) {
142-
runLeaveAnimationsWithCallback(parentLView, () => {
143-
nativeRemoveNode(renderer, rNode, isComponent);
142+
runLeaveAnimationsWithCallback(parentLView, (nodeHasLeaveAnimations: boolean) => {
143+
// the nodeHasLeaveAnimations indicates to the renderer that the element needs to
144+
// be removed synchronously and sets the requireSynchronousElementRemoval flag in
145+
// the renderer.
146+
nativeRemoveNode(renderer, rNode, isComponent, nodeHasLeaveAnimations);
144147
});
145148
} else if (action === WalkTNodeTreeAction.Destroy) {
146149
runLeaveAnimationsWithCallback(parentLView, () => {
@@ -350,6 +353,10 @@ function cleanUpView(tView: TView, lView: LView): void {
350353
}
351354
}
352355

356+
function hasLeaveAnimations(lView: LView | undefined): boolean {
357+
return lView !== undefined && lView[ANIMATIONS] !== null && lView[ANIMATIONS].leave !== undefined;
358+
}
359+
353360
function runLeaveAnimationsWithCallback(lView: LView | undefined, callback: Function) {
354361
if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].leave) {
355362
if (lView[ANIMATIONS].skipLeaveAnimations) {
@@ -375,11 +382,11 @@ function runAfterLeaveAnimations(lView: LView | undefined, callback: Function) {
375382
lView[ANIMATIONS].running = undefined;
376383
}
377384
allLeavingAnimations.delete(lView);
378-
callback();
385+
callback(true);
379386
});
380387
return;
381388
}
382-
callback();
389+
callback(false);
383390
}
384391

385392
/** Removes listeners and unsubscribes from output subscriptions */

packages/core/test/acceptance/animation_spec.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import {fakeAsync, TestBed, tick} from '@angular/core/testing';
2323
import {By} from '@angular/platform-browser';
2424
import {isNode} from '@angular/private/testing';
2525
import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
26-
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
2726
import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing';
27+
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
2828

2929
@NgModule({
3030
providers: [provideZonelessChangeDetection()],
@@ -37,11 +37,19 @@ describe('Animation', () => {
3737
return;
3838
}
3939

40-
beforeAll(() => {
40+
beforeEach(() => {
4141
TestBed.resetTestEnvironment();
4242
TestBed.initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting());
4343
});
4444

45+
afterEach(() => {
46+
TestBed.resetTestEnvironment();
47+
TestBed.initTestEnvironment(
48+
[BrowserTestingModule, NoopAnimationsModule, TestModule],
49+
platformBrowserTesting(),
50+
);
51+
});
52+
4553
afterAll(() => {
4654
TestBed.resetTestEnvironment();
4755
TestBed.initTestEnvironment(
@@ -709,6 +717,70 @@ describe('Animation', () => {
709717
expect(fixture.nativeElement.outerHTML).not.toContain('slide-out fade ');
710718
expect(fixture.debugElement.query(By.css('child-cmp'))).toBeNull();
711719
}));
720+
721+
describe('legacy animations compatibility', () => {
722+
beforeAll(() => {
723+
TestBed.resetTestEnvironment();
724+
TestBed.initTestEnvironment(
725+
[BrowserTestingModule, NoopAnimationsModule, TestModule],
726+
platformBrowserTesting(),
727+
);
728+
});
729+
730+
const styles = `
731+
.fade {
732+
animation: fade-out 1ms;
733+
}
734+
@keyframes fade-out {
735+
from {
736+
opacity: 1;
737+
}
738+
to {
739+
opacity: 0;
740+
}
741+
}
742+
`;
743+
744+
it('should have the same exact timing when AnimationsModule is present', fakeAsync(() => {
745+
const logSpy = jasmine.createSpy('logSpy');
746+
@Component({
747+
selector: 'test-cmp',
748+
styles: styles,
749+
template:
750+
'<div>@if (show()) {<p animate.leave="fade" (animationend)="logMe($event)">I should fade</p>}</div>',
751+
encapsulation: ViewEncapsulation.None,
752+
})
753+
class TestComponent {
754+
show = signal(true);
755+
756+
logMe(event: AnimationEvent) {
757+
logSpy();
758+
}
759+
}
760+
761+
TestBed.configureTestingModule({animationsEnabled: true});
762+
763+
const fixture = TestBed.createComponent(TestComponent);
764+
const cmp = fixture.componentInstance;
765+
fixture.detectChanges();
766+
const paragragh = fixture.debugElement.query(By.css('p'));
767+
768+
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
769+
cmp.show.set(false);
770+
fixture.detectChanges();
771+
tickAnimationFrames(1);
772+
expect(cmp.show()).toBeFalsy();
773+
fixture.detectChanges();
774+
expect(fixture.nativeElement.outerHTML).toContain('class="fade"');
775+
fixture.detectChanges();
776+
paragragh.nativeElement.dispatchEvent(
777+
new AnimationEvent('animationend', {animationName: 'fade-out'}),
778+
);
779+
tick();
780+
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
781+
expect(logSpy).toHaveBeenCalled();
782+
}));
783+
});
712784
});
713785

714786
describe('animate.enter', () => {

0 commit comments

Comments
 (0)