From c2dec74783f9fddceb299fb4e44e2ec5b6d907d8 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Thu, 11 Jun 2026 13:43:56 -0700 Subject: [PATCH] Fix native-driven props dropped on Animated.FlatList/SectionList under shared animated backend (#57178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/AnimatedBackend-itest.js | 119 +++++++++++++++++- .../Libraries/Animated/nodes/AnimatedProps.js | 28 ++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index 9db64ffb80bb..adeaf7e485cf 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -15,7 +15,8 @@ import type {HostInstance} from 'react-native'; import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; -import {createRef, memo, useEffect, useMemo, useState} from 'react'; +import * as React from 'react'; +import {Component, createRef, memo, useEffect, useMemo, useState} from 'react'; import {Animated, View, useAnimatedValue} from 'react-native'; import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; @@ -75,6 +76,122 @@ test('animated opacity', () => { ); }); +// ScrollView's ref is the host instance, so it resolves directly (sanity check +// that the fix doesn't regress it). +test('animated opacity on Animated.ScrollView', () => { + let _opacity; + let _opacityAnimation; + + function MyApp() { + const opacity = useAnimatedValue(1); + _opacity = opacity; + return ( + + + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _opacityAnimation = Animated.timing(_opacity, { + toValue: 0, + duration: 30, + useNativeDriver: true, + }).start(); + }); + Fantom.unstable_produceFramesForDuration(30); + Fantom.runTask(() => { + _opacityAnimation?.stop(); + }); + + expect( + JSON.stringify(root.getRenderedOutput({props: ['opacity']}).toJSON()), + ).toContain('"opacity":"0"'); +}); + +test('animated opacity on Animated.FlatList', () => { + let _opacity; + let _opacityAnimation; + + function MyApp() { + const opacity = useAnimatedValue(1); + _opacity = opacity; + return ( + } + renderItem={() => null} + style={{opacity}} + /> + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _opacityAnimation = Animated.timing(_opacity, { + toValue: 0, + duration: 30, + useNativeDriver: true, + }).start(); + }); + Fantom.unstable_produceFramesForDuration(30); + Fantom.runTask(() => { + _opacityAnimation?.stop(); + }); + + expect( + JSON.stringify(root.getRenderedOutput({props: ['opacity']}).toJSON()), + ).toContain('"opacity":"0"'); +}); + +// A class composite uses the findShadowNodeByTag fallback path in #connectShadowNode. +test('animated opacity on a class composite wrapping a host', () => { + let _opacity; + let _opacityAnimation; + + class HostWrapper extends Component<{style?: $FlowFixMe}> { + render(): React.Node { + return ; + } + } + const AnimatedHostWrapper = Animated.createAnimatedComponent(HostWrapper); + + function MyApp() { + const opacity = useAnimatedValue(1); + _opacity = opacity; + return ; + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _opacityAnimation = Animated.timing(_opacity, { + toValue: 0, + duration: 30, + useNativeDriver: true, + }).start(); + }); + Fantom.unstable_produceFramesForDuration(30); + Fantom.runTask(() => { + _opacityAnimation?.stop(); + }); + + expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual( + , + ); +}); + test('animate layout props', () => { const viewRef = createRef(); allowStyleProp('height'); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js index a29bd5ed5a55..6581c150f437 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js @@ -15,6 +15,7 @@ import type {AnimatedStyleAllowlist} from './AnimatedStyle'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; +import {getFabricUIManager} from '../../ReactNative/FabricUIManager'; import {findNodeHandle} from '../../ReactNative/RendererProxy'; import {getNodeFromPublicInstance} from '../../ReactPrivate/ReactNativePrivateInterface'; import flattenStyle from '../../StyleSheet/flattenStyle'; @@ -298,8 +299,31 @@ export default class AnimatedProps extends AnimatedNode { } invariant(this.__isNative, 'Expected node to be marked as "native"'); - // $FlowExpectedError[incompatible-type] - target.instance may be an HTMLElement but we need ReactNativeElement for Fabric - const shadowNode = getNodeFromPublicInstance(target.instance); + // Host components and ScrollView (whose ref is the host instance) resolve a + // shadow node directly; FlatList/SectionList are class composites that expose + // the host via getNativeScrollRef(). + // $FlowFixMe[unclear-type] - Legacy instance assumptions. + const instance: any = target.instance; + const candidates = [instance, instance?.getNativeScrollRef?.()]; + let shadowNode = null; + for (const candidate of candidates) { + if (candidate == null) { + continue; + } + shadowNode = getNodeFromPublicInstance(candidate); + if (shadowNode != null) { + break; + } + } + // Any other class composite: resolve from the host tag #connectAnimatedView + // already found via findNodeHandle (the lookup runs on the native side). + const connectedViewTag = target.connectedViewTag; + if (shadowNode == null && connectedViewTag != null) { + shadowNode = + getFabricUIManager()?.findShadowNodeByTag_DEPRECATED?.( + connectedViewTag, + ); + } if (shadowNode == null) { return; }