diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-test.js b/packages/react-native/Libraries/Animated/__tests__/Animated-test.js index e99c9a90acbe..3d4b8c70fdc7 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Animated-test.js +++ b/packages/react-native/Libraries/Animated/__tests__/Animated-test.js @@ -1102,6 +1102,39 @@ describe('Animated', () => { value1.setValue(7); expect(listener.mock.calls.length).toBe(4); }); + + it('should keep listeners when the last attached node detaches', () => { + const value1 = new Animated.Value(0); + const listener = jest.fn(); + value1.addListener(listener); + + const node = new AnimatedProps({style: {opacity: value1}}, () => {}); + node.__attach(); + node.__detach(); + + expect(value1.__getChildren().length).toBe(0); + expect(value1.hasListeners()).toBe(true); + + value1.setValue(42); + expect(listener).toBeCalledWith({value: 42}); + expect(listener.mock.calls.length).toBe(1); + }); + + it('should keep listeners when a bound component unmounts', async () => { + const value1 = new Animated.Value(0); + const listener = jest.fn(); + value1.addListener(listener); + + const root = await create( + , + ); + await unmount(root); + jest.runAllTicks(); + + value1.setValue(42); + expect(listener).toBeCalledWith({value: 42}); + expect(listener.mock.calls.length).toBe(1); + }); }); describe('Animated Diff Clamp', () => { diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js index b10fe9da8bee..17cc208d3762 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js @@ -49,7 +49,12 @@ export default class AnimatedNode { __attach(): void {} __detach(): void { - this.removeAllListeners(); + // NOTE: Listeners registered with `addListener` are owned by the caller, + // not by this node. An `Animated.Value` can outlive every component that + // uses it, so detaching from the graph must not remove them. Subclasses + // are responsible for tearing down any listening state they own (e.g. + // `AnimatedValue` stops listening to native value updates) before the + // native node is dropped below. if (this.__isNative && this.__nativeTag != null) { NativeAnimatedHelper.API.dropAnimatedNode(this.__nativeTag); this.__nativeTag = undefined; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 76dba2196f48..7896ae06c967 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -124,6 +124,10 @@ export default class AnimatedValue extends AnimatedWithChildren { }); } this.stopAnimation(); + // Stop listening to native value updates before the native node is + // dropped by `super.__detach()`. This releases the subscription this node + // owns without discarding listeners registered by the caller. + this._updateSubscription?.remove(); super.__detach(); } diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 667afdd9fb77..4b9b6d78c6f8 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -286,6 +286,65 @@ describe('Native Animated', () => { }); expect(listener).toHaveBeenCalledTimes(4); }); + + it('should stop listening to native updates on unmount, but keep listeners', async () => { + const {Animated, NativeAnimatedHelper} = importModules(); + + const value1 = new Animated.Value(0, {useNativeDriver: true}); + const listener = jest.fn(); + value1.addListener(listener); + + const tag = value1.__getNativeTag(); + expect( + NativeAnimatedModule.startListeningToAnimatedNodeValue, + ).toHaveBeenCalledWith(tag); + + const root = await create(); + await unmount(root); + jest.runAllTicks(); + + // The subscription this node owns is released before the native node is + // dropped. + expect( + NativeAnimatedModule.stopListeningToAnimatedNodeValue, + ).toHaveBeenCalledWith(tag); + expect(NativeAnimatedModule.dropAnimatedNode).toHaveBeenCalledWith(tag); + NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { + value: 42, + tag, + }); + expect(listener).not.toHaveBeenCalled(); + + // The caller's listener is not discarded. + expect(value1.hasListeners()).toBe(true); + }); + + it('should resume delivering native updates when remounted', async () => { + const {Animated, NativeAnimatedHelper} = importModules(); + + const value1 = new Animated.Value(0, {useNativeDriver: true}); + const listener = jest.fn(); + value1.addListener(listener); + + const root = await create(); + await unmount(root); + jest.runAllTicks(); + + await create(); + jest.runAllTicks(); + + const tag = value1.__getNativeTag(); + expect( + NativeAnimatedModule.startListeningToAnimatedNodeValue, + ).toHaveBeenCalledWith(tag); + + NativeAnimatedHelper.nativeEventEmitter.emit('onAnimatedValueUpdate', { + value: 42, + tag, + }); + expect(listener).toBeCalledWith({value: 42}); + expect(listener).toHaveBeenCalledTimes(1); + }); }); describe('Animated Events', () => {