[Fizz] handle throwing after abort during render (#30730)
It is possible to throw after aborting during a render and we were not properly tracking this. We use an AbortSigil to mark whether a rendering task needs to abort but the throw interrupts that and we end up handling an error on the error pathway instead. This change reworks the abort-while-rendering support to be robust to throws after calling abort
Josh Story committed
Aug 16, 2024 at 18:29 UTC
7954db9398b9afa962167577a6c6940be3856c39
2 files changed
+54
-10
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+42
@@ -8377,6 +8377,48 @@ describe('ReactDOMFizzServer', () => {
8377
);
8378
});
8379
8380
+ it('can support throwing after aborting during a render', async () => {
8381
+ function App() {
8382
+ return (
8383
+ <div>
8384
+ <Suspense fallback={<p>loading...</p>}>
8385
+ <ComponentThatAborts />
8386
+ </Suspense>
8387
+ </div>
8388
+ );
8389
+ }
8390
+
8391
+ function ComponentThatAborts() {
8392
+ abortRef.current('boom');
8393
+ throw new Error('bam');
8394
+ }
8395
+
8396
+ const abortRef = {current: null};
8397
+ let finished = false;
8398
+ const errors = [];
8399
+ await act(() => {
8400
+ const {pipe, abort} = renderToPipeableStream(<App />, {
8401
+ onError(err) {
8402
+ errors.push(err);
8403
+ },
8404
+ });
8405
+ abortRef.current = abort;
8406
+ writable.on('finish', () => {
8407
+ finished = true;
8408
+ });
8409
+ pipe(writable);
8410
+ });
8411
+
8412
+ expect(errors).toEqual(['boom']);
8413
+
8414
+ expect(finished).toBe(true);
8415
+ expect(getVisibleChildren(container)).toEqual(
8416
+ <div>
8417
+ <p>loading...</p>
8418
+ </div>,
8419
+ );
8420
+ });
8421
+
8422
it('should warn for using generators as children props', async () => {
8423
function* getChildren() {
8424
yield <h1 key="1">Hello</h1>;
packages/react-server/src/ReactFizzServer.js
+12
-10
@@ -652,8 +652,6 @@ export function resumeRequest(
652
return request;
653
}
654
655
-const AbortSigil = {};
656
-
655
let currentRequest: null | Request = null;
656
657
export function resolveRequest(): null | Request {
@@ -1173,7 +1171,7 @@ function renderSuspenseBoundary(
1171
);
1172
boundarySegment.status = COMPLETED;
1173
} catch (thrownValue: mixed) {
1176
- if (thrownValue === AbortSigil) {
1174
+ if (request.status === ABORTING) {
1175
boundarySegment.status = ABORTED;
1176
} else {
1177
boundarySegment.status = ERRORED;
@@ -1246,7 +1244,7 @@ function renderSuspenseBoundary(
1244
} catch (thrownValue: mixed) {
1245
newBoundary.status = CLIENT_RENDERED;
1246
let error: mixed;
1249
- if (thrownValue === AbortSigil) {
1247
+ if (request.status === ABORTING) {
1248
contentRootSegment.status = ABORTED;
1249
error = request.fatalError;
1250
} else {
@@ -1601,7 +1599,8 @@ function finishClassComponent(
1599
nextChildren = instance.render();
1600
}
1601
if (request.status === ABORTING) {
1604
- throw AbortSigil;
1602
+ // eslint-disable-next-line no-throw-literal
1603
+ throw null;
1604
}
1605
1606
if (__DEV__) {
@@ -1757,7 +1756,8 @@ function renderFunctionComponent(
1756
legacyContext,
1757
);
1758
if (request.status === ABORTING) {
1760
- throw AbortSigil;
1759
+ // eslint-disable-next-line no-throw-literal
1760
+ throw null;
1761
}
1762
1763
const hasId = checkDidRenderIdHook();
@@ -2076,7 +2076,8 @@ function renderLazyComponent(
2076
Component = init(payload);
2077
}
2078
if (request.status === ABORTING) {
2079
- throw AbortSigil;
2079
+ // eslint-disable-next-line no-throw-literal
2080
+ throw null;
2081
}
2082
const resolvedProps = resolveDefaultPropsOnNonClassComponent(
2083
Component,
@@ -2655,7 +2656,8 @@ function retryNode(request: Request, task: Task): void {
2656
resolvedNode = init(payload);
2657
}
2658
if (request.status === ABORTING) {
2658
- throw AbortSigil;
2659
+ // eslint-disable-next-line no-throw-literal
2660
+ throw null;
2661
}
2662
// Now we render the resolved node
2663
renderNodeDestructive(request, task, resolvedNode, childIndex);
@@ -4127,7 +4129,7 @@ function retryRenderTask(
4129
// (unstable) API for suspending. This implementation detail can change
4130
// later, once we deprecate the old API in favor of `use`.
4131
getSuspendedThenable()
4130
- : thrownValue === AbortSigil
4132
+ : request.status === ABORTING
4133
? request.fatalError
4134
: thrownValue;
4135
@@ -4250,7 +4252,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
4252
erroredReplay(
4253
request,
4254
task.blockedBoundary,
4253
- x === AbortSigil ? request.fatalError : x,
4255
+ request.status === ABORTING ? request.fatalError : x,
4256
errorInfo,
4257
task.replay.nodes,
4258
task.replay.slots,