Skip to content

Commit

Permalink
[Fizz][Static] when aborting a prerender halt unfinished boundaries i…
Browse files Browse the repository at this point in the history
…nstead of erroring

Halt semantics in Fizz are essentially the same as Postpone. Boundaries will be client rendered but no error will bubble up on the client. In this implementation we literally use the Postpone mechanic for implementing this. The key different between halting and postponing is in the triggering API. Halts happen when you abort a prerender whereas Postpones happened when you call React.unstable_postpone within a render or if you Abort with a postpone.

This latter case is an overlap where we would need ot decide which semantics to choose however thankfully aborting with a postpone and halting are equivalent so even when the halt flag is on we can implement this with an arbitrary hierarchy.

Note that with halting we still call onError and onPostpone so if you abort a prerender with an error you will see observe the error through this event handler but the client won't recover from an error in the browser.
  • Loading branch information
gnoff committed Aug 19, 2024
1 parent 6340525 commit 31fa415
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 21 deletions.
52 changes: 52 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,56 @@ describe('ReactDOMFizzStatic', () => {
});
expect(getVisibleChildren(container)).toEqual(undefined);
});

// @enableHalt
it('will halt a prerender when aborting with an error during a render', async () => {
const controller = new AbortController();
function App() {
controller.abort('sync');
return <div>hello world</div>;
}

const errors = [];
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error);
},
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(errors).toEqual(['sync']);
expect(getVisibleChildren(container)).toEqual(undefined);
});

// @enableHalt
it('will halt a prerender when aborting with an error in a microtask', async () => {
const errors = [];

const controller = new AbortController();
function App() {
React.use(
new Promise(() => {
Promise.resolve().then(() => {
controller.abort('async');
});
}),
);
return <div>hello world</div>;
}

errors.length = 0;
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
signal: controller.signal,
onError(error) {
errors.push(error);
},
});
await act(async () => {
result.prelude.pipe(writable);
});
expect(errors).toEqual(['async']);
expect(getVisibleChildren(container)).toEqual(undefined);
});
});
127 changes: 106 additions & 21 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
enableSuspenseAvoidThisFallbackFizz,
enableCache,
enablePostpone,
enableHalt,
enableRenderableContext,
enableRefAsProp,
disableDefaultPropsExceptForClasses,
Expand Down Expand Up @@ -3625,6 +3626,9 @@ function erroredTask(
) {
// Report the error to a global handler.
let errorDigest;
// We don't handle halts here because we only halt when prerendering and
// when prerendering we should be finishing tasks not erroring them when
// they halt or postpone
if (
enablePostpone &&
typeof error === 'object' &&
Expand Down Expand Up @@ -3812,6 +3816,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
logRecoverableError(request, fatal, errorInfo, null);
fatalError(request, fatal, errorInfo, null);
}
} else if (enableHalt && request.trackedPostpones !== null) {
// We are aborting a prerender and must treat the shell as halted
// We log the error but we still resolve the prerender
logRecoverableError(request, error, errorInfo, null);
trackPostpone(request, request.trackedPostpones, task, segment);
finishedTask(request, null, segment);
} else {
logRecoverableError(request, error, errorInfo, null);
fatalError(request, error, errorInfo, null);
Expand Down Expand Up @@ -3856,31 +3866,85 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}
} else {
boundary.pendingTasks--;
// We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
// boundary the message is referring to
const errorInfo = getThrownInfo(task.componentStack);
const trackedPostpones = request.trackedPostpones;
if (boundary.status !== CLIENT_RENDERED) {
boundary.status = CLIENT_RENDERED;
// We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
// boundary the message is referring to
const errorInfo = getThrownInfo(task.componentStack);
let errorDigest;
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
if (enableHalt) {
if (trackedPostpones !== null) {
// We are aborting a prerender
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, boundary, segment);
} else {
// We are aborting a prerender and must halt this boundary.
// We treat this like other postpones during prerendering
logRecoverableError(request, error, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, boundary, segment);
}
} else {
// Fork of the non-enableHalt path below. Keep these in sync

// We are aborting a render
boundary.status = CLIENT_RENDERED;
// We are aborting a render or resume which should put boundaries
// into an explicitly client rendered state
let errorDigest;
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(request, error, errorInfo, null);
}
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);

untrackBoundary(request, boundary);

if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
}
}
} else {
errorDigest = logRecoverableError(request, error, errorInfo, null);
}
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);
boundary.status = CLIENT_RENDERED;
// We are aborting a render or resume which should put boundaries
// into an explicitly client rendered state
let errorDigest;
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(request, error, errorInfo, null);
}
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);

untrackBoundary(request, boundary);
untrackBoundary(request, boundary);

if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
}
}
}

Expand Down Expand Up @@ -4166,6 +4230,27 @@ function retryRenderTask(
finishedTask(request, task.blockedBoundary, segment);
return;
}
} else if (
enableHalt &&
request.status === ABORTING &&
request.trackedPostpones !== null
) {
// We are aborting a prerender and need to halt this task.
// We log the error but encode a POSTPONE. Since we are prerendering
// and we have postpone semantics we also need to finish the task
// rather than erroring it.
const trackedPostpones = request.trackedPostpones;
task.abortSet.delete(task);

logRecoverableError(
request,
x,
errorInfo,
__DEV__ && enableOwnerStacks ? task.debugTask : null,
);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, task.blockedBoundary, segment);
return;
}

const errorInfo = getThrownInfo(task.componentStack);
Expand Down

0 comments on commit 31fa415

Please sign in to comment.