Skip to content

Conversation

@hannojg
Copy link

@hannojg hannojg commented Nov 28, 2025

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, forever

The problem is that this would call completeRoot/Surface every very milliseconds:

Screenshots of CPU sample profiler + react devtools Screenshot 2025-11-24 at 14 16 40 Screenshot 2025-11-24 at 14 16 06

Over 1.000 commits in a few seconds:
Screenshot_2025-11-25_at_13 35 09

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:
Screenshot 2025-11-14 at 16 48 47

Note: the app wouldn't freeze in that loop, as that happens as part of react's concurrent rendering, the user is able to break out of that loop by scheduling any other kind of higher priority sync re-render.

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:

  • Legacy one: throwing a promise. This is what react-freeze is currently doing
  • Officially supported one: using the use() function

One 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:
import React, {Suspense} from 'react';
import {Text, Button, View, AppRegistry} from 'react-native';
import {Freeze} from 'react-freeze';

let resolve: VoidFunction;
const promise = new Promise<void>((r) => {
resolve = r;
});
let status = 'pending';
setTimeout(() => {
resolve();
status = 'success';
}, 3000);

function NestedChildThatSuspendsButResolves() {
if (status === 'pending') {
  throw promise;
}

return <Text>Here is the async data!</Text>;
}

function ComponentThatFreezes() {
const [counter, setCounter] = React.useState(0);

React.useEffect(() => {
  const interval = setInterval(() => {
    setCounter((c) => c + 1);
  }, 1000);
  return () => clearInterval(interval);
}, []);

console.log('ComponentThatFreezes render with counter:', counter);

return (
  <View>
    <Text>Counter: {counter}</Text>

    <Suspense>
      <NestedChildThatSuspendsButResolves />
    </Suspense>
  </View>
);
}

function ReproductionApp() {
const [isFrozen, setIsFrozen] = React.useState(false);

return (
  <View style={{flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'white'}}>
    <Button
      title="Press me"
      onPress={() => {
        setIsFrozen((prev) => !prev);
      }}
    />

    <Freeze freeze={isFrozen} placeholder={<View style={{width: 40, height: 20, backgroundColor: 'aliceblue'}} />}>
      <ComponentThatFreezes />
    </Freeze>
  </View>
);
}

AppRegistry.registerComponent('Discord', () => ReproductionApp);

I was able to confirm that:

State updates still happened (inside react): Screenshot 2025-11-28 at 10 20 09
Mounting layer wasn't rendering the changes:

The part of the tree basically gets replaced by react/fabric with rendering a View display=none:

Screenshot 2025-11-28 at 10 19 33

Full recording with all logs of the test run:

output.mp4

And most importantly in the client's app the infinite loop was gone 🎊

@kkafar kkafar self-requested a review December 1, 2025 13:02
@kkafar
Copy link
Member

kkafar commented Dec 1, 2025

Hey, just giving heads up - I'll look into this in upcoming days. Most likely I'll want to schedule a call to see the bug in clients app, but will be back with more info soon.

@kkafar kkafar self-assigned this Dec 1, 2025
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, this is hard one.

I'm just dumping my initial thoughts, it's not my final opinion.

First, react-freeze to require react@19 is a bit problematic. Not only it is a breaking change, but also since the Activity component is stable from react@19.2 I believe, the Freeze component looses a bit on the value proposition => it seems to me that keeping it working on older react versions for some more time is important.

The patch you submitted seems reasonable & it is very similar to what we had before #34 landed. Obviously it uses use instead of throwing the promise. I have some concerns though, raising from these claims that promises should never be cached in suspending components. That discussion was back in 2023, maybe something changed with React 19.2, but I don't have full understanding of the issue yet.

Will do some research here.

In the meantime I think that we should schedule a call (I'll DM you) so that I san see the problem & attempt to reproduce it.

@kkafar kkafar self-requested a review December 12, 2025 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants