Uh oh!
There was an error while loading. Please reload this page.
fix(animated): prevent negative listener count in AnimatedValue - #57170
fix(animated): prevent negative listener count in AnimatedValue#57170mhdamirhamza wants to merge 10 commits into
Conversation
Fixed an issue in AnimatedValue.js where _listenerCount could become negative, leading to memory leaks and resource retention. Problem: If removeListener is called more times than addListener (e.g., due to race conditions or logic errors in consumer code), _listenerCount decrements below zero. This causes the cleanup logic (this._updateSubscription?.remove()) to never execute, leaking native subscriptions. Fix: Used Math.max(0, this._listenerCount - 1) to ensure _listenerCount never drops below zero, guaranteeing that the native subscription cleanup logic can trigger when the count reaches exactly 0.
Hi @mhdamirhamza! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
javache
left a comment
There was a problem hiding this comment.
removeListener is only called by infrastructure code - if this ever becomes negative, there's another bug in Animated that this would be masking. Do you have a concrete repro for this?
mhdamirhamza
commented
Jun 11, 2026
Thanks for the challenge, @javache! You're completely right that if this drops into negative values, it points to an edge case or race condition within the infrastructure code itself. I conducted a systematic trace through the core animated nodes, and this boundary condition is actually triggered directly by React Native's own infrastructure (specifically via Here is the exact root cause and reproduction path: The Root Cause: |
mhdamirhamza
commented
Jun 11, 2026
Why _listenerCount Becomes Negative: |
javache
commented
Jun 12, 2026
Great investigation. Let's add a test-case showing this issue in AnimatedColor and make a local fix there. |
mhdamirhamza
commented
Jun 12, 2026
Hi @javache — I've pushed the requested test case and local fix to this branch. |
Added AnimatedColor-test.js to cover the negative listener count bug after __detach and removeListener, as requested by @javache.
Warning Missing Test Plan Please add a "## Test Plan" section to your PR description. A Test Plan lets us know how these changes were tested. |
Overrode addListener, removeListener, and removeAllListeners to mirror AnimatedValueXY architecture and utilize a local listeners map. This handles edge cases like calling removeListener after __detach seamlessly.
Uh oh!
There was an error while loading. Please reload this page.
mhdamirhamza
commented
Jun 12, 2026
Done! I've reverted the accidental documentation removal in AnimatedValue.js and updated the branch. Thanks for catching that! |
## Test Plan Added a comprehensive regression test suite in `packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js` covering: - Verifying `_listenerCount` does not drop into negative values after a `__detach()` and subsequent `removeListener()` execution. - Testing `removeListener` safely acts as a no-op when called after `removeAllListeners()`. - Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks during complex component unmount/re-attach lifecycles. Tested locally by running: `yarn jest AnimatedColor-test` ## Changelog [General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades.
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory. --> ## Summary: Fixed an issue in `AnimatedColor.js` where `_listenerCount` could become negative during complex unmount/detach phases. When `__detach()` is invoked on `AnimatedColor`, it triggers `removeAllListeners()` on its underlying channel nodes (r, g, b, a), resetting their counts to 0. Subsequent stale infrastructure cleanups safely invoke `removeListener()`, which blindly decremented the counter to -1, bypassing the `=== 0` check and permanently leaking native subscriptions. This PR implements an overridden `removeListener` and `addListener` routine in `AnimatedColor` (mirroring the robust pattern in `AnimatedValueXY`) to act as a defensive shield and guarantee stable listener lifecycles. ## Changelog: [General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades ## Test Plan: Added a comprehensive regression test suite in `packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js` covering: - Verifying `_listenerCount` does not drop into negative values after a `__detach()` and subsequent `removeListener()` execution. - Testing `removeListener` safely acts as a no-op when called after `removeAllListeners()`. - Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks during complex component unmount/re-attach lifecycles. Tested locally by running: `yarn jest AnimatedColor-test`
## Summary: Fixed an issue in `AnimatedColor.js` where `_listenerCount` could become negative during component unmount, leading to native subscription memory leaks. When `AnimatedColor.__detach()` is called, it invokes `removeAllListeners()` on its channel nodes (r, g, b, a), resetting their `_listenerCount` to 0. If infrastructure cleanup then calls `removeListener(id)` on those same nodes, the count drops to -1, permanently bypassing the cleanup condition `if (this._listenerCount === 0)` — so `this._updateSubscription?.remove()` never fires. Fix: Added a `_listeners` map to `AnimatedColor` with overrides for `addListener`, `removeListener`, and `removeAllListeners`. `removeListener` now returns early if the id is not found (already cleaned up by `__detach`), mirroring the pattern used by `AnimatedValueXY`. ## Changelog: [GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor causing native subscription memory leaks after `__detach()` ## Test Plan: Added regression tests in `Libraries/Animated/tests/AnimatedColor-test.js`: - `_listenerCount` does not go negative after `__detach()` + `removeListener()` - `removeListener` after `removeAllListeners` is a safe no-op - Native subscription starts correctly for all 4 channels (r/g/b/a) - Native subscription cleans up correctly when count reaches 0 - No native subscription leak after re-attach cycle
## Summary: Fixed an issue in AnimatedColor.js where _listenerCount could become negative during unmount, causing native subscription memory leaks... ## Changelog: [GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor causing native subscription memory leaks after __detach() ## Test Plan: Added regression tests in AnimatedColor-test.js covering: - _listenerCount does not go negative after __detach() + removeListener() - removeListener after removeAllListeners is a safe no-op - Native subscription cleanup when count reaches 0
## Summary: Fixed an issue in AnimatedColor.js where _listenerCount could become negative during complex unmount phases. When __detach() is executed, it invokes removeAllListeners() on underlying channel nodes (r, g, b, a), resetting counts to 0. Subsequent stale infrastructure cleanups invoke removeListener(), which blindly decrements the counter to -1, bypassing the === 0 check and leaking native subscriptions permanently. ## Changelog: [General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades ## Test Plan: Added a comprehensive regression test suite in packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js covering: - Verifying _listenerCount does not drop into negative values after a __detach() and subsequent removeListener() execution. - Testing removeListener safely acts as a no-op when called after removeAllListeners(). - Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks. Tested locally by running: yarn jest AnimatedColor-test
## Summary: Fixed negative listener count bug in AnimatedColor causing memory leaks. ## Changelog: [GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor after __detach() ## Test Plan: Added regression tests in AnimatedColor-test.js covering listener count and cleanup.
…etaches (#57941) Summary: Fixes#43586. `value.addListener(cb)` stops firing forever once any component bound to that `Animated.Value` unmounts, even though the value is still alive and still animating. The chain: 1. [`AnimatedProps.__detach()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js#L222-L235) loops `node.__removeChild(this)` on unmount. 2. [`AnimatedWithChildren.__removeChild()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedWithChildren.js#L63-L65) does `if (this._children.length === 0) { this.__detach(); }` — the **value** detaches itself. 3. [`AnimatedNode.__detach()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js#L51-L52) calls `this.removeAllListeners()`, which discards callbacks the **caller** registered. An `Animated.Value` is owned by the caller and routinely outlives the components it drives. `addListener` / `removeListener` / `removeAllListeners` are 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 `() => false` in OSS: ```js // v0.71.19 Libraries/Animated/nodes/AnimatedNode.js __detach(): void { if (ReactNativeFeatureFlags.removeListenersOnDetach()) { this.removeAllListeners(); } ... ``` 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](#43586 (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: > Removing listener on detached node leads to a red box, if the said node is `DiffClampAnimatedNode`. This is because calling `AnimatedNode.__getNativeTag()` makes native module call and creates node in native. This node is not completely initialised and red boxes […] The fix is make sure all listeners are removed before node is destroyed. 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** calls `this._updateSubscription?.remove()` (node-owned: the `onAnimatedValueUpdate` emitter subscription plus `stopListeningToAnimatedNodeValue`). - Only the second belongs in `__detach()`. The 2022 hazard is also structurally gone. In 0.71, `_stopListeningForNativeValueUpdates()` called `NativeAnimatedAPI.stopListeningToAnimatedNodeValue(this.__getNativeTag())` — on a detached node `__getNativeTag()` resurrects a half-initialised native node, which is the red box. Today's [`_updateSubscription.remove()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js#L182-L192) closes over a local `nativeTag` const 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. - The `onAnimatedValueUpdate` `NativeEventEmitter` subscription and the native `startListeningToAnimatedNodeValue` state are still released on detach — asserted by a new test. - `_listeners` lives on the `AnimatedValue` itself. React Native keeps no registry of JS `Animated` values, so a value is reachable only from user code. Drop the value and the listeners go with it. - If the caller deliberately keeps a value alive past its components, the retained graph is exactly what their own closures capture, and `removeListener()` / `removeAllListeners()` are the documented way to release it. Both in-tree consumers that register listeners on an `AnimatedValue` already clean up after themselves and never relied on `__detach()` doing it — [`ScrollViewStickyHeader`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js#L247-L251) and [`createAnimatedPropsHook`](https://github.com/react/react-native/blob/main/packages/react-native/src/private/animated/createAnimatedPropsHook.js#L221-L223), 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**. - Textual: #57170 rewrites `AnimatedValue.__detach()`, so `git apply --3way` of its patch onto this branch conflicts in `AnimatedValue.js`. - Semantic: #57170 new tests assert the behaviour this PR changes (`__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 `_listenerCount` so it cannot go negative. I measured where the negative count comes from — `__detach()` zeroing `_listenerCount` out from under a caller who still holds a listener id: | flow | `main` | this PR | |---|---|---| | `addListener` → `__detach()` | `_listenerCount === 0` | `_listenerCount === 1` | | … then `removeListener(id)` | `_listenerCount === -1` | `_listenerCount === 0` | This 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.Value` listeners registered with `addListener` are no longer removed when a component bound to the value unmounts Pull Request resolved: #57941 Test Plan: Tests added: - `packages/react-native/Libraries/Animated/__tests__/Animated-test.js` - `should 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, then `setValue(42)` and assert the listener fires. - `packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js` - `should stop listening to native updates on unmount, but keep listeners` — guards what `removeAllListeners()` was there for: after unmount, `stopListeningToAnimatedNodeValue(tag)` and `dropAnimatedNode(tag)` are still called and a subsequent `onAnimatedValueUpdate` emission does **not** reach the listener, while `hasListeners()` stays `true`. - `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: ``` $ git checkout -- packages/react-native/Libraries/Animated/nodes/AnimatedNode.js \ packages/react-native/Libraries/Animated/nodes/AnimatedValue.js $ yarn jest packages/react-native/Libraries/Animated/__tests__/Animated-test.js \ packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js ● Native Animated › Animated Listeners › should stop listening to native updates on unmount, but keep listeners ● Native Animated › Animated Listeners › should resume delivering native updates when remounted ● Animated › Animated Listeners › should keep listeners when the last attached node detaches ● Animated › Animated Listeners › should keep listeners when a bound component unmounts Test Suites: 2 failed, 2 total Tests: 4 failed, 97 passed, 101 total ``` All four fail without the change; the 97 pre-existing tests in those two files pass either way. Full suite, with the change restored: ``` $ yarn test Test Suites: 218 passed, 218 total Tests: 1 skipped, 5589 passed, 5590 total $ yarn flow-check Found 0 errors $ yarn lint Done in 9.20s. (eslint --max-warnings 0 .) ``` Baseline on `main` measured 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. Reviewed By: javache Differential Revision: D116040594 Pulled By: zeyap fbshipit-source-id: b9bcb18c8ca01fa28a1dd8ff1c1737c15627d520
Fixed an issue in AnimatedValue.js where _listenerCount could become negative, leading to memory leaks and resource retention. Problem:
If removeListener is called more times than addListener (e.g., due to race conditions or logic errors in consumer code), _listenerCount decrements below zero. This causes the cleanup logic (this._updateSubscription?.remove()) to never execute, leaking native subscriptions. Fix:
Used Math.max(0, this._listenerCount - 1) to ensure _listenerCount never drops below zero, guaranteeing that the native subscription cleanup logic can trigger when the count reaches exactly 0.