diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js index 7e6de90377e..62058b26df5 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js @@ -25,6 +25,10 @@ export type AnimatedPropsAllowlist = $ReadOnly<{ [string]: true, }>; +type TargetView = { + +instance: TargetViewInstance, + connectedViewTag: ?number, +}; type TargetViewInstance = React.ElementRef; function createAnimatedProps( @@ -81,7 +85,7 @@ export default class AnimatedProps extends AnimatedNode { #nodeKeys: $ReadOnlyArray; #nodes: $ReadOnlyArray; #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 { diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 863de0b3e76..b4ce724c26e 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -897,7 +897,7 @@ declare export default class AnimatedProps extends AnimatedNode { config?: ?AnimatedNodeConfig ): void; update(): void; - setNativeView(targetInstance: TargetViewInstance): void; + setNativeView(instance: TargetViewInstance): void; } " `; diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedProps-itest.js b/packages/react-native/src/private/animated/__tests__/AnimatedProps-itest.js new file mode 100644 index 00000000000..b41247d5208 --- /dev/null +++ b/packages/react-native/src/private/animated/__tests__/AnimatedProps-itest.js @@ -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(); + }); + 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); +});