[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__) {