Skip to content

Commit 92b783d

Browse files
authored
fix(react-motion): cleanup animation handle on unmounted component (#35617)
1 parent f2c5878 commit 92b783d

File tree

4 files changed

+99
-62
lines changed

4 files changed

+99
-62
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: cleanup animation handle on unmounted component",
4+
"packageName": "@fluentui/react-motion",
5+
"email": "olfedias@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-motion/library/src/factories/createPresenceComponent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,17 @@ export function createPresenceComponent<MotionParams extends Record<string, Moti
241241
],
242242
);
243243

244+
React.useEffect(() => {
245+
// Heads up!
246+
//
247+
// Dispose the handle when unmounting the component to clean up retained references. Doing it in a separate
248+
// effect to ensure that the component is unmounted.
249+
250+
if (unmountOnExit && !mounted) {
251+
handleRef.current?.dispose();
252+
}
253+
}, [handleRef, unmountOnExit, mounted]);
254+
244255
if (mounted) {
245256
return child;
246257
}

packages/react-components/react-motion/library/src/hooks/useAnimateAtoms.ts

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,81 @@ const DEFAULT_REDUCED_MOTION_ATOM: NonNullable<AtomMotion['reducedMotion']> = {
1515
duration: 1,
1616
};
1717

18+
/**
19+
* Creates an animation handle that controls multiple animations.
20+
* Is used to avoid leaking "element" references from the hook.
21+
*
22+
* @param animations
23+
*/
24+
function createHandle(animations: Animation[]): AnimationHandle {
25+
return {
26+
set playbackRate(rate: number) {
27+
animations.forEach(animation => {
28+
animation.playbackRate = rate;
29+
});
30+
},
31+
setMotionEndCallbacks(onfinish: () => void, oncancel: () => void) {
32+
// Heads up!
33+
// This could use "Animation:finished", but it's causing a memory leak in Chromium.
34+
// See: https://issues.chromium.org/u/2/issues/383016426
35+
const promises = animations.map(animation => {
36+
return new Promise<void>((resolve, reject) => {
37+
animation.onfinish = () => resolve();
38+
animation.oncancel = () => reject();
39+
});
40+
});
41+
42+
Promise.all(promises)
43+
.then(() => {
44+
onfinish();
45+
})
46+
.catch(() => {
47+
oncancel();
48+
});
49+
},
50+
isRunning() {
51+
return animations.some(animation => isAnimationRunning(animation));
52+
},
53+
54+
dispose: () => {
55+
animations.length = 0;
56+
},
57+
58+
cancel: () => {
59+
animations.forEach(animation => {
60+
animation.cancel();
61+
});
62+
},
63+
pause: () => {
64+
animations.forEach(animation => {
65+
animation.pause();
66+
});
67+
},
68+
play: () => {
69+
animations.forEach(animation => {
70+
animation.play();
71+
});
72+
},
73+
finish: () => {
74+
animations.forEach(animation => {
75+
animation.finish();
76+
});
77+
},
78+
reverse: () => {
79+
// Heads up!
80+
//
81+
// This is used for the interruptible motion. If the animation is running, we need to reverse it.
82+
//
83+
// TODO: what do with animations that have "delay"?
84+
// TODO: what do with animations that have different "durations"?
85+
86+
animations.forEach(animation => {
87+
animation.reverse();
88+
});
89+
},
90+
};
91+
}
92+
1893
function useAnimateAtomsInSupportedEnvironment() {
1994
// eslint-disable-next-line @nx/workspace-no-restricted-globals
2095
const SUPPORTS_PERSIST = typeof window !== 'undefined' && typeof window.Animation?.prototype.persist === 'function';
@@ -67,68 +142,7 @@ function useAnimateAtomsInSupportedEnvironment() {
67142
})
68143
.filter(animation => !!animation) as Animation[];
69144

70-
return {
71-
set playbackRate(rate: number) {
72-
animations.forEach(animation => {
73-
animation.playbackRate = rate;
74-
});
75-
},
76-
setMotionEndCallbacks(onfinish: () => void, oncancel: () => void) {
77-
// Heads up!
78-
// This could use "Animation:finished", but it's causing a memory leak in Chromium.
79-
// See: https://issues.chromium.org/u/2/issues/383016426
80-
const promises = animations.map(animation => {
81-
return new Promise<void>((resolve, reject) => {
82-
animation.onfinish = () => resolve();
83-
animation.oncancel = () => reject();
84-
});
85-
});
86-
87-
Promise.all(promises)
88-
.then(() => {
89-
onfinish();
90-
})
91-
.catch(() => {
92-
oncancel();
93-
});
94-
},
95-
isRunning() {
96-
return animations.some(animation => isAnimationRunning(animation));
97-
},
98-
99-
cancel: () => {
100-
animations.forEach(animation => {
101-
animation.cancel();
102-
});
103-
},
104-
pause: () => {
105-
animations.forEach(animation => {
106-
animation.pause();
107-
});
108-
},
109-
play: () => {
110-
animations.forEach(animation => {
111-
animation.play();
112-
});
113-
},
114-
finish: () => {
115-
animations.forEach(animation => {
116-
animation.finish();
117-
});
118-
},
119-
reverse: () => {
120-
// Heads up!
121-
//
122-
// This is used for the interruptible motion. If the animation is running, we need to reverse it.
123-
//
124-
// TODO: what do with animations that have "delay"?
125-
// TODO: what do with animations that have different "durations"?
126-
127-
animations.forEach(animation => {
128-
animation.reverse();
129-
});
130-
},
131-
};
145+
return createHandle(animations);
132146
},
133147
[SUPPORTS_PERSIST],
134148
);
@@ -181,6 +195,10 @@ function useAnimateAtomsInTestEnvironment() {
181195
return false;
182196
},
183197

198+
dispose() {
199+
/* no-op */
200+
},
201+
184202
cancel() {
185203
/* no-op */
186204
},

packages/react-components/react-motion/library/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type PresenceMotionFn<MotionParams extends Record<string, MotionParam> =
3434
export type AnimationHandle = Pick<Animation, 'cancel' | 'finish' | 'pause' | 'play' | 'playbackRate' | 'reverse'> & {
3535
setMotionEndCallbacks: (onfinish: () => void, oncancel: () => void) => void;
3636
isRunning: () => boolean;
37+
dispose: () => void;
3738
};
3839

3940
export type MotionImperativeRef = {

0 commit comments

Comments
 (0)