[Fizz] Allow aborting during render (#30488)
Currently if you abort a Fizz render during rendering the render will not complete correctly because there are inconsistencies with task counting. This change updates the abort implementation to allow you to abort from within a render itself. We already landed a similar change for Flight in #29764
Josh Story committed
Jul 29, 2024 at 13:18 UTC
a451de014ca71718aee924bb57d5b4a1d87e20f2
2 files changed
+334
-17
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+250
@@ -8127,6 +8127,256 @@ describe('ReactDOMFizzServer', () => {
8127
expect(document.body.textContent).toBe('HelloWorld');
8128
});
8129
8130
+ it('can abort synchronously during render', async () => {
8131
+ function Sibling() {
8132
+ return <p>sibling</p>;
8133
+ }
8134
+
8135
+ function App() {
8136
+ return (
8137
+ <div>
8138
+ <Suspense fallback={<p>loading 1...</p>}>
8139
+ <ComponentThatAborts />
8140
+ <Sibling />
8141
+ </Suspense>
8142
+ <Suspense fallback={<p>loading 2...</p>}>
8143
+ <Sibling />
8144
+ </Suspense>
8145
+ <div>
8146
+ <Suspense fallback={<p>loading 3...</p>}>
8147
+ <div>
8148
+ <Sibling />
8149
+ </div>
8150
+ </Suspense>
8151
+ </div>
8152
+ </div>
8153
+ );
8154
+ }
8155
+
8156
+ const abortRef = {current: null};
8157
+ function ComponentThatAborts() {
8158
+ abortRef.current();
8159
+ return <p>hello world</p>;
8160
+ }
8161
+
8162
+ let finished = false;
8163
+ await act(() => {
8164
+ const {pipe, abort} = renderToPipeableStream(<App />);
8165
+ abortRef.current = abort;
8166
+ writable.on('finish', () => {
8167
+ finished = true;
8168
+ });
8169
+ pipe(writable);
8170
+ });
8171
+
8172
+ assertConsoleErrorDev([
8173
+ 'The render was aborted by the server without a reason.',
8174
+ 'The render was aborted by the server without a reason.',
8175
+ 'The render was aborted by the server without a reason.',
8176
+ ]);
8177
+
8178
+ expect(finished).toBe(true);
8179
+ expect(getVisibleChildren(container)).toEqual(
8180
+ <div>
8181
+ <p>loading 1...</p>
8182
+ <p>loading 2...</p>
8183
+ <div>
8184
+ <p>loading 3...</p>
8185
+ </div>
8186
+ </div>,
8187
+ );
8188
+ });
8189
+
8190
+ it('can abort during render in a lazy initializer for a component', async () => {
8191
+ function Sibling() {
8192
+ return <p>sibling</p>;
8193
+ }
8194
+
8195
+ function App() {
8196
+ return (
8197
+ <div>
8198
+ <Suspense fallback={<p>loading 1...</p>}>
8199
+ <LazyAbort />
8200
+ <Sibling />
8201
+ </Suspense>
8202
+ <Suspense fallback={<p>loading 2...</p>}>
8203
+ <Sibling />
8204
+ </Suspense>
8205
+ <div>
8206
+ <Suspense fallback={<p>loading 3...</p>}>
8207
+ <div>
8208
+ <Sibling />
8209
+ </div>
8210
+ </Suspense>
8211
+ </div>
8212
+ </div>
8213
+ );
8214
+ }
8215
+
8216
+ const abortRef = {current: null};
8217
+ const LazyAbort = React.lazy(() => {
8218
+ abortRef.current();
8219
+ return {
8220
+ then(cb) {
8221
+ cb({default: 'div'});
8222
+ },
8223
+ };
8224
+ });
8225
+
8226
+ let finished = false;
8227
+ await act(() => {
8228
+ const {pipe, abort} = renderToPipeableStream(<App />);
8229
+ abortRef.current = abort;
8230
+ writable.on('finish', () => {
8231
+ finished = true;
8232
+ });
8233
+ pipe(writable);
8234
+ });
8235
+
8236
+ assertConsoleErrorDev([
8237
+ 'The render was aborted by the server without a reason.',
8238
+ 'The render was aborted by the server without a reason.',
8239
+ 'The render was aborted by the server without a reason.',
8240
+ ]);
8241
+
8242
+ expect(finished).toBe(true);
8243
+ expect(getVisibleChildren(container)).toEqual(
8244
+ <div>
8245
+ <p>loading 1...</p>
8246
+ <p>loading 2...</p>
8247
+ <div>
8248
+ <p>loading 3...</p>
8249
+ </div>
8250
+ </div>,
8251
+ );
8252
+ });
8253
+
8254
+ it('can abort during render in a lazy initializer for an element', async () => {
8255
+ function Sibling() {
8256
+ return <p>sibling</p>;
8257
+ }
8258
+
8259
+ function App() {
8260
+ return (
8261
+ <div>
8262
+ <Suspense fallback={<p>loading 1...</p>}>
8263
+ {lazyAbort}
8264
+ <Sibling />
8265
+ </Suspense>
8266
+ <Suspense fallback={<p>loading 2...</p>}>
8267
+ <Sibling />
8268
+ </Suspense>
8269
+ <div>
8270
+ <Suspense fallback={<p>loading 3...</p>}>
8271
+ <div>
8272
+ <Sibling />
8273
+ </div>
8274
+ </Suspense>
8275
+ </div>
8276
+ </div>
8277
+ );
8278
+ }
8279
+
8280
+ const abortRef = {current: null};
8281
+ const lazyAbort = React.lazy(() => {
8282
+ abortRef.current();
8283
+ return {
8284
+ then(cb) {
8285
+ cb({default: 'hello world'});
8286
+ },
8287
+ };
8288
+ });
8289
+
8290
+ let finished = false;
8291
+ await act(() => {
8292
+ const {pipe, abort} = renderToPipeableStream(<App />);
8293
+ abortRef.current = abort;
8294
+ writable.on('finish', () => {
8295
+ finished = true;
8296
+ });
8297
+ pipe(writable);
8298
+ });
8299
+
8300
+ assertConsoleErrorDev([
8301
+ 'The render was aborted by the server without a reason.',
8302
+ 'The render was aborted by the server without a reason.',
8303
+ 'The render was aborted by the server without a reason.',
8304
+ ]);
8305
+
8306
+ expect(finished).toBe(true);
8307
+ expect(getVisibleChildren(container)).toEqual(
8308
+ <div>
8309
+ <p>loading 1...</p>
8310
+ <p>loading 2...</p>
8311
+ <div>
8312
+ <p>loading 3...</p>
8313
+ </div>
8314
+ </div>,
8315
+ );
8316
+ });
8317
+
8318
+ it('can abort during a synchronous thenable resolution', async () => {
8319
+ function Sibling() {
8320
+ return <p>sibling</p>;
8321
+ }
8322
+
8323
+ function App() {
8324
+ return (
8325
+ <div>
8326
+ <Suspense fallback={<p>loading 1...</p>}>
8327
+ {thenable}
8328
+ <Sibling />
8329
+ </Suspense>
8330
+ <Suspense fallback={<p>loading 2...</p>}>
8331
+ <Sibling />
8332
+ </Suspense>
8333
+ <div>
8334
+ <Suspense fallback={<p>loading 3...</p>}>
8335
+ <div>
8336
+ <Sibling />
8337
+ </div>
8338
+ </Suspense>
8339
+ </div>
8340
+ </div>
8341
+ );
8342
+ }
8343
+
8344
+ const abortRef = {current: null};
8345
+ const thenable = {
8346
+ then(cb) {
8347
+ abortRef.current();
8348
+ cb(thenable.value);
8349
+ },
8350
+ };
8351
+
8352
+ let finished = false;
8353
+ await act(() => {
8354
+ const {pipe, abort} = renderToPipeableStream(<App />);
8355
+ abortRef.current = abort;
8356
+ writable.on('finish', () => {
8357
+ finished = true;
8358
+ });
8359
+ pipe(writable);
8360
+ });
8361
+
8362
+ assertConsoleErrorDev([
8363
+ 'The render was aborted by the server without a reason.',
8364
+ 'The render was aborted by the server without a reason.',
8365
+ 'The render was aborted by the server without a reason.',
8366
+ ]);
8367
+
8368
+ expect(finished).toBe(true);
8369
+ expect(getVisibleChildren(container)).toEqual(
8370
+ <div>
8371
+ <p>loading 1...</p>
8372
+ <p>loading 2...</p>
8373
+ <div>
8374
+ <p>loading 3...</p>
8375
+ </div>
8376
+ </div>,
8377
+ );
8378
+ });
8379
+
8380
it('should warn for using generators as children props', async () => {
8381
function* getChildren() {
8382
yield <h1 key="1">Hello</h1>;
packages/react-server/src/ReactFizzServer.js
+84
-17
@@ -294,11 +294,12 @@ const FLUSHED = 2;
294
const ABORTED = 3;
295
const ERRORED = 4;
296
const POSTPONED = 5;
297
+const RENDERING = 6;
298
299
type Root = null;
300
301
type Segment = {
301
- status: 0 | 1 | 2 | 3 | 4 | 5,
302
+ status: 0 | 1 | 2 | 3 | 4 | 5 | 6,
303
parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed
304
id: number, // starts as 0 and is lazily assigned if the parent flushes early
305
+index: number, // the index within the parent's chunks or 0 at the root
@@ -314,8 +315,9 @@ type Segment = {
315
};
316
317
const OPEN = 0;
317
-const CLOSING = 1;
318
-const CLOSED = 2;
318
+const ABORTING = 1;
319
+const CLOSING = 2;
320
+const CLOSED = 3;
321
322
export opaque type Request = {
323
destination: null | Destination,
@@ -324,7 +326,7 @@ export opaque type Request = {
326
+renderState: RenderState,
327
+rootFormatContext: FormatContext,
328
+progressiveChunkSize: number,
327
- status: 0 | 1 | 2,
329
+ status: 0 | 1 | 2 | 3,
330
fatalError: mixed,
331
nextSegmentId: number,
332
allPendingTasks: number, // when it reaches zero, we can close the connection.
@@ -650,6 +652,8 @@ export function resumeRequest(
652
return request;
653
}
654
655
+const AbortSigil = {};
656
+
657
let currentRequest: null | Request = null;
658
659
export function resolveRequest(): null | Request {
@@ -1158,6 +1162,7 @@ function renderSuspenseBoundary(
1162
1163
task.blockedSegment = boundarySegment;
1164
task.keyPath = fallbackKeyPath;
1165
+ boundarySegment.status = RENDERING;
1166
try {
1167
renderNode(request, task, fallback, -1);
1168
pushSegmentFinale(
@@ -1167,6 +1172,13 @@ function renderSuspenseBoundary(
1172
boundarySegment.textEmbedded,
1173
);
1174
boundarySegment.status = COMPLETED;
1175
+ } catch (thrownValue: mixed) {
1176
+ if (thrownValue === AbortSigil) {
1177
+ boundarySegment.status = ABORTED;
1178
+ } else {
1179
+ boundarySegment.status = ERRORED;
1180
+ }
1181
+ throw thrownValue;
1182
} finally {
1183
task.blockedSegment = parentSegment;
1184
task.keyPath = prevKeyPath;
@@ -1211,6 +1223,7 @@ function renderSuspenseBoundary(
1223
task.hoistableState = newBoundary.contentState;
1224
task.blockedSegment = contentRootSegment;
1225
task.keyPath = keyPath;
1226
+ contentRootSegment.status = RENDERING;
1227
1228
try {
1229
// We use the safe form because we don't handle suspending here. Only error handling.
@@ -1230,9 +1243,17 @@ function renderSuspenseBoundary(
1243
newBoundary.status = COMPLETED;
1244
return;
1245
}
1233
- } catch (error: mixed) {
1234
- contentRootSegment.status = ERRORED;
1246
+ } catch (thrownValue: mixed) {
1247
newBoundary.status = CLIENT_RENDERED;
1248
+ let error: mixed;
1249
+ if (thrownValue === AbortSigil) {
1250
+ contentRootSegment.status = ABORTED;
1251
+ error = request.fatalError;
1252
+ } else {
1253
+ contentRootSegment.status = ERRORED;
1254
+ error = thrownValue;
1255
+ }
1256
+
1257
const thrownInfo = getThrownInfo(task.componentStack);
1258
let errorDigest;
1259
if (
@@ -1579,6 +1600,9 @@ function finishClassComponent(
1600
} else {
1601
nextChildren = instance.render();
1602
}
1603
+ if (request.status === ABORTING) {
1604
+ throw AbortSigil;
1605
+ }
1606
1607
if (__DEV__) {
1608
if (instance.props !== props) {
@@ -1732,6 +1756,10 @@ function renderFunctionComponent(
1756
props,
1757
legacyContext,
1758
);
1759
+ if (request.status === ABORTING) {
1760
+ throw AbortSigil;
1761
+ }
1762
+
1763
const hasId = checkDidRenderIdHook();
1764
const actionStateCount = getActionStateCount();
1765
const actionStateMatchingIndex = getActionStateMatchingIndex();
@@ -2047,6 +2075,9 @@ function renderLazyComponent(
2075
const init = lazyComponent._init;
2076
Component = init(payload);
2077
}
2078
+ if (request.status === ABORTING) {
2079
+ throw AbortSigil;
2080
+ }
2081
const resolvedProps = resolveDefaultPropsOnNonClassComponent(
2082
Component,
2083
props,
@@ -2623,6 +2654,9 @@ function retryNode(request: Request, task: Task): void {
2654
const init = lazyNode._init;
2655
resolvedNode = init(payload);
2656
}
2657
+ if (request.status === ABORTING) {
2658
+ throw AbortSigil;
2659
+ }
2660
// Now we render the resolved node
2661
renderNodeDestructive(request, task, resolvedNode, childIndex);
2662
return;
@@ -3738,6 +3772,11 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3772
const boundary = task.blockedBoundary;
3773
const segment = task.blockedSegment;
3774
if (segment !== null) {
3775
+ if (segment.status === RENDERING) {
3776
+ // This is the a currently rendering Segment. The render itself will
3777
+ // abort the task.
3778
+ return;
3779
+ }
3780
segment.status = ABORTED;
3781
}
3782
@@ -4032,6 +4071,10 @@ function retryRenderTask(
4071
// We completed this by other means before we had a chance to retry it.
4072
return;
4073
}
4074
+
4075
+ // We track when a Segment is rendering so we can handle aborts while rendering
4076
+ segment.status = RENDERING;
4077
+
4078
// We restore the context to what it was when we suspended.
4079
// We don't restore it after we leave because it's likely that we'll end up
4080
// needing a very similar context soon again.
@@ -4080,9 +4123,10 @@ function retryRenderTask(
4123
// $FlowFixMe[method-unbinding]
4124
if (typeof x.then === 'function') {
4125
// Something suspended again, let's pick it back up later.
4126
+ segment.status = PENDING;
4127
+ task.thenableState = getThenableStateAfterSuspending();
4128
const ping = task.ping;
4129
x.then(ping, ping);
4085
- task.thenableState = getThenableStateAfterSuspending();
4130
return;
4131
} else if (
4132
enablePostpone &&
@@ -4111,14 +4155,26 @@ function retryRenderTask(
4155
4156
const errorInfo = getThrownInfo(task.componentStack);
4157
task.abortSet.delete(task);
4114
- segment.status = ERRORED;
4115
- erroredTask(
4116
- request,
4117
- task.blockedBoundary,
4118
- x,
4119
- errorInfo,
4120
- __DEV__ && enableOwnerStacks ? task.debugTask : null,
4121
- );
4158
+
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
+ }
4178
return;
4179
} finally {
4180
if (__DEV__) {
@@ -4192,7 +4248,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
4248
erroredReplay(
4249
request,
4250
task.blockedBoundary,
4195
- x,
4251
+ x === AbortSigil ? request.fatalError : x,
4252
errorInfo,
4253
task.replay.nodes,
4254
task.replay.slots,
@@ -4725,6 +4781,7 @@ function flushCompletedQueues(
4781
}
4782
}
4783
// We're done.
4784
+ request.status = CLOSED;
4785
close(destination);
4786
// We need to stop flowing now because we do not want any async contexts which might call
4787
// float methods to initiate any flushes after this point
@@ -4846,13 +4903,23 @@ export function stopFlowing(request: Request): void {
4903
4904
// This is called to early terminate a request. It puts all pending boundaries in client rendered state.
4905
export function abort(request: Request, reason: mixed): void {
4906
+ if (request.status === OPEN) {
4907
+ request.status = ABORTING;
4908
+ }
4909
try {
4910
const abortableTasks = request.abortableTasks;
4911
if (abortableTasks.size > 0) {
4912
const error =
4913
reason === undefined
4914
? new Error('The render was aborted by the server without a reason.')
4855
- : reason;
4915
+ : typeof reason === 'object' &&
4916
+ reason !== null &&
4917
+ typeof reason.then === 'function'
4918
+ ? new Error('The render was aborted by the server with a promise.')
4919
+ : reason;
4920
+ // This error isn't necessarily fatal in this case but we need to stash it
4921
+ // so we can use it to abort any pending work
4922
+ request.fatalError = error;
4923
abortableTasks.forEach(task => abortTask(task, request, error));
4924
abortableTasks.clear();
4925
}