Skip to content

Commit 007cea5

Browse files
committed
[Fizz][Static] when aborting a prerender halt unfinished boundaries instead of erroring
When we introduces prerendering for flight we modeled an abort of a flight prerender as having unfinished rows. This is similar to how postpone was already implemented when you postponed from "within" a prerender using React.unstable_postpone. However when aborting with a postponed instance every boundary would be eagerly marked for client rendering which is more akin to prerendering and then resuming with an aborted signal. The insight with the flight work was that it's not so much the postpone that describes the intended semantics but the abort combined with a prerender. So like in flight when you abort a prerender and enableHalt is enabled boundaries and the shell won't error for any reason. Fizz will still call onPostpone and onError according to the abort reason but the consuemr of the prerender should expect to resume it before trying to use it.
1 parent 31631d6 commit 007cea5

File tree

4 files changed

+250
-5
lines changed

4 files changed

+250
-5
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7667,6 +7667,7 @@ describe('ReactDOMFizzServer', () => {
76677667
});
76687668

76697669
// @gate enablePostpone
7670+
// @gate !enableHalt
76707671
it('does not call onError when you abort with a postpone instance during prerender', async () => {
76717672
const promise = new Promise(r => {});
76727673

@@ -7746,6 +7747,106 @@ describe('ReactDOMFizzServer', () => {
77467747
);
77477748
});
77487749

7750+
// @gate enableHalt
7751+
it('can resume a prerender that was aborted', async () => {
7752+
const promise = new Promise(r => {});
7753+
7754+
let prerendering = true;
7755+
7756+
function Wait() {
7757+
if (prerendering) {
7758+
return React.use(promise);
7759+
} else {
7760+
return 'Hello';
7761+
}
7762+
}
7763+
7764+
function App() {
7765+
return (
7766+
<div>
7767+
<Suspense fallback="Loading...">
7768+
<p>
7769+
<span>
7770+
<Suspense fallback="Loading again...">
7771+
<Wait />
7772+
</Suspense>
7773+
</span>
7774+
</p>
7775+
<p>
7776+
<span>
7777+
<Suspense fallback="Loading again too...">
7778+
<Wait />
7779+
</Suspense>
7780+
</span>
7781+
</p>
7782+
</Suspense>
7783+
</div>
7784+
);
7785+
}
7786+
7787+
const controller = new AbortController();
7788+
const signal = controller.signal;
7789+
7790+
const errors = [];
7791+
function onError(error) {
7792+
errors.push(error);
7793+
}
7794+
let pendingPrerender;
7795+
await act(() => {
7796+
pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
7797+
signal,
7798+
onError,
7799+
});
7800+
});
7801+
controller.abort('boom');
7802+
7803+
const prerendered = await pendingPrerender;
7804+
7805+
expect(errors).toEqual(['boom', 'boom']);
7806+
7807+
await act(() => {
7808+
prerendered.prelude.pipe(writable);
7809+
});
7810+
7811+
expect(getVisibleChildren(container)).toEqual(
7812+
<div>
7813+
<p>
7814+
<span>Loading again...</span>
7815+
</p>
7816+
<p>
7817+
<span>Loading again too...</span>
7818+
</p>
7819+
</div>,
7820+
);
7821+
7822+
prerendering = false;
7823+
7824+
errors.length = 0;
7825+
const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
7826+
<App />,
7827+
JSON.parse(JSON.stringify(prerendered.postponed)),
7828+
{
7829+
onError,
7830+
},
7831+
);
7832+
7833+
await act(() => {
7834+
resumed.pipe(writable);
7835+
});
7836+
7837+
expect(errors).toEqual([]);
7838+
expect(getVisibleChildren(container)).toEqual(
7839+
<div>
7840+
<p>
7841+
<span>Hello</span>
7842+
</p>
7843+
<p>
7844+
<span>Hello</span>
7845+
</p>
7846+
</div>,
7847+
);
7848+
});
7849+
77497850
// @gate enablePostpone
77507851
it('does not call onError when you abort with a postpone instance during resume', async () => {
77517852
let prerendering = true;

packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,4 +454,56 @@ describe('ReactDOMFizzStatic', () => {
454454
});
455455
expect(getVisibleChildren(container)).toEqual(undefined);
456456
});
457+
458+
// @enableHalt
459+
it('will halt a prerender when aborting with an error during a render', async () => {
460+
const controller = new AbortController();
461+
function App() {
462+
controller.abort('sync');
463+
return <div>hello world</div>;
464+
}
465+
466+
const errors = [];
467+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
468+
signal: controller.signal,
469+
onError(error) {
470+
errors.push(error);
471+
},
472+
});
473+
await act(async () => {
474+
result.prelude.pipe(writable);
475+
});
476+
expect(errors).toEqual(['sync']);
477+
expect(getVisibleChildren(container)).toEqual(undefined);
478+
});
479+
480+
// @enableHalt
481+
it('will halt a prerender when aborting with an error in a microtask', async () => {
482+
const errors = [];
483+
484+
const controller = new AbortController();
485+
function App() {
486+
React.use(
487+
new Promise(() => {
488+
Promise.resolve().then(() => {
489+
controller.abort('async');
490+
});
491+
}),
492+
);
493+
return <div>hello world</div>;
494+
}
495+
496+
errors.length = 0;
497+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
498+
signal: controller.signal,
499+
onError(error) {
500+
errors.push(error);
501+
},
502+
});
503+
await act(async () => {
504+
result.prelude.pipe(writable);
505+
});
506+
expect(errors).toEqual(['async']);
507+
expect(getVisibleChildren(container)).toEqual(undefined);
508+
});
457509
});

packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,39 @@ describe('ReactDOMFizzStaticNode', () => {
211211
expect(errors).toEqual(['This operation was aborted']);
212212
});
213213

214-
// @gate experimental
215-
it('should reject if aborting before the shell is complete', async () => {
214+
// @gate !enableHalt
215+
fit('should reject if aborting before the shell is complete', async () => {
216+
const errors = [];
217+
const controller = new AbortController();
218+
const promise = ReactDOMFizzStatic.prerenderToNodeStream(
219+
<div>
220+
<InfiniteSuspend />
221+
</div>,
222+
{
223+
signal: controller.signal,
224+
onError(x) {
225+
errors.push(x.message);
226+
},
227+
},
228+
);
229+
230+
await jest.runAllTimers();
231+
232+
const theReason = new Error('aborted for reasons');
233+
controller.abort(theReason);
234+
235+
let caughtError = null;
236+
try {
237+
await promise;
238+
} catch (error) {
239+
caughtError = error;
240+
}
241+
expect(caughtError).toBe(theReason);
242+
expect(errors).toEqual(['aborted for reasons']);
243+
});
244+
245+
// @gate enableHalt
246+
fit('should resolve an empty shell if aborting before the shell is complete', async () => {
216247
const errors = [];
217248
const controller = new AbortController();
218249
const promise = ReactDOMFizzStatic.prerenderToNodeStream(

packages/react-server/src/ReactFizzServer.js

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ import {
157157
enableSuspenseAvoidThisFallbackFizz,
158158
enableCache,
159159
enablePostpone,
160+
enableHalt,
160161
enableRenderableContext,
161162
enableRefAsProp,
162163
disableDefaultPropsExceptForClasses,
@@ -3625,6 +3626,9 @@ function erroredTask(
36253626
) {
36263627
// Report the error to a global handler.
36273628
let errorDigest;
3629+
// We don't handle halts here because we only halt when prerendering and
3630+
// when prerendering we should be finishing tasks not erroring them when
3631+
// they halt or postpone
36283632
if (
36293633
enablePostpone &&
36303634
typeof error === 'object' &&
@@ -3812,6 +3816,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
38123816
logRecoverableError(request, fatal, errorInfo, null);
38133817
fatalError(request, fatal, errorInfo, null);
38143818
}
3819+
} else if (enableHalt && request.trackedPostpones !== null) {
3820+
// We are aborting a prerender and must treat the shell as halted
3821+
// We log the error but we still resolve the prerender
3822+
logRecoverableError(request, error, errorInfo, null);
3823+
trackPostpone(request, request.trackedPostpones, task, segment);
3824+
finishedTask(request, null, segment);
38153825
} else {
38163826
logRecoverableError(request, error, errorInfo, null);
38173827
fatalError(request, error, errorInfo, null);
@@ -3856,10 +3866,40 @@ function abortTask(task: Task, request: Request, error: mixed): void {
38563866
}
38573867
} else {
38583868
boundary.pendingTasks--;
3869+
// We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
3870+
// boundary the message is referring to
3871+
const errorInfo = getThrownInfo(task.componentStack);
3872+
const trackedPostpones = request.trackedPostpones;
38593873
if (boundary.status !== CLIENT_RENDERED) {
3860-
// We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
3861-
// boundary the message is referring to
3862-
const errorInfo = getThrownInfo(task.componentStack);
3874+
if (enableHalt) {
3875+
if (trackedPostpones !== null) {
3876+
// We are aborting a prerender
3877+
if (
3878+
enablePostpone &&
3879+
typeof error === 'object' &&
3880+
error !== null &&
3881+
error.$$typeof === REACT_POSTPONE_TYPE
3882+
) {
3883+
const postponeInstance: Postpone = (error: any);
3884+
logPostpone(request, postponeInstance.message, errorInfo, null);
3885+
} else {
3886+
// We are aborting a prerender and must halt this boundary.
3887+
// We treat this like other postpones during prerendering
3888+
logRecoverableError(request, error, errorInfo, null);
3889+
}
3890+
trackPostpone(request, trackedPostpones, task, segment);
3891+
// If this boundary was still pending then we haven't already cancelled its fallbacks.
3892+
// We'll need to abort the fallbacks, which will also error that parent boundary.
3893+
boundary.fallbackAbortableTasks.forEach(fallbackTask =>
3894+
abortTask(fallbackTask, request, error),
3895+
);
3896+
boundary.fallbackAbortableTasks.clear();
3897+
return finishedTask(request, boundary, segment);
3898+
}
3899+
}
3900+
boundary.status = CLIENT_RENDERED;
3901+
// We are aborting a render or resume which should put boundaries
3902+
// into an explicitly client rendered state
38633903
let errorDigest;
38643904
if (
38653905
enablePostpone &&
@@ -4178,6 +4218,27 @@ function retryRenderTask(
41784218
finishedTask(request, task.blockedBoundary, segment);
41794219
return;
41804220
}
4221+
} else if (
4222+
enableHalt &&
4223+
request.status === ABORTING &&
4224+
request.trackedPostpones !== null
4225+
) {
4226+
// We are aborting a prerender and need to halt this task.
4227+
// We log the error but encode a POSTPONE. Since we are prerendering
4228+
// and we have postpone semantics we also need to finish the task
4229+
// rather than erroring it.
4230+
const trackedPostpones = request.trackedPostpones;
4231+
task.abortSet.delete(task);
4232+
4233+
logRecoverableError(
4234+
request,
4235+
x,
4236+
errorInfo,
4237+
__DEV__ && enableOwnerStacks ? task.debugTask : null,
4238+
);
4239+
trackPostpone(request, trackedPostpones, task, segment);
4240+
finishedTask(request, task.blockedBoundary, segment);
4241+
return;
41814242
}
41824243

41834244
const errorInfo = getThrownInfo(task.componentStack);

0 commit comments

Comments
 (0)