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; }