@samitouri / QOS-React / commits / 689a4fa441

[Fizz] Extend stack overflow recovery to retries (#36977)

Ran into this test failure as part of https://github.com/react/react/pull/36917 - it seems that the added code was just enough to increase stack size and fail the deep tree recovery test in CI. Looking into that, there appears to be a gap here with retries, including a TODO test case for the scenario. Fizz recovers from stack overflows in extremely deep trees by catching the first overflow in the `renderNode` trampoline and spawning a continuation task. That continuation is retried via `retryRenderTask → retryNode`, which has no trampoline above it. So if the remaining tree still doesn't fit in one fresh stack, the overflow was treated as a fatal error instead of recovering again. This fix re-schedules the task when a retried render overflows but `task.node` advanced (proving forward progress was made). If this is a real in-component overflow, `task.node` doesn't advance and we still fail. The existing test used `n={1000}`, which only required one recovery round and didn't catch this gap in source mode. It's updated to `n={1200}`, which reliably requires multiple recovery rounds.

Jack Pope committed Jul 19, 2026 at 07:13 UTC 689a4fa44169f945a32308959f42e852fbb66017
3 files changed +131 -12
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+93 -4
@@ -7676,13 +7676,14 @@ describe('ReactDOMFizzServer', () => {
7676 return <span>hi</span>;
7677 }
7678
7679 - // Recursively render a component tree deep enough to trigger stack overflow.
7680 - // Don't make this too short to not hit the limit but also not too deep to slow
7681 - // down the test.
7679 + // Recursively render a component tree deep enough to trigger stack overflow
7680 + // more than once. The first overflow is recovered by the renderNode
7681 + // trampoline; deeper trees must also recover when the retried task
7682 + // overflows again. Don't make this too deep to slow down the test.
7683 await act(() => {
7684 const {pipe} = renderToPipeableStream(
7685 <div>
7685 - <Recursive n={1000} />
7686 + <Recursive n={1200} />
7687 </div>,
7688 );
7689 pipe(writable);
@@ -7729,6 +7730,94 @@ describe('ReactDOMFizzServer', () => {
7730 expect(caughtError.message).toBe('Maximum call stack size exceeded');
7731 });
7732
7733 + it('can recover from very deep trees during resume to avoid stack overflow', async () => {
7734 + const promise = new Promise(() => {});
7735 +
7736 + let prerendering = true;
7737 +
7738 + // Deep wrappers above the postponed boundary. On resume, replaying this
7739 + // path goes through retryReplayTask → retryNode (no trampoline), so a
7740 + // tree deep enough to overflow must recover there — not only on the
7741 + // ordinary render retry path.
7742 + function Deep({n, children}) {
7743 + if (n > 0) {
7744 + return <Deep n={n - 1}>{children}</Deep>;
7745 + }
7746 + return children;
7747 + }
7748 +
7749 + function Content() {
7750 + if (prerendering) {
7751 + return React.use(promise);
7752 + }
7753 + return <span>hi</span>;
7754 + }
7755 +
7756 + function App() {
7757 + return (
7758 + <div>
7759 + <Deep n={1200}>
7760 + <Suspense fallback="Loading...">
7761 + <Content />
7762 + </Suspense>
7763 + </Deep>
7764 + </div>
7765 + );
7766 + }
7767 +
7768 + const controller = new AbortController();
7769 + const errors = [];
7770 + let pendingPrerender;
7771 + await act(() => {
7772 + pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
7773 + signal: controller.signal,
7774 + onError(error) {
7775 + errors.push(error);
7776 + },
7777 + });
7778 + });
7779 + controller.abort('abort');
7780 +
7781 + const prerendered = await pendingPrerender;
7782 + expect(errors).toEqual(['abort']);
7783 + expect(prerendered.postponed).not.toBe(null);
7784 +
7785 + const preludeWritable = new Stream.PassThrough();
7786 + preludeWritable.setEncoding('utf8');
7787 + preludeWritable.on('data', chunk => {
7788 + writable.write(chunk);
7789 + });
7790 +
7791 + await act(() => {
7792 + prerendered.prelude.pipe(preludeWritable);
7793 + });
7794 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
7795 +
7796 + prerendering = false;
7797 + errors.length = 0;
7798 +
7799 + const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
7800 + <App />,
7801 + JSON.parse(JSON.stringify(prerendered.postponed)),
7802 + {
7803 + onError(error) {
7804 + errors.push(error);
7805 + },
7806 + },
7807 + );
7808 +
7809 + await act(() => {
7810 + resumed.pipe(writable);
7811 + });
7812 +
7813 + expect(errors).toEqual([]);
7814 + expect(getVisibleChildren(container)).toEqual(
7815 + <div>
7816 + <span>hi</span>
7817 + </div>,
7818 + );
7819 + });
7820 +
7821 it('client renders incomplete Suspense boundaries when the document is no longer loading when hydration begins', async () => {
7822 let resolve;
7823 const promise = new Promise(r => {
packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+7 -7
@@ -375,7 +375,7 @@ describe('ReactDOMFizzShellHydration', () => {
375 expect(container.textContent).toBe('New screen');
376 });
377
378 - it('TODO: A large component stack causes SSR to stack overflow', async () => {
378 + it('recovers from a large component stack during SSR', async () => {
379 spyOnDevAndProd(console, 'error').mockImplementation(() => {});
380
381 function NestedComponent({depth}: {depth: number}) {
@@ -385,16 +385,16 @@ describe('ReactDOMFizzShellHydration', () => {
385 return <NestedComponent depth={depth - 1} />;
386 }
387
388 - // Server render
388 + await resolveText('Shell');
389 await serverAct(async () => {
390 - ReactDOMFizzServer.renderToPipeableStream(
390 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
391 <NestedComponent depth={3000} />,
392 );
393 + pipe(writable);
394 });
394 - expect(console.error).toHaveBeenCalledTimes(1);
395 - expect(console.error.mock.calls[0][0].toString()).toBe(
396 - 'RangeError: Maximum call stack size exceeded',
397 - );
395 + expect(console.error).not.toHaveBeenCalled();
396 + assertLog(['Shell']);
397 + expect(container.textContent).toBe('Shell');
398 });
399
400 it('client renders when an error is thrown in an error boundary', async () => {
packages/react-server/src/ReactFizzServer.js
+31 -1
@@ -3213,7 +3213,10 @@ function replayElement(
3213 if (
3214 typeof x === 'object' &&
3215 x !== null &&
3216 - (x === SuspenseException || typeof x.then === 'function')
3216 + (x === SuspenseException ||
3217 + typeof x.then === 'function' ||
3218 + // Rethrow so retryReplayTask can trampoline on stack overflow.
3219 + x.message === 'Maximum call stack size exceeded')
3220 ) {
3221 // Suspend
3222 if (task.node === currentNode) {
@@ -5239,6 +5242,8 @@ function retryRenderTask(
5242
5243 const childrenLength = segment.children.length;
5244 const chunkLength = segment.chunks.length;
5245 + // Used to detect forward progress if we hit a stack overflow below.
5246 + const startNode = task.node;
5247 try {
5248 // We call the destructive form that mutates this task. That way if something
5249 // suspends again, we can reuse the same task instead of spawning a new one.
@@ -5303,6 +5308,18 @@ function retryRenderTask(
5308 (x as any).then(ping.resolve, ping.reject);
5309 return;
5310 }
5311 + if (
5312 + x.message === 'Maximum call stack size exceeded' &&
5313 + task.node !== startNode
5314 + ) {
5315 + // Stack overflow after making forward progress. Retry from a fresh stack.
5316 + // No progress (e.g. overflow inside the component itself) falls through.
5317 + segment.status = PENDING;
5318 + task.thenableState = null;
5319 + // Immediately schedule the task for retrying.
5320 + request.pingedTasks.push(task);
5321 + return;
5322 + }
5323 }
5324
5325 const errorInfo = getThrownInfo(task.componentStack);
@@ -5345,6 +5362,8 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
5362 setCurrentTaskInDEV(task);
5363 }
5364
5365 + // Used to detect forward progress if we hit a stack overflow below.
5366 + const startNode = task.node;
5367 try {
5368 // We call the destructive form that mutates this task. That way if something
5369 // suspends again, we can reuse the same task instead of spawning a new one.
@@ -5408,6 +5427,17 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
5427 : null;
5428 return;
5429 }
5430 + if (
5431 + x.message === 'Maximum call stack size exceeded' &&
5432 + task.node !== startNode
5433 + ) {
5434 + // Stack overflow after making forward progress. Retry from a fresh stack.
5435 + // No progress (e.g. overflow inside the component itself) falls through.
5436 + task.thenableState = null;
5437 + // Immediately schedule the task for retrying.
5438 + request.pingedTasks.push(task);
5439 + return;
5440 + }
5441 }
5442 task.replay.pendingTasks--;
5443 task.abortSet.delete(task);