Animated: Manually Manage Connected viewTag (#50230)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/50230

Currently, `AnimatedProps` invokes `findNodeHandle` to both connect and disconnect the native `AnimatedNode` instances to corresponding `viewTag`s.

Not only is this slow and wasteful (because `findNodeHandle` requires traversing the fiber tree), but it prevents deferring disconnection to after the fiber tree has been unmounted.

Disconnecting after unmount is necessary when the `scheduleAnimatedCleanupInMicrotask` feature flag is enabled, which is necessary to avoid invoking animation completion callbacks in the commit phase that unmounts animated views.

I have verified that `disconnectAnimatedNodeFromView` is needed and handles being called after fibers are unmounted.

Changelog:
[Internal]

Reviewed By: javache

Differential Revision: D71745805

fbshipit-source-id: ce8c2c95d38c4d5adbb79bac3c07b0872211cf51
This commit is contained in:
Tim Yung
2025-03-27 15:41:39 -07:00
committed by Facebook GitHub Bot
parent 185c809afd
commit 05fe502821
3 changed files with 84 additions and 25 deletions
@@ -25,6 +25,10 @@ export type AnimatedPropsAllowlist = $ReadOnly<{
[string]: true,
}>;
type TargetView = {
+instance: TargetViewInstance,
connectedViewTag: ?number,
};
type TargetViewInstance = React.ElementRef<React.ElementType>;
function createAnimatedProps(
@@ -81,7 +85,7 @@ export default class AnimatedProps extends AnimatedNode {
#nodeKeys: $ReadOnlyArray<string>;
#nodes: $ReadOnlyArray<AnimatedNode>;
#props: {[string]: mixed};
#targetInstance: ?TargetViewInstance = null;
#target: ?TargetView = null;
constructor(
inputProps: {[string]: mixed},
@@ -182,10 +186,10 @@ export default class AnimatedProps extends AnimatedNode {
}
__detach(): void {
if (this.__isNative && this.#targetInstance != null) {
this.#disconnectAnimatedView(this.#targetInstance);
if (this.__isNative && this.#target != null) {
this.#disconnectAnimatedView(this.#target);
}
this.#targetInstance = null;
this.#target = null;
const nodes = this.#nodes;
for (let ii = 0, length = nodes.length; ii < length; ii++) {
@@ -215,52 +219,50 @@ export default class AnimatedProps extends AnimatedNode {
// where it will be needed to traverse the graph of attached values.
super.__setPlatformConfig(platformConfig);
if (this.#targetInstance != null) {
this.#connectAnimatedView(this.#targetInstance);
if (this.#target != null) {
this.#connectAnimatedView(this.#target);
}
}
}
setNativeView(targetInstance: TargetViewInstance): void {
if (this.#targetInstance === targetInstance) {
setNativeView(instance: TargetViewInstance): void {
if (this.#target?.instance === instance) {
return;
}
this.#targetInstance = targetInstance;
this.#target = {instance, connectedViewTag: null};
if (this.__isNative) {
this.#connectAnimatedView(this.#targetInstance);
this.#connectAnimatedView(this.#target);
}
}
#connectAnimatedView(targetInstance: TargetViewInstance): void {
#connectAnimatedView(target: TargetView): void {
invariant(this.__isNative, 'Expected node to be marked as "native"');
let nativeViewTag: ?number = findNodeHandle(targetInstance);
if (nativeViewTag == null) {
let viewTag: ?number = findNodeHandle(target.instance);
if (viewTag == null) {
if (process.env.NODE_ENV === 'test') {
nativeViewTag = -1;
viewTag = -1;
} else {
throw new Error('Unable to locate attached view in the native tree');
}
}
NativeAnimatedHelper.API.connectAnimatedNodeToView(
this.__getNativeTag(),
nativeViewTag,
viewTag,
);
target.connectedViewTag = viewTag;
}
#disconnectAnimatedView(targetInstance: TargetViewInstance): void {
#disconnectAnimatedView(target: TargetView): void {
invariant(this.__isNative, 'Expected node to be marked as "native"');
let nativeViewTag: ?number = findNodeHandle(targetInstance);
if (nativeViewTag == null) {
if (process.env.NODE_ENV === 'test') {
nativeViewTag = -1;
} else {
throw new Error('Unable to locate attached view in the native tree');
}
const viewTag = target.connectedViewTag;
if (viewTag == null) {
return;
}
NativeAnimatedHelper.API.disconnectAnimatedNodeFromView(
this.__getNativeTag(),
nativeViewTag,
viewTag,
);
target.connectedViewTag = null;
}
__restoreDefaultValues(): void {
@@ -897,7 +897,7 @@ declare export default class AnimatedProps extends AnimatedNode {
config?: ?AnimatedNodeConfig
): void;
update(): void;
setNativeView(targetInstance: TargetViewInstance): void;
setNativeView(instance: TargetViewInstance): void;
}
"
`;
@@ -0,0 +1,57 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
import 'react-native/Libraries/Core/InitializeCore';
import NativeAnimatedHelper from '../NativeAnimatedHelper';
import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {Animated} from 'react-native';
function mockNativeAnimatedHelperAPI() {
const mocks = {
connectAnimatedNodeToView: jest.fn(),
disconnectAnimatedNodeFromView: jest.fn(),
};
// $FlowFixMe[cannot-write] - Switch to `jest.spyOn` when supported.
Object.assign(NativeAnimatedHelper.API, mocks);
return mocks;
}
test('connects and disconnects views', () => {
const mocks = mockNativeAnimatedHelperAPI();
const opacity = new Animated.Value(0);
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<Animated.View style={{opacity}} />);
});
expect(mocks.connectAnimatedNodeToView).not.toBeCalled();
expect(mocks.disconnectAnimatedNodeFromView).not.toBeCalled();
Fantom.runTask(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}).start();
});
expect(mocks.connectAnimatedNodeToView).toBeCalledTimes(1);
expect(mocks.disconnectAnimatedNodeFromView).not.toBeCalled();
Fantom.runTask(() => {
root.destroy();
});
expect(mocks.connectAnimatedNodeToView).toBeCalledTimes(1);
expect(mocks.disconnectAnimatedNodeFromView).toBeCalledTimes(1);
});