Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Animated.ScrollView style={{opacity}}>
<View style={{width: 100, height: 100}} />
</Animated.ScrollView>
);
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

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 (
<Animated.FlatList
data={[] as Array<string>}
renderItem={() => null}
style={{opacity}}
/>
);
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

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 <View style={this.props.style} />;
}
}
const AnimatedHostWrapper = Animated.createAnimatedComponent(HostWrapper);

function MyApp() {
const opacity = useAnimatedValue(1);
_opacity = opacity;
return <AnimatedHostWrapper style={{width: 100, height: 100, opacity}} />;
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

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(
<rn-view opacity="0" />,
);
});

test('animate layout props', () => {
const viewRef = createRef<HostInstance>();
allowStyleProp('height');
Expand Down
28 changes: 26 additions & 2 deletions packages/react-native/Libraries/Animated/nodes/AnimatedProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
Loading