facebook / react · Issue No. 37556
React version: 19.2.7 (also reproduces on 19.0.0, 19.1.1, 19.2.0, and 19.3.0; does not reproduce on 18.3.1)
Under act(), re-rendering three or more sibling <Suspense> boundaries whose children have thrown still-pending (stable, cached) thenables puts React into an infinite synchronous render/retry loop: flushActQueue never drains, the suspended components are re-rendered millions of times, and the process hard-blocks at 100% CPU. Because the event loop is starved, test-runner timeouts (Jest/Vitest) never fire — the worker hangs until killed.
children prop), React bails out and there is no loop.use(promise) does not reproduce — only the thrown-thenable suspension path loops.act() (real scheduler, same jsdom setup) the loop does not occur.Real-world impact: any React Testing Library / Vitest / Jest test (and Storybook's vitest-based test runner, which also wraps act) rendering a dashboard-style screen with 3+ suspending tiles under a parent that re-renders while they load (e.g. a context provider fetching layout state) hangs the worker with no failure output. We hit this in production code where a tiles-order context provider re-renders its tiles during mount; bisecting our tree down led to the minimal repro below.
npm install react@19.2.7 react-dom@19.2.7 jsdomrepro.mjs and run node repro.mjs 3 (hangs / prints LOOP DETECTED). Run node repro.mjs 2 to see the same shape pass with two siblings. Swap the deps for react@18.3.1 react-dom@18.3.1 and node repro.mjs 3 passes.Link to code example:
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.navigator = dom.window.navigator;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const React = (await import('react')).default;
const { act, Suspense, useEffect, useState, Fragment } = await import('react');
const { createRoot } = await import('react-dom/client');
const count = Number(process.argv[2] || 3);
console.log(`react ${React.version}, ${count} suspended siblings`);
// Stable, cached thenables — created once at module scope, same instance rethrown.
const entries = Array.from({ length: count }, () => {
let resolve;
const state = { status: 'pending', value: '' };
const promise = new Promise(r => (resolve = r)).then(v => {
state.status = 'fulfilled';
state.value = v;
});
return { state, promise, resolve };
});
let attempts = 0;
function Tile({ index }) {
attempts++;
if (attempts > 100000) {
console.log('LOOP DETECTED: >100000 render attempts');
process.exit(1);
}
const entry = entries[index];
if (entry.state.status === 'pending') {
throw entry.promise;
}
return React.createElement('span', null, `content ${entry.state.value}`);
}
function Parent() {
// One state update after mount — e.g. a provider storing fetched layout state.
const [, bump] = useState(0);
useEffect(() => {
bump(1);
}, []);
// Boundary elements are recreated on each render (plain inline JSX).
// Hoisting them into a useMemo(..., []) makes the loop disappear.
return React.createElement(
Fragment,
null,
entries.map((_, index) =>
React.createElement(
Suspense,
{ key: index, fallback: React.createElement('span', null, 'loading') },
React.createElement(Tile, { index })
)
)
);
}
const root = createRoot(document.getElementById('root'));
await act(async () => {
root.render(React.createElement(Parent));
});
console.log(`after mount: attempts=${attempts}`);
await act(async () => {
entries.forEach(entry => entry.resolve('done'));
await Promise.resolve();
});
const html = document.getElementById('root').innerHTML;
console.log(`final attempts=${attempts}, html=${html.slice(0, 120)}`);
console.log(html.includes('content done') ? 'PASS' : 'FAIL: content missing');
process.exit(html.includes('content done') ? 0 : 1);
With count = 3 (or more), the first act() never returns. The parent renders exactly twice (mount + the single bump), after which React re-attempts the three suspended children forever — >100,000 render attempts within a couple of seconds (left alone, millions), spinning inside flushActQueue → performWorkOnRootViaSchedulerTask → renderRootConcurrent → workLoopSync, with periodic commits (commitRoot → flushMutationEffects). Sampled stacks from a hung worker consistently show that cycle. With count = 2, the same script completes: attempts=6 after mount, PASS at the end.
The suspended boundaries wait for their thenables to resolve (as with two siblings, and as on React 18.3.1): act returns after a bounded number of render attempts, and the content commits once the promises resolve.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.