fix(animated): prevent negative listener count in AnimatedValue#57170
fix(animated): prevent negative listener count in AnimatedValue#57170mhdamirhamza wants to merge 9 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?
|
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:
|
|
Why _listenerCount Becomes Negative: |
|
Great investigation. Let's add a test-case showing this issue in AnimatedColor and make a local fix there. |
|
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.
|
|
||
| const NativeAnimatedAPI = NativeAnimatedHelper.API; | ||
|
|
||
| /** |
There was a problem hiding this comment.
Please revert the documentation removal.
There was a problem hiding this comment.
Done! I've reverted the accidental documentation removal in AnimatedValue.js and updated the branch. Thanks for catching that!
|
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
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.