React 18 : Bug: Potential infinite loop with Suspense (and Error boundaries not triggered)
React version: latest stable (and experimental)
Steps To Reproduce
- Checkout the following repo: https://github.com/bvaughn/react-suspense-error-boundary-bug
- Run
npm install && npm run dev
- Load the browser and observe an infinite loop of components re-rendering (rather than the error boundary catching the error).
I've added a lot of inline comments about things that are necessary to trigger this bug in this file:
https://github.com/bvaughn/react-suspense-error-boundary-bug/blob/main/pages/index.js
Note that I was unable to reproduce this bug with Code Sandbox or Create React App. It only reproduces when running with Next JS (and only in DEV mode). Maybe it has something to do with Next's custom error logging behavior? Unfortunately there's no way to disable this (see vercel/next.js/discussions/13387) so I'm not sure.
I'm pretty sure that I know what is the scenario here. This is about React trying to render many components in "parallel" - each of them could suspend after all and I suspect that React wants to "kick-off" all of them and a single caught error (that is not a promise) doesn't abort the computation. The problem is that this particular render won't be suspending at all - all of those "parallel" components throw here. And it takes time to go through all of them, in fact - it takes more than a "concurrent deadline". So when looping over work React finally recognizes that it should yield, and it does but when going back to the work loop... it starts from scratch. So the whole work done in the previous iteration of the work loop is lost here (probably that work that has thrown errors is not cached in your React's internal structures).
This can be verified~ by decreasing the deadline from the 5ms in the scheduler package to 1ms (then an even smaller slice of those parallel components can lead to a problem). It's also easier to verify this with a console open because the JavaScript will ececute slower (probably you can even use CPU throttling, but I didn't have time to verify this last thing).
I kept digging into this. I've failed to repro this in codesandbox when I've just tried to ensure that I'm yielding in the middle of "throwing siblings" but the render was able to complete just fine. So it turns out that Brian's hunch was right here - this is, at least partially, related to Next's ReactDevOverlay
and I was able to repro this on codesandbox when using this component:
https://codesandbox.io/s/thirsty-elion-nefk9t?file=/src/App.js
So I think what happens here is that:
- Next catches errors that are supposed to be handled by the custom ErrorBoundary created by Brian, roughly here
- it emits an event, listens to it in the
ReactDevOverlay
(here), and updates state there - this in turn leads to an effect being flushed and yet another state update here
So based on that I think that the tree rerenders and the partially done work is being discarded and the cycle just continues. I'm not sure if this assessment is fully correct though because ReactDevOverlay
's children
are always the same (its parent doesn't rerender) and IIRC this should be optimized and shouldn't require a rerender. But perhaps since the work in children
didn't yet commit this doesn't happen or something.