fix(animated): keep user-registered listeners when an Animated node detaches - #57941
Open
giaBaoJS wants to merge 1 commit into
Open
fix(animated): keep user-registered listeners when an Animated node detaches#57941giaBaoJS wants to merge 1 commit into
giaBaoJS wants to merge 1 commit into
Conversation
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.
zeyap
self-requested a review
August 13, 2026 14:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #43586.
value.addListener(cb)stops firing forever once any component bound to thatAnimated.Valueunmounts, even though the value is still alive and still animating.The chain:
AnimatedProps.__detach()loopsnode.__removeChild(this)on unmount.AnimatedWithChildren.__removeChild()doesif (this._children.length === 0) { this.__detach(); }— the value detaches itself.AnimatedNode.__detach()callsthis.removeAllListeners(), which discards callbacks the caller registered.An
Animated.Valueis owned by the caller and routinely outlives the components it drives.addListener/removeListener/removeAllListenersare documented public API. Detaching from the graph should not silently unregister the caller's callbacks.Why this is a regression, not intended behaviour
removeAllListeners()was added to__detach()in cd83194 (Oct 2022) — but behind a feature flag,removeListenersOnDetach, which shipped as() => falsein OSS:49d5e7c (Nov 2022) then deleted the flag as an "unused feature flag" under
changelog: [internal], inlining the enabled branch. That flipped the OSS default and is exactly the 0.71 → 0.72 regression a second reporter bisected in this comment. The user-facing semantics change was never the intent of that commit.Why removing the call is safe
cd83194's stated purpose was narrow:
The requirement is stop listening to native value updates before the native node is dropped, not discard the caller's callbacks. Those are two different things, and today they are cleanly separable:
AnimatedValue.removeAllListeners()clears_listeners(caller-owned) and callsthis._updateSubscription?.remove()(node-owned: theonAnimatedValueUpdateemitter subscription plusstopListeningToAnimatedNodeValue).__detach().The 2022 hazard is also structurally gone. In 0.71,
_stopListeningForNativeValueUpdates()calledNativeAnimatedAPI.stopListeningToAnimatedNodeValue(this.__getNativeTag())— on a detached node__getNativeTag()resurrects a half-initialised native node, which is the red box. Today's_updateSubscription.remove()closes over a localnativeTagconst and never calls__getNativeTag(). This PR adds no new__getNativeTag()call on any path.So
__detach()now tears down only what the node owns, and the ordering that mattered (stop listening →dropAnimatedNode) is preserved.Does this leak?
No framework-owned resource is retained.
onAnimatedValueUpdateNativeEventEmittersubscription and the nativestartListeningToAnimatedNodeValuestate are still released on detach — asserted by a new test._listenerslives on theAnimatedValueitself. React Native keeps no registry of JSAnimatedvalues, so a value is reachable only from user code. Drop the value and the listeners go with it.removeListener()/removeAllListeners()are the documented way to release it.Both in-tree consumers that register listeners on an
AnimatedValuealready clean up after themselves and never relied on__detach()doing it —ScrollViewStickyHeaderandcreateAnimatedPropsHook, both in effect cleanups.Honest behavioural delta: a caller who adds a listener on every mount and never removes it, on a value that outlives those components, will now accumulate listeners. Previously the accumulation was hidden by the very bug being fixed. Note the old behaviour was not a dependable cleanup mechanism either — it only fired when the last child detached, and never for listeners added after detach.
Relationship to #57170
They overlap and cannot both land as-is.
AnimatedValue.__detach(), sogit apply --3wayof its patch onto this branch conflicts inAnimatedValue.js.__detach()→removeAllListeners()on the r/g/b/a channels). Those assertions would need rewriting on top of this change.They also address the same underlying defect from opposite ends. #57170 clamps
_listenerCountso it cannot go negative. I measured where the negative count comes from —__detach()zeroing_listenerCountout from under a caller who still holds a listener id:mainaddListener→__detach()_listenerCount === 0_listenerCount === 1removeListener(id)_listenerCount === -1_listenerCount === 0This PR removes that root cause, so the count stays consistent without clamping. I have no opinion on whether the clamp is still wanted as defence-in-depth — flagging the interaction for whoever reviews both.
Changelog
[GENERAL] [FIXED] - Animated -
Animated.Valuelisteners registered withaddListenerare no longer removed when a component bound to the value unmountsTest Plan
Tests added:
packages/react-native/Libraries/Animated/__tests__/Animated-test.jsshould keep listeners when the last attached node detaches— node-graph level.should keep listeners when a bound component unmounts— the user-visible path: render<Animated.View style={{transform: [{translateX: value}]}} />, unmount it, thensetValue(42)and assert the listener fires.packages/react-native/src/private/animated/__tests__/AnimatedNative-test.jsshould stop listening to native updates on unmount, but keep listeners— guards whatremoveAllListeners()was there for: after unmount,stopListeningToAnimatedNodeValue(tag)anddropAnimatedNode(tag)are still called and a subsequentonAnimatedValueUpdateemission does not reach the listener, whilehasListeners()staystrue.should resume delivering native updates when remounted— native driver end to end: unmount, remount, and native updates on the new tag reach the original listener.Counterfactual — reverting only the two source files and keeping the tests:
All four fail without the change; the 97 pre-existing tests in those two files pass either way.
Full suite, with the change restored:
Baseline on
mainmeasured on the same checkout: 218 suites, 5585 passed / 1 skipped — this PR adds exactly the 4 tests above.Not verified: I did not run this on a device or simulator, so the fix is verified through the JS graph and the mocked native-driver harness rather than against the reporter's app.