Skip to content

Commit 2a54019

Browse files
authored
[Flight] do not emit error after abort (#30683)
When synchronously aborting in a non-async Function Component if you throw after aborting the task would error rather than abort because React never observed the AbortSignal. Using a sigil to throw after aborting during render isn't effective b/c the user code itself could throw so insteead we just read the request status. This is ok b/c we don't expect any tasks to still be pending after the currently running task finishes. However I found one instance where that wasn't true related to serializing thenables which I've fixed so we may find other cases. If we do, though it's almost certainly a bug in our task bookkeeping so we should just fix it if it comes up. I also updated `abort` to not set the status to ABORTING unless the status was OPEN. we don't want to ever leave CLOSED or CLOSING status
1 parent 8e60bac commit 2a54019

File tree

2 files changed

+110
-9
lines changed

2 files changed

+110
-9
lines changed

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,4 +2554,100 @@ describe('ReactFlightDOM', () => {
25542554
</div>,
25552555
);
25562556
});
2557+
2558+
it('can error synchronously after aborting in a synchronous Component', async () => {
2559+
const rejectError = new Error('bam!');
2560+
const rejectedPromise = Promise.reject(rejectError);
2561+
rejectedPromise.catch(() => {});
2562+
rejectedPromise.status = 'rejected';
2563+
rejectedPromise.reason = rejectError;
2564+
2565+
const resolvedValue = <p>hello world</p>;
2566+
const resolvedPromise = Promise.resolve(resolvedValue);
2567+
resolvedPromise.status = 'fulfilled';
2568+
resolvedPromise.value = resolvedValue;
2569+
2570+
function App() {
2571+
return (
2572+
<div>
2573+
<Suspense fallback={<p>loading...</p>}>
2574+
<ComponentThatAborts />
2575+
</Suspense>
2576+
<Suspense fallback={<p>loading too...</p>}>
2577+
{rejectedPromise}
2578+
</Suspense>
2579+
<Suspense fallback={<p>loading three...</p>}>
2580+
{resolvedPromise}
2581+
</Suspense>
2582+
</div>
2583+
);
2584+
}
2585+
2586+
const abortRef = {current: null};
2587+
2588+
// This test is specifically asserting that this works with Sync Server Component
2589+
function ComponentThatAborts() {
2590+
abortRef.current();
2591+
throw new Error('boom');
2592+
}
2593+
2594+
const {writable: flightWritable, readable: flightReadable} =
2595+
getTestStream();
2596+
2597+
await serverAct(() => {
2598+
const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
2599+
<App />,
2600+
webpackMap,
2601+
{
2602+
onError(e) {
2603+
console.error(e);
2604+
},
2605+
},
2606+
);
2607+
abortRef.current = abort;
2608+
pipe(flightWritable);
2609+
});
2610+
2611+
assertConsoleErrorDev([
2612+
'The render was aborted by the server without a reason.',
2613+
'bam!',
2614+
]);
2615+
2616+
const response =
2617+
ReactServerDOMClient.createFromReadableStream(flightReadable);
2618+
2619+
const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
2620+
2621+
function ClientApp() {
2622+
return use(response);
2623+
}
2624+
2625+
const shellErrors = [];
2626+
await serverAct(async () => {
2627+
ReactDOMFizzServer.renderToPipeableStream(
2628+
React.createElement(ClientApp),
2629+
{
2630+
onShellError(error) {
2631+
shellErrors.push(error.message);
2632+
},
2633+
},
2634+
).pipe(fizzWritable);
2635+
});
2636+
assertConsoleErrorDev([
2637+
'The render was aborted by the server without a reason.',
2638+
'bam!',
2639+
]);
2640+
2641+
expect(shellErrors).toEqual([]);
2642+
2643+
const container = document.createElement('div');
2644+
await readInto(container, fizzReadable);
2645+
expect(getMeaningfulChildren(container)).toEqual(
2646+
<div>
2647+
<p>loading...</p>
2648+
<p>loading too...</p>
2649+
<p>hello world</p>
2650+
</div>,
2651+
);
2652+
});
25572653
});

packages/react-server/src/ReactFlightServer.js

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,6 @@ export type Request = {
382382
didWarnForKey: null | WeakSet<ReactComponentInfo>,
383383
};
384384

385-
const AbortSigil = {};
386-
387385
const {
388386
TaintRegistryObjects,
389387
TaintRegistryValues,
@@ -594,6 +592,8 @@ function serializeThenable(
594592
const digest = logRecoverableError(request, x, null);
595593
emitErrorChunk(request, newTask.id, digest, x);
596594
}
595+
newTask.status = ERRORED;
596+
request.abortableTasks.delete(newTask);
597597
return newTask.id;
598598
}
599599
default: {
@@ -650,10 +650,10 @@ function serializeThenable(
650650
logPostpone(request, postponeInstance.message, newTask);
651651
emitPostponeChunk(request, newTask.id, postponeInstance);
652652
} else {
653-
newTask.status = ERRORED;
654653
const digest = logRecoverableError(request, reason, newTask);
655654
emitErrorChunk(request, newTask.id, digest, reason);
656655
}
656+
newTask.status = ERRORED;
657657
request.abortableTasks.delete(newTask);
658658
enqueueFlush(request);
659659
},
@@ -1114,7 +1114,8 @@ function renderFunctionComponent<Props>(
11141114
// If we aborted during rendering we should interrupt the render but
11151115
// we don't need to provide an error because the renderer will encode
11161116
// the abort error as the reason.
1117-
throw AbortSigil;
1117+
// eslint-disable-next-line no-throw-literal
1118+
throw null;
11181119
}
11191120

11201121
if (
@@ -1616,7 +1617,8 @@ function renderElement(
16161617
// lazy initializers are user code and could abort during render
16171618
// we don't wan to return any value resolved from the lazy initializer
16181619
// if it aborts so we interrupt rendering here
1619-
throw AbortSigil;
1620+
// eslint-disable-next-line no-throw-literal
1621+
throw null;
16201622
}
16211623
return renderElement(
16221624
request,
@@ -2183,7 +2185,7 @@ function renderModel(
21832185
}
21842186
}
21852187

2186-
if (thrownValue === AbortSigil) {
2188+
if (request.status === ABORTING) {
21872189
task.status = ABORTED;
21882190
const errorId: number = (request.fatalError: any);
21892191
if (wasReactNode) {
@@ -2357,7 +2359,8 @@ function renderModelDestructive(
23572359
// lazy initializers are user code and could abort during render
23582360
// we don't wan to return any value resolved from the lazy initializer
23592361
// if it aborts so we interrupt rendering here
2360-
throw AbortSigil;
2362+
// eslint-disable-next-line no-throw-literal
2363+
throw null;
23612364
}
23622365
if (__DEV__) {
23632366
const debugInfo: ?ReactDebugInfo = lazy._debugInfo;
@@ -3690,7 +3693,7 @@ function retryTask(request: Request, task: Task): void {
36903693
}
36913694
}
36923695

3693-
if (x === AbortSigil) {
3696+
if (request.status === ABORTING) {
36943697
request.abortableTasks.delete(task);
36953698
task.status = ABORTED;
36963699
const errorId: number = (request.fatalError: any);
@@ -3909,7 +3912,9 @@ export function stopFlowing(request: Request): void {
39093912
// This is called to early terminate a request. It creates an error at all pending tasks.
39103913
export function abort(request: Request, reason: mixed): void {
39113914
try {
3912-
request.status = ABORTING;
3915+
if (request.status === OPEN) {
3916+
request.status = ABORTING;
3917+
}
39133918
const abortableTasks = request.abortableTasks;
39143919
// We have tasks to abort. We'll emit one error row and then emit a reference
39153920
// to that row from every row that's still remaining.

0 commit comments

Comments
 (0)