@samitouri / QOS-React / commits / 96aca5f4f3

Spawn new task if we hit stack overflow (#30419)

If we see the "Maximum call stack size exceeded" error we know we've hit stack overflow. We can recover from this by spawning a new task and trying again. Effectively a zero-cost trampoline in the normal case. The new task will have a clean stack. If you have a lot of siblings at the same depth that hits the limit you can end up hitting this once for each sibling but within that new sibling you're unlikely to hit this again. So it's not too expensive. If it errors again in the retryTask pass, the other error handling takes over which causes this to be able to still not infinitely stall. E.g. when the component itself throws an error like this. It's still better to increase the stack limit for performance if you have a really deep tree but it doesn't really hurt to be able to recover since it's zero cost when it doesn't happen. We could do the same thing for Flight. Those trees don't tend to be as deep but could happen.

Sebastian Markbåge committed Aug 27, 2024 at 13:10 UTC 96aca5f4f3d7fbe0c13350f90031d8ec4c060ccb
2 files changed +137 -16
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+61
@@ -8677,4 +8677,65 @@ describe('ReactDOMFizzServer', () => {
8677 '\n in Bar (at **)' + '\n in Foo (at **)',
8678 );
8679 });
8680 +
8681 + it('can recover from very deep trees to avoid stack overflow', async () => {
8682 + function Recursive({n}) {
8683 + if (n > 0) {
8684 + return <Recursive n={n - 1} />;
8685 + }
8686 + return <span>hi</span>;
8687 + }
8688 +
8689 + // Recursively render a component tree deep enough to trigger stack overflow.
8690 + // Don't make this too short to not hit the limit but also not too deep to slow
8691 + // down the test.
8692 + await act(() => {
8693 + const {pipe} = renderToPipeableStream(
8694 + <div>
8695 + <Recursive n={1000} />
8696 + </div>,
8697 + );
8698 + pipe(writable);
8699 + });
8700 +
8701 + expect(getVisibleChildren(container)).toEqual(
8702 + <div>
8703 + <span>hi</span>
8704 + </div>,
8705 + );
8706 + });
8707 +
8708 + it('handles stack overflows inside components themselves', async () => {
8709 + function StackOverflow() {
8710 + // This component is recursive inside itself and is therefore an error.
8711 + // Assuming no tail-call optimizations.
8712 + function recursive(n, a0, a1, a2, a3) {
8713 + if (n > 0) {
8714 + return recursive(n - 1, a0, a1, a2, a3) + a0 + a1 + a2 + a3;
8715 + }
8716 + return a0;
8717 + }
8718 + return recursive(10000, 'should', 'not', 'resolve', 'this');
8719 + }
8720 +
8721 + let caughtError;
8722 +
8723 + await expect(async () => {
8724 + await act(() => {
8725 + const {pipe} = renderToPipeableStream(
8726 + <div>
8727 + <StackOverflow />
8728 + </div>,
8729 + {
8730 + onError(error, errorInfo) {
8731 + caughtError = error;
8732 + },
8733 + },
8734 + );
8735 + pipe(writable);
8736 + });
8737 + }).rejects.toThrow('Maximum call stack size exceeded');
8738 +
8739 + expect(caughtError.message).toBe('Maximum call stack size exceeded');
8740 + });
8741 });
packages/react-server/src/ReactFizzServer.js
+76 -16
@@ -3320,9 +3320,8 @@ function spawnNewSuspendedReplayTask(
3320 request: Request,
3321 task: ReplayTask,
3322 thenableState: ThenableState | null,
3323 - x: Wakeable,
3324 -): void {
3325 - const newTask = createReplayTask(
3323 +): ReplayTask {
3324 + return createReplayTask(
3325 request,
3326 thenableState,
3327 task.replay,
@@ -3340,17 +3339,13 @@ function spawnNewSuspendedReplayTask(
3339 !disableLegacyContext ? task.legacyContext : emptyContextObject,
3340 __DEV__ && enableOwnerStacks ? task.debugTask : null,
3341 );
3343 -
3344 - const ping = newTask.ping;
3345 - x.then(ping, ping);
3342 }
3343
3344 function spawnNewSuspendedRenderTask(
3345 request: Request,
3346 task: RenderTask,
3347 thenableState: ThenableState | null,
3352 - x: Wakeable,
3353 -): void {
3348 +): RenderTask {
3349 // Something suspended, we'll need to create a new segment and resolve it later.
3350 const segment = task.blockedSegment;
3351 const insertionIndex = segment.chunks.length;
@@ -3367,7 +3362,7 @@ function spawnNewSuspendedRenderTask(
3362 segment.children.push(newSegment);
3363 // Reset lastPushedText for current Segment since the new Segment "consumed" it
3364 segment.lastPushedText = false;
3370 - const newTask = createRenderTask(
3365 + return createRenderTask(
3366 request,
3367 thenableState,
3368 task.node,
@@ -3385,9 +3380,6 @@ function spawnNewSuspendedRenderTask(
3380 !disableLegacyContext ? task.legacyContext : emptyContextObject,
3381 __DEV__ && enableOwnerStacks ? task.debugTask : null,
3382 );
3388 -
3389 - const ping = newTask.ping;
3390 - x.then(ping, ping);
3383 }
3384
3385 // This is a non-destructive form of rendering a node. If it suspends it spawns
@@ -3436,14 +3428,48 @@ function renderNode(
3428 if (typeof x.then === 'function') {
3429 const wakeable: Wakeable = (x: any);
3430 const thenableState = getThenableStateAfterSuspending();
3439 - spawnNewSuspendedReplayTask(
3431 + const newTask = spawnNewSuspendedReplayTask(
3432 + request,
3433 + // $FlowFixMe: Refined.
3434 + task,
3435 + thenableState,
3436 + );
3437 + const ping = newTask.ping;
3438 + wakeable.then(ping, ping);
3439 +
3440 + // Restore the context. We assume that this will be restored by the inner
3441 + // functions in case nothing throws so we don't use "finally" here.
3442 + task.formatContext = previousFormatContext;
3443 + if (!disableLegacyContext) {
3444 + task.legacyContext = previousLegacyContext;
3445 + }
3446 + task.context = previousContext;
3447 + task.keyPath = previousKeyPath;
3448 + task.treeContext = previousTreeContext;
3449 + task.componentStack = previousComponentStack;
3450 + if (__DEV__ && enableOwnerStacks) {
3451 + task.debugTask = previousDebugTask;
3452 + }
3453 + // Restore all active ReactContexts to what they were before.
3454 + switchContext(previousContext);
3455 + return;
3456 + }
3457 + if (x.message === 'Maximum call stack size exceeded') {
3458 + // This was a stack overflow. We do a lot of recursion in React by default for
3459 + // performance but it can lead to stack overflows in extremely deep trees.
3460 + // We do have the ability to create a trampoile if this happens which makes
3461 + // this kind of zero-cost.
3462 + const thenableState = getThenableStateAfterSuspending();
3463 + const newTask = spawnNewSuspendedReplayTask(
3464 request,
3465 // $FlowFixMe: Refined.
3466 task,
3467 thenableState,
3444 - wakeable,
3468 );
3469
3470 + // Immediately schedule the task for retrying.
3471 + request.pingedTasks.push(newTask);
3472 +
3473 // Restore the context. We assume that this will be restored by the inner
3474 // functions in case nothing throws so we don't use "finally" here.
3475 task.formatContext = previousFormatContext;
@@ -3493,13 +3519,14 @@ function renderNode(
3519 if (typeof x.then === 'function') {
3520 const wakeable: Wakeable = (x: any);
3521 const thenableState = getThenableStateAfterSuspending();
3496 - spawnNewSuspendedRenderTask(
3522 + const newTask = spawnNewSuspendedRenderTask(
3523 request,
3524 // $FlowFixMe: Refined.
3525 task,
3526 thenableState,
3501 - wakeable,
3527 );
3528 + const ping = newTask.ping;
3529 + wakeable.then(ping, ping);
3530
3531 // Restore the context. We assume that this will be restored by the inner
3532 // functions in case nothing throws so we don't use "finally" here.
@@ -3540,6 +3567,39 @@ function renderNode(
3567 );
3568 trackPostpone(request, trackedPostpones, task, postponedSegment);
3569
3570 + // Restore the context. We assume that this will be restored by the inner
3571 + // functions in case nothing throws so we don't use "finally" here.
3572 + task.formatContext = previousFormatContext;
3573 + if (!disableLegacyContext) {
3574 + task.legacyContext = previousLegacyContext;
3575 + }
3576 + task.context = previousContext;
3577 + task.keyPath = previousKeyPath;
3578 + task.treeContext = previousTreeContext;
3579 + task.componentStack = previousComponentStack;
3580 + if (__DEV__ && enableOwnerStacks) {
3581 + task.debugTask = previousDebugTask;
3582 + }
3583 + // Restore all active ReactContexts to what they were before.
3584 + switchContext(previousContext);
3585 + return;
3586 + }
3587 + if (x.message === 'Maximum call stack size exceeded') {
3588 + // This was a stack overflow. We do a lot of recursion in React by default for
3589 + // performance but it can lead to stack overflows in extremely deep trees.
3590 + // We do have the ability to create a trampoile if this happens which makes
3591 + // this kind of zero-cost.
3592 + const thenableState = getThenableStateAfterSuspending();
3593 + const newTask = spawnNewSuspendedRenderTask(
3594 + request,
3595 + // $FlowFixMe: Refined.
3596 + task,
3597 + thenableState,
3598 + );
3599 +
3600 + // Immediately schedule the task for retrying.
3601 + request.pingedTasks.push(newTask);
3602 +
3603 // Restore the context. We assume that this will be restored by the inner
3604 // functions in case nothing throws so we don't use "finally" here.
3605 task.formatContext = previousFormatContext;