fix(breaking): migrate to use() to fix endless loop
#42
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.
Problem
In a clients react-native app, using new arch bridgeless, we noticed an occasion where without the user doing anything react was entering an infinite rendering loop.
React concurrently working on a lane, that lane would suspend, it would try to work on another lane which would also suspend and so on.
I added logs in react which would look something like this:
Logs inside of `ReactFiberWorkLoop.js`
[PERFORM WORK START] {"forceSync": false, "lanes": 8388608, "pendingLanes": 25165824, "suspendedLanes": 16777216} [RENDER START CONCURRENT] {"lanes": 8388608, "shellSuspendCounter": 0, "suspendedLanes": 16777216, "timestamp": 1764236797188} [SUSPENSE BOUNDARY] {"componentName": "Anonymous", "didSuspend": false, "flags": 0, "memoizedState": {"dehydrated": null, "hydrationErrors": null, "retryLane": 0, "treeContext": null}} # encountering a freeze component that would freeze and cause suspend [THROW EXCEPTION DETAILED] {"boundaryMode": 3, "currentLanes": 8388608, "hasSuspenseBoundary": true, "parentChain": ["Tag22", "Tag13", "Freeze", "DelayedFreeze", "Tag11"], "promiseHasStatus": false, "promiseIsInfiniteThenable": true, "promiseStatus": undefined, "rootPendingLanes": 25165824, "rootSuspendedLanes": 16777216, "sourceFiberTag": 0, "sourceFiberType": "Suspender", "stackDepth": 5} [SUSPENSE BOUNDARY] {"componentName": "Anonymous", "didSuspend": true, "flags": 16528, "memoizedState": null} [UPDATE HOST CONTAINER] completeRoot {"containerTag": 1, "newChildSet": {}} [SCHEDULE ROOT] {"pendingLanes": 50331648, "pingedLanes": 0, "suspendedLanes": 33554432} [SCHEDULE ROOT] {"pendingLanes": 50331648, "pingedLanes": 0, "suspendedLanes": 33554432} [PERFORM WORK END] {"lanes": 8388608, "pendingLanes": 50331648, "suspendedLanes": 33554432} # encountering freeze again, this time DelayedFreeze is gone [PERFORM WORK START] {"forceSync": false, "lanes": 16777216, "pendingLanes": 50331648, "suspendedLanes": 33554432} [RENDER START CONCURRENT] {"lanes": 16777216, "shellSuspendCounter": 0, "suspendedLanes": 33554432, "timestamp": 1764236797196} [SUSPENSE BOUNDARY] {"componentName": "Anonymous", "didSuspend": false, "flags": 0, "memoizedState": {"dehydrated": null, "hydrationErrors": null, "retryLane": 0, "treeContext": null}} [THROW EXCEPTION DETAILED] {"boundaryMode": 3, "currentLanes": 16777216, "hasSuspenseBoundary": true, "parentChain": ["Tag22", "Tag13", "Freeze", "Tag5", "Tag11"], "promiseHasStatus": false, "promiseIsInfiniteThenable": true, "promiseStatus": undefined, "rootPendingLanes": 50331648, "rootSuspendedLanes": 33554432, "sourceFiberTag": 0, "sourceFiberType": "Suspender", "stackDepth": 1} [SUSPENSE BOUNDARY] {"componentName": "Anonymous", "didSuspend": true, "flags": 16528, "memoizedState": null} [UPDATE HOST CONTAINER] completeRoot {"containerTag": 1, "newChildSet": {}} [SCHEDULE ROOT] {"pendingLanes": 37748736, "pingedLanes": 0, "suspendedLanes": 4194304} [SCHEDULE ROOT] {"pendingLanes": 37748736, "pingedLanes": 0, "suspendedLanes": 4194304} [PERFORM WORK END] {"lanes": 16777216, "pendingLanes": 37748736, "suspendedLanes": 4194304} // ... and so one, foreverThe problem is that this would call completeRoot/Surface every very milliseconds:
Screenshots of CPU sample profiler + react devtools
Over 1.000 commits in a few seconds:

And it would actually hit the mounting layer natively:
Screen.Recording.2025-11-28.at.10.03.35.mov
This basically caused our app's CPU usage to spike around a 100%:
JS thread CPU + memory graph during loop
Taken while this bug occurred:

The fix
I didn't fully root cause the issue in the react-reconciler. I tried reproducing it with just
react-freeze/ suspense but had no luck. From my logs i noticed that the involvement of the<DelayedFreeze>component from react-native-screens seems to be important for reproducing this. I tried to build a reproduction with that too and had no luck.I am happy though to connect one of your team to test the bug happening in the clients app.
While looking through the react-reconciler code to understand how this could happen I noticed that for suspending there are two code paths:
use()functionOne problem seems to be around how react-freeze is throwing a never ending promise, while react tries to attach a "listener" on our promise to get pinged once the promise resolves to continue working on that lane. This is something that react-freeze's infinite promise throwing is certainly preventing.
Interestingly though, my first fix implementation was to just throw a stable promise. The bug would happen less often, but still happen.
Only when i switched to the official
use()API it stopped happening. I can only assume that's due to the slightly different code paths in the react reconciler.Caveats
With this change react-freeze requires react >= 19, due to this it would be a breaking change and major version bump.
Testing
To be fair, i feel more happy with this change using the official APIs. And i think its nicer performance wise, as react will be pinged about when it can continue rendering instead of endlessly trying to render the frozen suspended part of the tree
(we reported a similar problem with the react-native RuntimeScheduler_Legacy here)
I tested this change with this sample code:
I was able to confirm that:
State updates still happened (inside react):
Mounting layer wasn't rendering the changes:
The part of the tree basically gets replaced by react/fabric with rendering a
View display=none:Full recording with all logs of the test run:
output.mp4
And most importantly in the client's app the infinite loop was gone 🎊