Skip to content

Commit 51e4be6

Browse files
fabriziocuccifacebook-github-bot
authored andcommitted
Sync JS-side AnimatedValue before invoking native-driver completion callback
Summary: `Animation.__startAnimationIfNative` invoked the user's `start({finished})` callback BEFORE syncing the JS-side `AnimatedValue` with the post-animation value reported by the native side. Any caller that read the `AnimatedValue` from inside the callback, or from a React re-render that the callback triggered, observed the **pre-animation** value, producing visual jumps. Concrete impact: an `<Animated.View>` whose `transform: [{scaleX}]` is driven by `value.interpolate({inputRange: [0, 1], outputRange: [0.5, 1]})` would render at `scaleX = interp(0) = 0.5` after the animation finished, instead of at `scaleX = interp(1) = 1`. The same pattern affects opacity, color and any other style derived from an animated value read during a re-render scheduled by the completion callback. This change reorders the native completion callback so `animatedValue.__onAnimatedValueUpdateReceived(value, offset)` runs BEFORE `this.__notifyAnimationEnd(result)`. The user's callback (and any re-render it schedules) now observes the post-animation JS value. The reorder is gated behind a new JS-only feature flag, `animatedShouldSyncValueBeforeStartCallback`, which defaults to `true` (the fix is on by default). Set the flag to `false` to opt out and restore the pre-fix ordering as a kill-switch. A Fantom integration test in `Animated-itest.js` exercises the exact scenario: starts a `useNativeDriver: true` `Animated.timing(0 -> 1)`, captures both `_value._value` and `value.interpolate(...).__getValue()` inside the `start({finished})` callback and asserts the value matches the flag state (pre-animation when off, post-animation when on). ## Behavior change to consider The reorder also changes the order in which JS-side observers of the `AnimatedValue` are notified relative to the `start({finished})` callback. This was confirmed empirically against the current and the proposed ordering. Before (flag off): 1. `start({finished})` callback fires 2. `AnimatedValue.addListener(...)` subscribers receive the post-animation value After (flag on): 1. `AnimatedValue.addListener(...)` subscribers receive the post-animation value 2. `start({finished})` callback fires For the vast majority of callers this is irrelevant or strictly better (observers and callback now agree on the same value). The flag defaults to `true` so the fix ships immediately; the flag itself stays as a kill-switch in case real-world callers turn out to depend on the previous ordering. Once adoption has been verified the flag can be removed entirely. Changelog: [General][Fixed] - Sync JS-side `Animated.Value` with the post-animation value before invoking `Animated.timing(...).start({finished})` callbacks so reads from inside the callback (or from React re-renders it triggers) observe the post-animation value rather than the pre-animation value. Gated behind a new JS-only feature flag, `animatedShouldSyncValueBeforeStartCallback`, defaulting to `true` (set to `false` to opt out). Reviewed By: javache Differential Revision: D106940382
1 parent 7cc8c76 commit 51e4be6

4 files changed

Lines changed: 108 additions & 9 deletions

File tree

packages/react-native/Libraries/Animated/__tests__/Animated-itest.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @fantom_flags useSharedAnimatedBackend:*
7+
* @fantom_flags useSharedAnimatedBackend:* animatedShouldSyncValueBeforeStartCallback:*
88
* @flow strict-local
99
* @format
1010
*/
@@ -735,3 +735,74 @@ test('Animated.sequence', () => {
735735

736736
expect(_isSequenceFinished).toBe(true);
737737
});
738+
739+
// Regression test for native-driver completion-callback ordering.
740+
//
741+
// `Animation.__startAnimationIfNative` must sync the JS-side AnimatedValue
742+
// with the post-animation value BEFORE firing the user's `start({finished})`
743+
// callback. Otherwise, code that reads the AnimatedValue from inside the
744+
// callback — or from any React re-render the callback triggers — observes the
745+
// pre-animation value and renders stale style (e.g. a `scaleX` interpolation
746+
// that resolves to the starting `outputRange[0]` instead of the final
747+
// `outputRange[1]`).
748+
//
749+
// The fix is gated behind `animatedShouldSyncValueBeforeStartCallback`. This
750+
// test runs under both flag values (via `@fantom_flags ...:*`) and asserts:
751+
// - flag OFF: bug is present (callback observes pre-animation value).
752+
// - flag ON : bug is fixed (callback observes post-animation value).
753+
test('useNativeDriver: JS-side _value is synced before the completion callback fires', () => {
754+
let _value;
755+
let _valueInCallback = null;
756+
let _interpolationInCallback = null;
757+
758+
function MyApp() {
759+
const value = useAnimatedValue(0);
760+
_value = value;
761+
const scaleX = value.interpolate({
762+
inputRange: [0, 1],
763+
outputRange: [0.5, 1],
764+
});
765+
return (
766+
<Animated.View
767+
style={[{width: 100, height: 100}, {transform: [{scaleX}]}]}
768+
/>
769+
);
770+
}
771+
772+
const root = Fantom.createRoot();
773+
774+
Fantom.runTask(() => {
775+
root.render(<MyApp />);
776+
});
777+
778+
Fantom.runTask(() => {
779+
Animated.timing(_value, {
780+
toValue: 1,
781+
duration: 100,
782+
useNativeDriver: true,
783+
}).start(({finished}) => {
784+
if (finished) {
785+
// $FlowFixMe[prop-missing] _value is internal but stable for testing.
786+
_valueInCallback = _value._value;
787+
// $FlowFixMe[prop-missing]
788+
_interpolationInCallback = _value
789+
.interpolate({inputRange: [0, 1], outputRange: [0.5, 1]})
790+
.__getValue();
791+
}
792+
});
793+
});
794+
795+
Fantom.unstable_produceFramesForDuration(150);
796+
Fantom.runWorkLoop();
797+
798+
if (ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback()) {
799+
// With the fix: the callback observes the post-animation value.
800+
expect(_valueInCallback).toBe(1);
801+
expect(_interpolationInCallback).toBe(1);
802+
} else {
803+
// Without the fix: the callback observes the pre-animation value.
804+
// interp(0) = outputRange[0] = 0.5.
805+
expect(_valueInCallback).toBe(0);
806+
expect(_interpolationInCallback).toBe(0.5);
807+
}
808+
});

packages/react-native/Libraries/Animated/animations/Animation.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,25 @@ export default class Animation {
141141
animatedValue.__getNativeTag(),
142142
config,
143143
result => {
144+
// The user's `start({finished})` callback observes the JS-side
145+
// AnimatedValue at the moment of invocation. Historically the JS
146+
// sync happened AFTER this callback, which meant the callback (and
147+
// any React re-renders it scheduled) saw the pre-animation value.
148+
// Sync first so the callback observes the post-animation value
149+
// when the flag is enabled.
150+
const {value, offset} = result;
151+
const syncBeforeCallback =
152+
ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback();
153+
if (syncBeforeCallback && value != null) {
154+
animatedValue.__onAnimatedValueUpdateReceived(value, offset);
155+
}
156+
144157
this.__notifyAnimationEnd(result);
145158

146-
// When using natively driven animations, once the animation completes,
147-
// we need to ensure that the JS side nodes are synced with the updated
148-
// values.
149-
const {value, offset} = result;
150159
if (value != null) {
151-
animatedValue.__onAnimatedValueUpdateReceived(value, offset);
160+
if (!syncBeforeCallback) {
161+
animatedValue.__onAnimatedValueUpdateReceived(value, offset);
162+
}
152163

153164
const isJsSyncRemoved =
154165
ReactNativeFeatureFlags.cxxNativeAnimatedEnabled();
@@ -158,8 +169,8 @@ export default class Animation {
158169
}
159170
}
160171

161-
// Once the JS side node is synced with the updated values, trigger an
162-
// update on the AnimatedProps nodes to call any registered callbacks.
172+
// Trigger an update on the AnimatedProps nodes to call any
173+
// registered callbacks now that the JS-side node is in sync.
163174
this.__findAnimatedPropsNodes(animatedValue).forEach(node =>
164175
node.update(),
165176
);

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,17 @@ const definitions: FeatureFlagDefinitions = {
992992
},
993993
ossReleaseStage: 'none',
994994
},
995+
animatedShouldSyncValueBeforeStartCallback: {
996+
defaultValue: true,
997+
metadata: {
998+
dateAdded: '2026-06-01',
999+
description:
1000+
'When a useNativeDriver animation completes, syncs the JS-side AnimatedValue with the post-animation value BEFORE invoking the user-supplied start({finished}) callback. Without the flag, the callback observes the pre-animation value, which can cause downstream re-renders to read stale interpolation outputs.',
1001+
expectedReleaseValue: true,
1002+
purpose: 'experimentation',
1003+
},
1004+
ossReleaseStage: 'none',
1005+
},
9951006
animatedShouldUseSingleOp: {
9961007
defaultValue: false,
9971008
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<7f48f734cd7a098d04cb147980ef364a>>
7+
* @generated SignedSource<<1f78266600508274a623ff1032fa7124>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -30,6 +30,7 @@ import {
3030
export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3131
jsOnlyTestFlag: Getter<boolean>,
3232
animatedShouldDebounceQueueFlush: Getter<boolean>,
33+
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
3334
animatedShouldUseSingleOp: Getter<boolean>,
3435
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
3536
enableNativeEventTargetEventDispatching: Getter<boolean>,
@@ -145,6 +146,11 @@ export const jsOnlyTestFlag: Getter<boolean> = createJavaScriptFlagGetter('jsOnl
145146
*/
146147
export const animatedShouldDebounceQueueFlush: Getter<boolean> = createJavaScriptFlagGetter('animatedShouldDebounceQueueFlush', false);
147148

149+
/**
150+
* When a useNativeDriver animation completes, syncs the JS-side AnimatedValue with the post-animation value BEFORE invoking the user-supplied start({finished}) callback. Without the flag, the callback observes the pre-animation value, which can cause downstream re-renders to read stale interpolation outputs.
151+
*/
152+
export const animatedShouldSyncValueBeforeStartCallback: Getter<boolean> = createJavaScriptFlagGetter('animatedShouldSyncValueBeforeStartCallback', true);
153+
148154
/**
149155
* Enables an experimental mega-operation for Animated.js that replaces many calls to native with a single call into native, to reduce JSI/JNI traffic.
150156
*/

0 commit comments

Comments
 (0)