From e3a0f7e24ac4d5ae58bcf88c7dc0c1739c44651b Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Sat, 31 Oct 2026 13:56:34 +0500 Subject: [PATCH] Fix Animated values going stale across Activity hide and reveal A stopped native animation reports its value asynchronously, which overwrote the value assigned by resetAnimation() or setValue(). TouchableOpacity resets its value in componentWillUnmount, which also runs when an Activity hides it, so it came back at activeOpacity. Updates are also dropped while hidden because the ref is detached, so a component that does not otherwise re-render on reveal kept its last committed value. Schedule an update when revealed. Co-Authored-By: Claude Opus 5.5 --- .../Animated/animations/Animation.js | 11 +++- .../Libraries/Animated/nodes/AnimatedValue.js | 4 ++ .../animated/__tests__/AnimatedNative-test.js | 59 ++++++++++++++++++- .../animated/createAnimatedPropsHook.js | 14 +++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/react-native/Libraries/Animated/animations/Animation.js b/packages/react-native/Libraries/Animated/animations/Animation.js index 83e1a715379a..495a58a83492 100644 --- a/packages/react-native/Libraries/Animated/animations/Animation.js +++ b/packages/react-native/Libraries/Animated/animations/Animation.js @@ -46,6 +46,9 @@ export default class Animation { _useNativeDriver: boolean; __active: boolean; + // Set when the value is explicitly assigned while this animation is running, + // so that its (asynchronous) native end result does not overwrite that value. + __isValueOverridden: boolean = false; __isInteraction: boolean; __isLooping: ?boolean; __iterations: number; @@ -148,14 +151,18 @@ export default class Animation { const {value, offset} = result; const syncBeforeCallback = ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback(); - if (syncBeforeCallback && value != null) { + if ( + syncBeforeCallback && + value != null && + !this.__isValueOverridden + ) { animatedValue.__onAnimatedValueUpdateReceived(value, offset); } this.__notifyAnimationEnd(result); if (value != null) { - if (!syncBeforeCallback) { + if (!syncBeforeCallback && !this.__isValueOverridden) { animatedValue.__onAnimatedValueUpdateReceived(value, offset); } diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index e033e0178f8f..dd3055a5ec02 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -141,6 +141,7 @@ export default class AnimatedValue extends AnimatedWithChildren { */ setValue(value: number): void { if (this._animation) { + this._animation.__isValueOverridden = true; this._animation.stop(); this._animation = null; } @@ -225,6 +226,9 @@ export default class AnimatedValue extends AnimatedWithChildren { * See https://reactnative.dev/docs/animatedvalue#resetanimation */ resetAnimation(callback?: ?(value: number) => void): void { + if (this._animation) { + this._animation.__isValueOverridden = true; + } this.stopAnimation(callback); this._value = this._startingValue; if (this.__isNative) { diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index f6b315d43915..8be54e9a513a 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -13,7 +13,7 @@ import typeof TNativeAnimatedModule from '../../specs_DEPRECATED/modules/NativeA import {create, unmount, update} from '@react-native/jest-preset/jest/renderer'; import {format} from 'node:util'; import * as React from 'react'; -import {createRef} from 'react'; +import {Activity, createRef} from 'react'; describe('Native Animated', () => { let NativeAnimatedModule: Exclude; @@ -1487,6 +1487,63 @@ describe('Native Animated', () => { expect(NativeAnimatedModule.stopAnimation).toBeCalledWith(animationId); }); + it.each([ + // $FlowFixMe[unclear-type] + ['resetAnimation', (value: any) => value.resetAnimation(), 1], + // $FlowFixMe[unclear-type] + ['setValue', (value: any) => value.setValue(0.5), 0.5], + ])( + 'does not let a stopped animation overwrite the value set by %s', + (_name, assignValue, expectedValue) => { + const {Animated} = importModules(); + + const value = new Animated.Value(1); + Animated.timing(value, { + toValue: 0.2, + duration: 50, + useNativeDriver: true, + }).start(); + + assignValue(value); + + // Native reports where the animation was stopped, asynchronously. + // $FlowFixMe[prop-missing] + NativeAnimatedModule.startAnimatingNode.mock.calls[0][3]({ + finished: false, + value: 0.2, + }); + + expect(value.__getValue()).toBe(expectedValue); + }, + ); + + it('commits the latest value when revealed after animating while hidden', async () => { + const {Animated} = importModules(); + + const opacity = new Animated.Value(0.2); + // A stable element, so that nothing else re-renders it when revealed. + const child = ; + + const root = await create({child}); + expect(root.toJSON()?.props.style.opacity).toBe(0.2); + + await update(root, {child}); + + Animated.timing(opacity, { + toValue: 1, + duration: 50, + useNativeDriver: true, + }).start(); + // $FlowFixMe[prop-missing] + NativeAnimatedModule.startAnimatingNode.mock.calls[0][3]({ + finished: true, + value: 1, + }); + + await update(root, {child}); + expect(root.toJSON()?.props.style.opacity).toBe(1); + }); + it('calls stopAnimation callback with native value', () => { const {Animated} = importModules(); diff --git a/packages/react-native/src/private/animated/createAnimatedPropsHook.js b/packages/react-native/src/private/animated/createAnimatedPropsHook.js index b931584dea3b..5a5f7e4d2682 100644 --- a/packages/react-native/src/private/animated/createAnimatedPropsHook.js +++ b/packages/react-native/src/private/animated/createAnimatedPropsHook.js @@ -25,6 +25,7 @@ import { useContext, useEffect, useInsertionEffect, + useLayoutEffect, useReducer, useRef, } from 'react'; @@ -97,6 +98,19 @@ export default function createAnimatedPropsHook( useAnimatedPropsLifecycle(node); + // `onUpdateRef` is cleared while hidden (e.g. by `Activity`), so values that + // changed in the meantime have not been committed. Layout effects with these + // dependencies only re-run for the same `node` when it is revealed. + const disconnectedNodeRef = useRef(null); + useLayoutEffect(() => { + if (disconnectedNodeRef.current === node) { + scheduleUpdate(); + } + return () => { + disconnectedNodeRef.current = node; + }; + }, [node]); + // TODO: This "effect" does three things: // // 1) Call `setNativeView`.