From 7d024094cb68b0e27f46be42461cdb422dc15ec8 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Thu, 13 Aug 2026 19:38:01 +0700 Subject: [PATCH] Keep user-registered Animated listeners when a node detaches AnimatedNode.__detach() called removeAllListeners(), which discards callbacks registered by the caller via addListener(). Because AnimatedWithChildren.__removeChild() detaches a node once its last child is removed, unmounting a component silently unregistered every listener on the Animated.Value it was bound to, even though the value itself is still alive and animating. Detaching now only tears down the listening state the node owns: AnimatedValue removes its native value-update subscription before the native node is dropped, which is what the original change (D40381895) needed. Callbacks registered by the caller are left alone, and are removed with removeListener()/removeAllListeners() as documented. --- .../Animated/__tests__/Animated-test.js | 33 +++++++++++ .../Libraries/Animated/nodes/AnimatedNode.js | 7 ++- .../Libraries/Animated/nodes/AnimatedValue.js | 4 ++ .../animated/__tests__/AnimatedNative-test.js | 59 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) 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', () => {