Skip to content

Commit 318ade0

Browse files
thePunderWomanmattrbeck
authored andcommitted
refactor(core): Ensure determineLongestAnimation is run synchronously after style applies
This adds a setTimeout, which guarantees that we call getAnimations one frame after a reflow is finished. This means getAnimations will return data, avoiding needing the expensive fallback of getComputedStyles. It also updates the cleanup to prevent a potential memory leak if the component is destroyed before the timeout runs.
1 parent df659b8 commit 318ade0

2 files changed

Lines changed: 101 additions & 23 deletions

File tree

packages/core/src/render3/instructions/animation.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ export function runEnterAnimation(
136136

137137
// We only need to add these event listeners if there are actual classes to apply
138138
if (activeClasses && activeClasses.length > 0) {
139+
let isCleanedUp = false;
140+
cleanupFns.push(() => {
141+
isCleanedUp = true;
142+
});
143+
139144
ngZone.runOutsideAngular(() => {
140145
cleanupFns.push(renderer.listen(nativeElement, 'animationstart', handleEnterAnimationStart));
141146
cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleEnterAnimationStart));
@@ -152,14 +157,16 @@ export function runEnterAnimation(
152157
// preventing an animation via selector specificity.
153158
ngZone.runOutsideAngular(() => {
154159
requestAnimationFrame(() => {
155-
if (hasCompleted) return;
156-
determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported);
157-
if (!longestAnimations.has(nativeElement)) {
158-
for (const klass of activeClasses) {
159-
renderer.removeClass(nativeElement, klass);
160+
setTimeout(() => {
161+
if (hasCompleted || isCleanedUp) return;
162+
determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported);
163+
if (!longestAnimations.has(nativeElement)) {
164+
for (const klass of activeClasses) {
165+
renderer.removeClass(nativeElement, klass);
166+
}
167+
cleanupEnterClassData(nativeElement);
160168
}
161-
cleanupEnterClassData(nativeElement);
162-
}
169+
});
163170
});
164171
});
165172
}
@@ -326,6 +333,14 @@ function animateLeaveClassRunner(
326333
const componentResolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
327334
let fallbackTimeoutId: number | undefined;
328335
let hasCompleted = false;
336+
let isCleanedUp = false;
337+
338+
cleanupFns.push(() => {
339+
isCleanedUp = true;
340+
if (fallbackTimeoutId !== undefined) {
341+
clearTimeout(fallbackTimeoutId);
342+
}
343+
});
329344

330345
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
331346
const target = getEventTarget(event as Event);
@@ -378,21 +393,22 @@ function animateLeaveClassRunner(
378393
// preventing an animation via selector specificity.
379394
ngZone.runOutsideAngular(() => {
380395
requestAnimationFrame(() => {
381-
if (hasCompleted) return;
382-
determineLongestAnimation(el, longestAnimations, areAnimationSupported);
383-
const longest = longestAnimations.get(el);
384-
if (!longest) {
385-
clearLeavingNodes(tNode, el);
386-
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
387-
clearLViewNodeAnimationResolvers(lView, tNode);
388-
} else {
389-
// Fallback cleanup if the browser drops the transitionend/animationend event
390-
// entirely due to off-screen optimizations or rapid DOM teardown.
391-
fallbackTimeoutId = setTimeout(() => {
392-
handleOutAnimationEnd(new CustomEvent('animation-fallback'));
393-
}, longest.duration + 50) as unknown as number;
394-
cleanupFns.push(() => clearTimeout(fallbackTimeoutId));
395-
}
396+
setTimeout(() => {
397+
if (hasCompleted || isCleanedUp) return;
398+
determineLongestAnimation(el, longestAnimations, areAnimationSupported);
399+
const longest = longestAnimations.get(el);
400+
if (!longest) {
401+
clearLeavingNodes(tNode, el);
402+
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
403+
clearLViewNodeAnimationResolvers(lView, tNode);
404+
} else {
405+
// Fallback cleanup if the browser drops the transitionend/animationend event
406+
// entirely due to off-screen optimizations or rapid DOM teardown.
407+
fallbackTimeoutId = setTimeout(() => {
408+
handleOutAnimationEnd(new CustomEvent('animation-fallback'));
409+
}, longest.duration + 50) as unknown as number;
410+
}
411+
});
396412
});
397413
});
398414
}

packages/core/test/acceptance/animation_spec.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
ViewChild,
3232
ViewContainerRef,
3333
} from '@angular/core';
34-
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
34+
import {fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
3535
import {By} from '@angular/platform-browser';
3636
import {isNode} from '@angular/private/testing';
3737
import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
@@ -171,6 +171,68 @@ describe('Animation', () => {
171171
expect(cmp.el).toBeUndefined();
172172
}));
173173

174+
it('should fire the fallback timer and clean up if the animationend event is not dispatched', fakeAsync(() => {
175+
@Component({
176+
selector: 'test-cmp',
177+
styles: styles,
178+
template: '<div>@if (show()) {<p animate.leave="fade" #el>I should fade</p>}</div>',
179+
encapsulation: ViewEncapsulation.None,
180+
})
181+
class TestComponent {
182+
show = signal(true);
183+
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
184+
}
185+
TestBed.configureTestingModule({animationsEnabled: true});
186+
187+
const fixture = TestBed.createComponent(TestComponent);
188+
const cmp = fixture.componentInstance;
189+
fixture.detectChanges();
190+
191+
expect(fixture.nativeElement.innerHTML).toContain('I should fade');
192+
cmp.show.set(false);
193+
fixture.detectChanges();
194+
195+
tickAnimationFrames(1);
196+
197+
// Now we step the tick so the macro-task setup timeout executes and queues our fallback.
198+
tick(1);
199+
200+
// Wait for duration + 50ms buffer time for fallback
201+
tick(10 + 50);
202+
203+
// Assert that cleanup occurred successfully without needing to artificially dispatch the event
204+
expect(fixture.nativeElement.innerHTML).not.toContain('I should fade');
205+
expect(cmp.el).toBeUndefined();
206+
}));
207+
208+
it('should prevent leaking fallback timeout if view is destroyed early', fakeAsync(() => {
209+
@Component({
210+
selector: 'test-cmp',
211+
styles: styles,
212+
template: '<div>@if (show()) {<p animate.leave="fade" #el>I should fade</p>}</div>',
213+
encapsulation: ViewEncapsulation.None,
214+
})
215+
class TestComponent {
216+
show = signal(true);
217+
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
218+
}
219+
TestBed.configureTestingModule({animationsEnabled: true});
220+
221+
const fixture = TestBed.createComponent(TestComponent);
222+
const cmp = fixture.componentInstance;
223+
fixture.detectChanges();
224+
225+
cmp.show.set(false);
226+
fixture.detectChanges();
227+
228+
// We explicitly destroy the fixture right after triggering the leave,
229+
// imitating the situation where the view goes away before the next macro-task.
230+
fixture.destroy();
231+
232+
// Flush all tasks, expecting no errors with our cleanup flag guarding the timeout.
233+
flush();
234+
}));
235+
174236
it('should support string arrays', fakeAsync(() => {
175237
const multiple = `
176238
.slide-out {

0 commit comments

Comments
 (0)