Skip to content

Commit c2dec74

Browse files
zeyapfacebook-github-bot
authored andcommitted
Fix native-driven props dropped on Animated.FlatList/SectionList under shared animated backend (#57178)
Summary: ## Changelog: [Internal][Fixed] - Fix native-driven props dropped on Animated.FlatList/SectionList under shared animated backend Under the shared animated backend (`cxxNativeAnimatedEnabled` + `useSharedAnimatedBackend`), a `useNativeDriver: true` animation on `Animated.FlatList` or `Animated.SectionList` (or any class composite that wraps a host) was silently dropped — e.g. an opacity fade never committed, so the content stayed at its initial value. Root cause: `AnimatedProps.#connectShadowNode` resolves the target shadow node via `getNodeFromPublicInstance(target.instance)`. `FlatList`/`SectionList` are class components (not `forwardRef`-to-host), so the ref is the composite instance, which has no `__internalInstanceHandle`; `getNodeFromPublicInstance` returns `null` and `connectAnimatedNodeToShadowNodeFamily` is never called. The C++ shared backend commits animated props only through a connected `ShadowNodeFamily` and has no viewTag fallback when `useSharedAnimatedBackend` is enabled, so the value is dropped. `Animated.View` and `Animated.ScrollView` are unaffected because their ref is the host instance; with `cxxNativeAnimatedEnabled` alone the legacy viewTag path still commits, so this only regresses once the shared backend is on. Fix: in `#connectShadowNode`, resolve the shadow node from the host instance rather than the composite — try the instance, then `getNativeScrollRef()` (covers `FlatList`/`SectionList`), then fall back to the host tag that `#connectAnimatedView` already resolved via `findNodeHandle`, looked up natively with `findShadowNodeByTag_DEPRECATED`. The `#connectAnimatedView` viewTag path is unchanged. Reviewed By: cipolleschi, javache Differential Revision: D108155219
1 parent 861d8e0 commit c2dec74

2 files changed

Lines changed: 144 additions & 3 deletions

File tree

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

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type {HostInstance} from 'react-native';
1515

1616
import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance';
1717
import * as Fantom from '@react-native/fantom';
18-
import {createRef, memo, useEffect, useMemo, useState} from 'react';
18+
import * as React from 'react';
19+
import {Component, createRef, memo, useEffect, useMemo, useState} from 'react';
1920
import {Animated, View, useAnimatedValue} from 'react-native';
2021
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
2122
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
@@ -75,6 +76,122 @@ test('animated opacity', () => {
7576
);
7677
});
7778

79+
// ScrollView's ref is the host instance, so it resolves directly (sanity check
80+
// that the fix doesn't regress it).
81+
test('animated opacity on Animated.ScrollView', () => {
82+
let _opacity;
83+
let _opacityAnimation;
84+
85+
function MyApp() {
86+
const opacity = useAnimatedValue(1);
87+
_opacity = opacity;
88+
return (
89+
<Animated.ScrollView style={{opacity}}>
90+
<View style={{width: 100, height: 100}} />
91+
</Animated.ScrollView>
92+
);
93+
}
94+
95+
const root = Fantom.createRoot();
96+
Fantom.runTask(() => {
97+
root.render(<MyApp />);
98+
});
99+
100+
Fantom.runTask(() => {
101+
_opacityAnimation = Animated.timing(_opacity, {
102+
toValue: 0,
103+
duration: 30,
104+
useNativeDriver: true,
105+
}).start();
106+
});
107+
Fantom.unstable_produceFramesForDuration(30);
108+
Fantom.runTask(() => {
109+
_opacityAnimation?.stop();
110+
});
111+
112+
expect(
113+
JSON.stringify(root.getRenderedOutput({props: ['opacity']}).toJSON()),
114+
).toContain('"opacity":"0"');
115+
});
116+
117+
test('animated opacity on Animated.FlatList', () => {
118+
let _opacity;
119+
let _opacityAnimation;
120+
121+
function MyApp() {
122+
const opacity = useAnimatedValue(1);
123+
_opacity = opacity;
124+
return (
125+
<Animated.FlatList
126+
data={[] as Array<string>}
127+
renderItem={() => null}
128+
style={{opacity}}
129+
/>
130+
);
131+
}
132+
133+
const root = Fantom.createRoot();
134+
Fantom.runTask(() => {
135+
root.render(<MyApp />);
136+
});
137+
138+
Fantom.runTask(() => {
139+
_opacityAnimation = Animated.timing(_opacity, {
140+
toValue: 0,
141+
duration: 30,
142+
useNativeDriver: true,
143+
}).start();
144+
});
145+
Fantom.unstable_produceFramesForDuration(30);
146+
Fantom.runTask(() => {
147+
_opacityAnimation?.stop();
148+
});
149+
150+
expect(
151+
JSON.stringify(root.getRenderedOutput({props: ['opacity']}).toJSON()),
152+
).toContain('"opacity":"0"');
153+
});
154+
155+
// A class composite uses the findShadowNodeByTag fallback path in #connectShadowNode.
156+
test('animated opacity on a class composite wrapping a host', () => {
157+
let _opacity;
158+
let _opacityAnimation;
159+
160+
class HostWrapper extends Component<{style?: $FlowFixMe}> {
161+
render(): React.Node {
162+
return <View style={this.props.style} />;
163+
}
164+
}
165+
const AnimatedHostWrapper = Animated.createAnimatedComponent(HostWrapper);
166+
167+
function MyApp() {
168+
const opacity = useAnimatedValue(1);
169+
_opacity = opacity;
170+
return <AnimatedHostWrapper style={{width: 100, height: 100, opacity}} />;
171+
}
172+
173+
const root = Fantom.createRoot();
174+
Fantom.runTask(() => {
175+
root.render(<MyApp />);
176+
});
177+
178+
Fantom.runTask(() => {
179+
_opacityAnimation = Animated.timing(_opacity, {
180+
toValue: 0,
181+
duration: 30,
182+
useNativeDriver: true,
183+
}).start();
184+
});
185+
Fantom.unstable_produceFramesForDuration(30);
186+
Fantom.runTask(() => {
187+
_opacityAnimation?.stop();
188+
});
189+
190+
expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual(
191+
<rn-view opacity="0" />,
192+
);
193+
});
194+
78195
test('animate layout props', () => {
79196
const viewRef = createRef<HostInstance>();
80197
allowStyleProp('height');

packages/react-native/Libraries/Animated/nodes/AnimatedProps.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {AnimatedStyleAllowlist} from './AnimatedStyle';
1515

1616
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
1717
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
18+
import {getFabricUIManager} from '../../ReactNative/FabricUIManager';
1819
import {findNodeHandle} from '../../ReactNative/RendererProxy';
1920
import {getNodeFromPublicInstance} from '../../ReactPrivate/ReactNativePrivateInterface';
2021
import flattenStyle from '../../StyleSheet/flattenStyle';
@@ -298,8 +299,31 @@ export default class AnimatedProps extends AnimatedNode {
298299
}
299300

300301
invariant(this.__isNative, 'Expected node to be marked as "native"');
301-
// $FlowExpectedError[incompatible-type] - target.instance may be an HTMLElement but we need ReactNativeElement for Fabric
302-
const shadowNode = getNodeFromPublicInstance(target.instance);
302+
// Host components and ScrollView (whose ref is the host instance) resolve a
303+
// shadow node directly; FlatList/SectionList are class composites that expose
304+
// the host via getNativeScrollRef().
305+
// $FlowFixMe[unclear-type] - Legacy instance assumptions.
306+
const instance: any = target.instance;
307+
const candidates = [instance, instance?.getNativeScrollRef?.()];
308+
let shadowNode = null;
309+
for (const candidate of candidates) {
310+
if (candidate == null) {
311+
continue;
312+
}
313+
shadowNode = getNodeFromPublicInstance(candidate);
314+
if (shadowNode != null) {
315+
break;
316+
}
317+
}
318+
// Any other class composite: resolve from the host tag #connectAnimatedView
319+
// already found via findNodeHandle (the lookup runs on the native side).
320+
const connectedViewTag = target.connectedViewTag;
321+
if (shadowNode == null && connectedViewTag != null) {
322+
shadowNode =
323+
getFabricUIManager()?.findShadowNodeByTag_DEPRECATED?.(
324+
connectedViewTag,
325+
);
326+
}
303327
if (shadowNode == null) {
304328
return;
305329
}

0 commit comments

Comments
 (0)