@samitouri / QOS-React / commits / a7d1240c96

[Fizz] Update postpone abort semantics when prerendering (#30541)

When aborting with a postpone value in Fizz if any tasks are still pending in the root while prerendering the prerender will fatally error. This is different from postponing imperatively in a root task and really the semantics should be the same. This change updates React to treat an abort with a postpone value as a postponed root rather than a fatal error.

Josh Story committed Jul 31, 2024 at 08:33 UTC a7d1240c962d2fdeac3ba31f1fdc12b5be4bbd2e
2 files changed +86 -28
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+56
@@ -398,4 +398,60 @@ describe('ReactDOMFizzStatic', () => {
398 </div>,
399 );
400 });
401 +
402 + // @gate enablePostpone
403 + it('does not fatally error when aborting with a postpone during a prerender', async () => {
404 + let postponedValue;
405 + try {
406 + React.unstable_postpone('aborting with postpone');
407 + } catch (e) {
408 + postponedValue = e;
409 + }
410 +
411 + const controller = new AbortController();
412 + const infinitePromise = new Promise(() => {});
413 + function App() {
414 + React.use(infinitePromise);
415 + return <div>aborted</div>;
416 + }
417 +
418 + const pendingResult = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
419 + signal: controller.signal,
420 + });
421 + pendingResult.catch(() => {});
422 +
423 + await Promise.resolve();
424 + controller.abort(postponedValue);
425 +
426 + const result = await pendingResult;
427 +
428 + await act(async () => {
429 + result.prelude.pipe(writable);
430 + });
431 + expect(getVisibleChildren(container)).toEqual(undefined);
432 + });
433 +
434 + // @gate enablePostpone
435 + it('does not fatally error when aborting with a postpone during a prerender from within', async () => {
436 + let postponedValue;
437 + try {
438 + React.unstable_postpone('aborting with postpone');
439 + } catch (e) {
440 + postponedValue = e;
441 + }
442 +
443 + const controller = new AbortController();
444 + function App() {
445 + controller.abort(postponedValue);
446 + return <div>aborted</div>;
447 + }
448 +
449 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
450 + signal: controller.signal,
451 + });
452 + await act(async () => {
453 + result.prelude.pipe(writable);
454 + });
455 + expect(getVisibleChildren(container)).toEqual(undefined);
456 + });
457 });
packages/react-server/src/ReactFizzServer.js
+30 -28
@@ -3794,12 +3794,22 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3794 error.$$typeof === REACT_POSTPONE_TYPE
3795 ) {
3796 const postponeInstance: Postpone = (error: any);
3797 - const fatal = new Error(
3798 - 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
3799 - postponeInstance.message,
3800 - );
3801 - logRecoverableError(request, fatal, errorInfo, null);
3802 - fatalError(request, fatal, errorInfo, null);
3797 + const trackedPostpones = request.trackedPostpones;
3798 +
3799 + if (trackedPostpones !== null && segment !== null) {
3800 + // We are prerendering. We don't want to fatal when the shell postpones
3801 + // we just need to mark it as postponed.
3802 + logPostpone(request, postponeInstance.message, errorInfo, null);
3803 + trackPostpone(request, trackedPostpones, task, segment);
3804 + finishedTask(request, null, segment);
3805 + } else {
3806 + const fatal = new Error(
3807 + 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
3808 + postponeInstance.message,
3809 + );
3810 + logRecoverableError(request, fatal, errorInfo, null);
3811 + fatalError(request, fatal, errorInfo, null);
3812 + }
3813 } else {
3814 logRecoverableError(request, error, errorInfo, null);
3815 fatalError(request, error, errorInfo, null);
@@ -4102,7 +4112,7 @@ function retryRenderTask(
4112 task.abortSet.delete(task);
4113 segment.status = COMPLETED;
4114 finishedTask(request, task.blockedBoundary, segment);
4105 - } catch (thrownValue) {
4115 + } catch (thrownValue: mixed) {
4116 resetHooksState();
4117
4118 // Reset the write pointers to where we started.
@@ -4117,7 +4127,9 @@ function retryRenderTask(
4127 // (unstable) API for suspending. This implementation detail can change
4128 // later, once we deprecate the old API in favor of `use`.
4129 getSuspendedThenable()
4120 - : thrownValue;
4130 + : thrownValue === AbortSigil
4131 + ? request.fatalError
4132 + : thrownValue;
4133
4134 if (typeof x === 'object' && x !== null) {
4135 // $FlowFixMe[method-unbinding]
@@ -4126,7 +4138,8 @@ function retryRenderTask(
4138 segment.status = PENDING;
4139 task.thenableState = getThenableStateAfterSuspending();
4140 const ping = task.ping;
4129 - x.then(ping, ping);
4141 + // We've asserted that x is a thenable above
4142 + (x: any).then(ping, ping);
4143 return;
4144 } else if (
4145 enablePostpone &&
@@ -4156,25 +4169,14 @@ function retryRenderTask(
4169 const errorInfo = getThrownInfo(task.componentStack);
4170 task.abortSet.delete(task);
4171
4159 - if (x === AbortSigil) {
4160 - segment.status = ABORTED;
4161 - erroredTask(
4162 - request,
4163 - task.blockedBoundary,
4164 - request.fatalError,
4165 - errorInfo,
4166 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
4167 - );
4168 - } else {
4169 - segment.status = ERRORED;
4170 - erroredTask(
4171 - request,
4172 - task.blockedBoundary,
4173 - x,
4174 - errorInfo,
4175 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
4176 - );
4177 - }
4172 + segment.status = ERRORED;
4173 + erroredTask(
4174 + request,
4175 + task.blockedBoundary,
4176 + x,
4177 + errorInfo,
4178 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
4179 + );
4180 return;
4181 } finally {
4182 if (__DEV__) {