Skip to content

Commit 93d2a9f

Browse files
Bartlomiej Bloniarzmeta-codesync[bot]
authored andcommitted
Keep non-layout animations on the synchronous path while another view animates layout (#58772)
Summary: Pull Request resolved: #58772 The shared animation backend decided per surface whether a frame's animated props go through a shadow tree commit or straight to the mounted views. As soon as one view animated a layout prop, every animated view on that surface went through the commit path for the whole animation, including views that only animate `transform` or `opacity`. Besides the extra commit work, on Android this moved those views from `updatePropsSynchronously` to regular mounts. There the synchronous mount props cache (`overrideBySynchronousMountPropsAtMountingAndroid`) replaced the incoming transform with its last synchronously written value, so the view froze while the other view's layout animation ran and jumped when it ended. The decision is now made per view. `applySurfaceUpdates` splits a surface's mutations, keyed by view tag, into views with layout updates, which go through `commitUpdates`, and the rest, which are applied synchronously. Views with layout updates are also written synchronously, as the non-shared Animated path already does, so a view that starts animating a layout prop while animating `opacity` doesn't freeze its opacity behind the cache either. `AnimationMutation` and `AnimationMutations` move to `AnimationMutation.h`, still included by `AnimationBackend.h`. `AnimatedPropsRegistry::update` reads the frame's batches instead of the merged per-surface map. When the same view receives several mutations in one frame, they are merged, so the mounted view and the registry stay in sync. Raw animated props are now merged per key, like the animation drivers merge them, instead of with merge-patch, which dropped `null` values meant to reset a prop. `RawProps::getDynamic` exposes a dynamic source without copying it. ## Changelog: [General] [Fixed] - Non-layout props animated on the shared animation backend no longer freeze while the same view or another view on the surface animates a layout prop Differential Revision: D122570616
1 parent 4e6bf24 commit 93d2a9f

18 files changed

Lines changed: 410 additions & 154 deletions

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

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import * as Fantom from '@react-native/fantom';
1717
import nullthrows from 'nullthrows';
1818
import * as React from 'react';
1919
import {Component, createRef, memo, useEffect, useMemo, useState} from 'react';
20-
import {Animated, View, useAnimatedValue} from 'react-native';
20+
import {Animated, Easing, View, useAnimatedValue} from 'react-native';
2121

2222
// marginLeft (and the other margin props) are only on the native animated
2323
// allowlist when the shared backend is enabled. This verifies the prop is
@@ -77,6 +77,196 @@ test('animate marginLeft layout prop', () => {
7777
);
7878
});
7979

80+
test('non-layout props stay on the direct path while another view animates layout', () => {
81+
const movingRef = createRef<HostInstance>();
82+
83+
let _translateX;
84+
let _translateXAnimation;
85+
let _siblingHeight;
86+
let _siblingHeightAnimation;
87+
88+
function MyApp() {
89+
const translateX = useAnimatedValue(0);
90+
const siblingHeight = useAnimatedValue(10);
91+
_translateX = translateX;
92+
_siblingHeight = siblingHeight;
93+
return (
94+
<View collapsable={false}>
95+
<Animated.View
96+
ref={movingRef}
97+
style={{width: 100, height: 100, transform: [{translateX}]}}
98+
/>
99+
<Animated.View style={{width: 100, height: siblingHeight}} />
100+
</View>
101+
);
102+
}
103+
104+
const root = Fantom.createRoot();
105+
106+
Fantom.runTask(() => {
107+
root.render(<MyApp />);
108+
});
109+
110+
Fantom.runTask(() => {
111+
_translateXAnimation = Animated.timing(_translateX, {
112+
toValue: 100,
113+
duration: 200,
114+
useNativeDriver: true,
115+
}).start();
116+
_siblingHeightAnimation = Animated.timing(_siblingHeight, {
117+
toValue: 110,
118+
duration: 200,
119+
useNativeDriver: true,
120+
}).start();
121+
});
122+
123+
Fantom.unstable_produceFramesForDuration(100);
124+
125+
// The sibling's height went through a commit; the transform did not.
126+
expect(root.getRenderedOutput({props: ['height']}).toJSX()).toEqual(
127+
<rn-view>
128+
<rn-view key={0} height="100" />
129+
<rn-view key={1} height="60" />
130+
</rn-view>,
131+
);
132+
expect(
133+
Fantom.unstable_getDirectManipulationProps(nullthrows(movingRef.current))
134+
.transform,
135+
).toEqual([{translateX: 50}]);
136+
137+
Fantom.runTask(() => {
138+
_translateXAnimation?.stop();
139+
_siblingHeightAnimation?.stop();
140+
});
141+
});
142+
143+
test('direct-path props survive a re-render while another view animates layout', () => {
144+
let _opacity;
145+
let _opacityAnimation;
146+
let _siblingHeight;
147+
let _siblingHeightAnimation;
148+
let _setWidth;
149+
150+
function MyApp() {
151+
const opacity = useAnimatedValue(0);
152+
const siblingHeight = useAnimatedValue(10);
153+
const [width, setWidth] = useState(100);
154+
_opacity = opacity;
155+
_siblingHeight = siblingHeight;
156+
_setWidth = setWidth;
157+
return (
158+
<View collapsable={false}>
159+
<Animated.View style={{width, height: 100, opacity}} />
160+
<Animated.View style={{width: 100, height: siblingHeight}} />
161+
</View>
162+
);
163+
}
164+
165+
const root = Fantom.createRoot();
166+
167+
Fantom.runTask(() => {
168+
root.render(<MyApp />);
169+
});
170+
171+
Fantom.runTask(() => {
172+
_opacityAnimation = Animated.timing(_opacity, {
173+
toValue: 0.5,
174+
duration: 1000,
175+
useNativeDriver: true,
176+
}).start();
177+
_siblingHeightAnimation = Animated.timing(_siblingHeight, {
178+
toValue: 110,
179+
duration: 1000,
180+
useNativeDriver: true,
181+
}).start();
182+
});
183+
184+
Fantom.unstable_produceFramesForDuration(500);
185+
186+
Fantom.runTask(() => {
187+
_setWidth(150);
188+
});
189+
190+
expect(
191+
root.getRenderedOutput({props: ['opacity', 'width', 'height']}).toJSX(),
192+
).toEqual(
193+
<rn-view>
194+
<rn-view key={0} opacity="0.25" width="150" height="100" />
195+
<rn-view key={1} width="100" height="60" />
196+
</rn-view>,
197+
);
198+
199+
Fantom.runTask(() => {
200+
_opacityAnimation?.stop();
201+
_siblingHeightAnimation?.stop();
202+
});
203+
});
204+
205+
test('a view keeps writing its props directly after it starts animating layout', () => {
206+
const viewRef = createRef<HostInstance>();
207+
208+
let _opacity;
209+
let _opacityAnimation;
210+
let _height;
211+
let _heightAnimation;
212+
213+
function MyApp() {
214+
const opacity = useAnimatedValue(0);
215+
const height = useAnimatedValue(10);
216+
_opacity = opacity;
217+
_height = height;
218+
return (
219+
<Animated.View ref={viewRef} style={{width: 100, height, opacity}} />
220+
);
221+
}
222+
223+
const root = Fantom.createRoot();
224+
225+
Fantom.runTask(() => {
226+
root.render(<MyApp />);
227+
});
228+
229+
const viewElement = nullthrows(viewRef.current);
230+
231+
Fantom.runTask(() => {
232+
_opacityAnimation = Animated.timing(_opacity, {
233+
toValue: 0.5,
234+
duration: 1000,
235+
easing: Easing.linear,
236+
useNativeDriver: true,
237+
}).start();
238+
});
239+
240+
Fantom.unstable_produceFramesForDuration(500);
241+
242+
expect(
243+
Fantom.unstable_getDirectManipulationProps(viewElement).opacity,
244+
).toBeCloseTo(0.25, 0.001);
245+
246+
Fantom.runTask(() => {
247+
_heightAnimation = Animated.timing(_height, {
248+
toValue: 110,
249+
duration: 1000,
250+
easing: Easing.linear,
251+
useNativeDriver: true,
252+
}).start();
253+
});
254+
255+
Fantom.unstable_produceFramesForDuration(250);
256+
257+
expect(root.getRenderedOutput({props: ['height']}).toJSX()).toEqual(
258+
<rn-view height="35" />,
259+
);
260+
expect(
261+
Fantom.unstable_getDirectManipulationProps(viewElement).opacity,
262+
).toBeCloseTo(0.375, 0.001);
263+
264+
Fantom.runTask(() => {
265+
_opacityAnimation?.stop();
266+
_heightAnimation?.stop();
267+
});
268+
});
269+
80270
test('animated opacity', () => {
81271
let _opacity;
82272
let _opacityAnimation;

‎packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.cpp‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,40 @@
66
*/
77

88
#include "AnimatedPropsRegistry.h"
9+
#include <react/debug/react_native_assert.h>
910
#include <react/renderer/core/PropsParserContext.h>
1011
#include "AnimatedProps.h"
1112

1213
namespace facebook::react {
1314

15+
void mergeAnimatedRawProps(
16+
folly::dynamic& target,
17+
const folly::dynamic& source) {
18+
if (!target.isObject() || !source.isObject()) {
19+
target = source;
20+
return;
21+
}
22+
for (const auto& [key, value] : source.items()) {
23+
target[key] = value;
24+
}
25+
}
26+
1427
void AnimatedPropsRegistry::update(
15-
const std::unordered_map<SurfaceId, SurfaceUpdates>& surfaceUpdates) {
28+
const std::vector<AnimationMutations>& batches) {
1629
auto lock = std::lock_guard(mutex_);
17-
for (const auto& [surfaceId, updates] : surfaceUpdates) {
18-
auto contextIt = surfaceContexts_.find(surfaceId);
19-
if (contextIt == surfaceContexts_.end()) {
20-
continue;
21-
}
22-
auto& surfaceContext = contextIt->second;
23-
auto& pendingMap = surfaceContext.pendingMap;
24-
auto& pendingFamilies = surfaceContext.pendingFamilies;
25-
26-
auto& updatesMap = updates.propsMap;
27-
auto& updatesFamilies = updates.families;
28-
29-
for (auto& family : updatesFamilies) {
30-
pendingFamilies.insert(family);
31-
}
32-
33-
for (auto& [tag, animatedProps] : updatesMap) {
30+
for (const auto& mutations : batches) {
31+
for (const auto& mutation : mutations.batch) {
32+
const auto& family = mutation.family;
33+
react_native_assert(family != nullptr);
34+
auto contextIt = surfaceContexts_.find(family->getSurfaceId());
35+
if (contextIt == surfaceContexts_.end()) {
36+
continue;
37+
}
38+
auto& surfaceContext = contextIt->second;
39+
auto& pendingMap = surfaceContext.pendingMap;
40+
surfaceContext.pendingFamilies.insert(family);
41+
const auto tag = mutation.tag;
42+
const auto& animatedProps = mutation.props;
3443
auto it = pendingMap.find(tag);
3544
if (it == pendingMap.end()) {
3645
it = pendingMap.insert_or_assign(tag, std::make_unique<PropsSnapshot>())
@@ -44,8 +53,7 @@ void AnimatedPropsRegistry::update(
4453
auto& currentRawProps = snapshot->rawProps;
4554

4655
if (currentRawProps) {
47-
auto newRawPropsDynamic = newRawProps.toDynamic();
48-
currentRawProps->merge_patch(newRawPropsDynamic);
56+
mergeAnimatedRawProps(*currentRawProps, newRawProps.toDynamic());
4957
} else {
5058
currentRawProps =
5159
std::make_unique<folly::dynamic>(newRawProps.toDynamic());
@@ -83,7 +91,8 @@ AnimatedPropsRegistry::getMap(SurfaceId surfaceId) {
8391
auto& currentSnapshot = currentIt->second;
8492
if (propsSnapshot->rawProps) {
8593
if (currentSnapshot->rawProps) {
86-
currentSnapshot->rawProps->merge_patch(*propsSnapshot->rawProps);
94+
mergeAnimatedRawProps(
95+
*currentSnapshot->rawProps, *propsSnapshot->rawProps);
8796
} else {
8897
currentSnapshot->rawProps = std::move(propsSnapshot->rawProps);
8998
}

‎packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.h‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <react/renderer/uimanager/UIManager.h>
1616
#include <react/renderer/uimanager/UIManagerCommitHook.h>
1717
#include "AnimatedProps.h"
18+
#include "AnimationMutation.h"
1819

1920
namespace facebook::react {
2021

@@ -29,17 +30,17 @@ struct SurfaceContext {
2930
std::unordered_set<std::shared_ptr<const ShadowNodeFamily>> pendingFamilies, families;
3031
};
3132

32-
struct SurfaceUpdates {
33-
std::unordered_set<std::shared_ptr<const ShadowNodeFamily>> families;
34-
std::unordered_map<Tag, AnimatedProps> propsMap;
35-
bool hasLayoutUpdates{false};
36-
};
37-
3833
using SnapshotMap = std::unordered_map<Tag, std::unique_ptr<PropsSnapshot>>;
3934

35+
/*
36+
* Merges raw animated props per key, like the props merged by the animation
37+
* drivers. A null value is kept, so it still resets the prop on commit.
38+
*/
39+
void mergeAnimatedRawProps(folly::dynamic &target, const folly::dynamic &source);
40+
4041
class AnimatedPropsRegistry {
4142
public:
42-
void update(const std::unordered_map<SurfaceId, SurfaceUpdates> &surfaceUpdates);
43+
void update(const std::vector<AnimationMutations> &batches);
4344
void initializeSurface(SurfaceId surfaceId);
4445
void clear(SurfaceId surfaceId);
4546
void clearOnSurfaceStop(SurfaceId surfaceId);

0 commit comments

Comments
 (0)