From 05c7cc84e7ac73cea8557c33eb15ee7c173b33c8 Mon Sep 17 00:00:00 2001 From: Martin Booth Date: Wed, 2 Jul 2025 11:16:45 -0700 Subject: [PATCH] Share the same Animated.Value amongst transitions that happen in the same commit Right now, updating state which affects transitioning properties accross multiple components results in each component creating its own Animated.Value and starting the animation in an effect. This results in animations that are not in sync as they should be. In this PR, transitions with the same delay, duration, timing function share the same Animated.Value as long as they're part of the same commit. useLayoutEffect is used to either create the Animated.Value or find an existing suitable one to use. Since all layout effects run before effects, we can assume that by the time any component's effect runs, all Animated.Values have been created or shared. One of the component's effects will then kick off the animation and empty the shared animation config map so that future commits don't reuse any of the Animated.Values that were created. The other component's effects will be no-ops. Finally, reference counting is used to ensure only the last component that unmounts would stop the animation if it was still running since now they are shared it would be disruptive to stop the animation unless the component was the last one relying on it --- .../src/native/modules/useStyleTransition.js | 108 +++++++++++++----- 1 file changed, 80 insertions(+), 28 deletions(-) diff --git a/packages/react-strict-dom/src/native/modules/useStyleTransition.js b/packages/react-strict-dom/src/native/modules/useStyleTransition.js index 82d6fe7d..69178d17 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleTransition.js +++ b/packages/react-strict-dom/src/native/modules/useStyleTransition.js @@ -14,7 +14,7 @@ import type { ReactNativeTransform } from '../../types/renderer.native'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { errorMsg, warnMsg } from '../../shared/logUtils'; import { Animated, Easing } from 'react-native'; @@ -29,6 +29,13 @@ type TransitionMetadata = $ReadOnly<{ shouldUseNativeDriver: boolean }>; +type AnimatedConfig = { + start: () => void, + dispose: () => void, + value: Animated.Value, + referenceCount: number +}; + const INPUT_RANGE: $ReadOnlyArray = [0, 1]; function isNumber(num: mixed): num is number { @@ -260,6 +267,53 @@ function getAnimation( }); } +const animatedConfigs = new Map(); + +function getOrCreateAnimatedConfig(transitionMetadata: TransitionMetadata) { + const key = JSON.stringify(transitionMetadata); + + const animatedConfig = animatedConfigs.get(key); + if (animatedConfig != null) { + animatedConfig.referenceCount++; + + return animatedConfig; + } + + const animatedValue = new Animated.Value(0); + let hasStarted = false; + let animation; + const newAnimatedConfig = { + referenceCount: 1, + value: animatedValue, + start: () => { + if (hasStarted) { + return; + } + hasStarted = true; + const { delay, duration, timingFunction, shouldUseNativeDriver } = + transitionMetadata; + animation = Animated.sequence([ + Animated.delay(delay), + getAnimation( + animatedValue, + duration, + timingFunction, + shouldUseNativeDriver + ) + ]); + animation.start(); + }, + dispose: () => { + if (--newAnimatedConfig.referenceCount === 0) { + animation?.stop(); + } + } + }; + animatedConfigs.set(key, newAnimatedConfig); + + return newAnimatedConfig; +} + export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { const { transitionDelay: _delay, @@ -292,7 +346,7 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { undefined ); - const [animatedValue, setAnimatedValue] = useState( + const [animatedConfig, setAnimatedConfig] = useState( undefined ); @@ -321,28 +375,32 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { ]); // effect to trigger a transition - // REMEMBER: it is super important that this effect's dependency array **only** contains the animated value + // REMEMBER: it is super important that this effect's dependency array **only** contains the animated config useEffect(() => { - if (animatedValue !== undefined) { - const { delay, duration, timingFunction, shouldUseNativeDriver } = - transitionMetadataRef.current; + if (animatedConfig == null) { + return; + } + animatedConfig.start(); + animatedConfigs.clear(); + return () => { + animatedConfig.dispose(); + }; + }, [animatedConfig]); - const animation = Animated.sequence([ - Animated.delay(delay), - getAnimation( - animatedValue, - duration, - timingFunction, - shouldUseNativeDriver - ) - ]); - animation.start(); + const transitionStyleHasChangedResult = transitionStyleHasChanged( + transitionStyle, + currentStyle + ); - return () => { - animation.stop(); - }; + useLayoutEffect(() => { + if (transitionStyleHasChangedResult) { + setCurrentStyle(style); + setPreviousStyle(currentStyle); + setAnimatedConfig( + getOrCreateAnimatedConfig(transitionMetadataRef.current) + ); } - }, [animatedValue]); + }, [currentStyle, style, transitionStyleHasChangedResult]); if ( _delay == null && @@ -354,18 +412,12 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { return style; } - if (transitionStyleHasChanged(transitionStyle, currentStyle)) { - setCurrentStyle(style); - setPreviousStyle(currentStyle); - setAnimatedValue(new Animated.Value(0)); - // This commit will be thrown away due to the above state setters so we can bail out early - return style; - } - if (transitionStyle === undefined) { return style; } + const animatedValue = animatedConfig?.value; + const outputAnimatedStyle: AnimatedStyle = Object.entries( transitionStyle ).reduce((animatedStyle, [property, value]) => {