[Fizz] Expose a method to abort a pending request (#21027)
* Track all suspended work while it's still pending This allows us to abort work and put everything into client rendered mode if we don't want to wait for further I/O. It also allows us to cancel fallbacks if we complete the main content before the fallback. * Expose abort API to the browser streams Since this API already returns a value, we need to use destructuring to expose more options. * Add a test including the client actually client rendering it * Use AbortSignal option for W3C streams instead of external control * Clean up listener after it's used once
Sebastian Markbåge committed
Mar 18, 2021 at 12:46 UTC
cf485e6f6b4d6a6e2ccfe7a12537941925316e86
9 files changed
+275
-19
fixtures/fizz-ssr-browser/index.html
+4
-1
@@ -20,7 +20,10 @@
20
<script src="../../build/node_modules/react-dom/umd/react-dom-unstable-fizz.browser.development.js"></script>
21
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
22
<script type="text/babel">
23
- let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>);
23
+ let controller = new AbortController();
24
+ let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>, {
25
+ signal: controller.signal,
26
+ });
27
let response = new Response(stream, {
28
headers: {'Content-Type': 'text/html'},
29
});
package.json
+1
@@ -36,6 +36,7 @@
36
"@babel/preset-react": "^7.10.4",
37
"@babel/traverse": "^7.11.0",
38
"@mattiasbuelens/web-streams-polyfill": "^0.3.2",
39
+ "abort-controller": "^3.0.0",
40
"art": "0.10.1",
41
"babel-eslint": "^10.0.3",
42
"babel-plugin-syntax-trailing-function-commas": "^6.5.0",
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+51
@@ -263,4 +263,55 @@ describe('ReactDOMFizzServer', () => {
263
// Now it's hydrated.
264
expect(ref.current).toBe(h1);
265
});
266
+
267
+ // @gate experimental
268
+ it('client renders a boundary if it does not resolve before aborting', async () => {
269
+ function App() {
270
+ return (
271
+ <div>
272
+ <Suspense fallback="Loading...">
273
+ <h1>
274
+ <AsyncText text="Hello" />
275
+ </h1>
276
+ </Suspense>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ let controls;
282
+ await act(async () => {
283
+ controls = ReactDOMFizzServer.pipeToNodeWritable(<App />, writable);
284
+ });
285
+
286
+ // We're still showing a fallback.
287
+
288
+ // Attempt to hydrate the content.
289
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
290
+ root.render(<App />);
291
+ Scheduler.unstable_flushAll();
292
+
293
+ // We're still loading because we're waiting for the server to stream more content.
294
+ expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
295
+
296
+ // We abort the server response.
297
+ await act(async () => {
298
+ controls.abort();
299
+ });
300
+
301
+ // We still can't render it on the client.
302
+ Scheduler.unstable_flushAll();
303
+ expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
304
+
305
+ // We now resolve it on the client.
306
+ resolveText('Hello');
307
+
308
+ Scheduler.unstable_flushAll();
309
+
310
+ // The client rendered HTML is now in place.
311
+ expect(getVisibleChildren(container)).toEqual(
312
+ <div>
313
+ <h1>Hello</h1>
314
+ </div>,
315
+ );
316
+ });
317
});
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+19
@@ -12,6 +12,7 @@
12
// Polyfills for test environment
13
global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
14
global.TextEncoder = require('util').TextEncoder;
15
+global.AbortController = require('abort-controller');
16
17
let React;
18
let ReactDOMFizzServer;
@@ -110,4 +111,22 @@ describe('ReactDOMFizzServer', () => {
111
const result = await readResult(stream);
112
expect(result).toContain('Loading');
113
});
114
+
115
+ // @gate experimental
116
+ it('should be able to complete by aborting even if the promise never resolves', async () => {
117
+ const controller = new AbortController();
118
+ const stream = ReactDOMFizzServer.renderToReadableStream(
119
+ <div>
120
+ <Suspense fallback={<div>Loading</div>}>
121
+ <InfiniteSuspend />
122
+ </Suspense>
123
+ </div>,
124
+ {signal: controller.signal},
125
+ );
126
+
127
+ controller.abort();
128
+
129
+ const result = await readResult(stream);
130
+ expect(result).toContain('Loading');
131
+ });
132
});
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+50
@@ -115,4 +115,54 @@ describe('ReactDOMFizzServer', () => {
115
expect(output.error).toBe(undefined);
116
expect(output.result).toContain('Loading');
117
});
118
+
119
+ // @gate experimental
120
+ it('should not attempt to render the fallback if the main content completes first', async () => {
121
+ const {writable, output, completed} = getTestWritable();
122
+
123
+ let renderedFallback = false;
124
+ function Fallback() {
125
+ renderedFallback = true;
126
+ return 'Loading...';
127
+ }
128
+ function Content() {
129
+ return 'Hi';
130
+ }
131
+ ReactDOMFizzServer.pipeToNodeWritable(
132
+ <Suspense fallback={<Fallback />}>
133
+ <Content />
134
+ </Suspense>,
135
+ writable,
136
+ );
137
+
138
+ await completed;
139
+
140
+ expect(output.result).toContain('Hi');
141
+ expect(output.result).not.toContain('Loading');
142
+ expect(renderedFallback).toBe(false);
143
+ });
144
+
145
+ // @gate experimental
146
+ it('should be able to complete by aborting even if the promise never resolves', async () => {
147
+ const {writable, output, completed} = getTestWritable();
148
+ const {abort} = ReactDOMFizzServer.pipeToNodeWritable(
149
+ <div>
150
+ <Suspense fallback={<div>Loading</div>}>
151
+ <InfiniteSuspend />
152
+ </Suspense>
153
+ </div>,
154
+ writable,
155
+ );
156
+
157
+ jest.runAllTimers();
158
+
159
+ expect(output.result).toContain('Loading');
160
+
161
+ abort();
162
+
163
+ await completed;
164
+
165
+ expect(output.error).toBe(undefined);
166
+ expect(output.result).toContain('Loading');
167
+ });
168
});
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+17
-1
@@ -13,10 +13,26 @@ import {
13
createRequest,
14
startWork,
15
startFlowing,
16
+ abort,
17
} from 'react-server/src/ReactFizzServer';
18
18
-function renderToReadableStream(children: ReactNodeList): ReadableStream {
19
+type Options = {
20
+ signal?: AbortSignal,
21
+};
22
+
23
+function renderToReadableStream(
24
+ children: ReactNodeList,
25
+ options?: Options,
26
+): ReadableStream {
27
let request;
28
+ if (options && options.signal) {
29
+ const signal = options.signal;
30
+ const listener = () => {
31
+ abort(request);
32
+ signal.removeEventListener('abort', listener);
33
+ };
34
+ signal.addEventListener('abort', listener);
35
+ }
36
return new ReadableStream({
37
start(controller) {
38
request = createRequest(children, controller);
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+13
-1
@@ -14,19 +14,31 @@ import {
14
createRequest,
15
startWork,
16
startFlowing,
17
+ abort,
18
} from 'react-server/src/ReactFizzServer';
19
20
function createDrainHandler(destination, request) {
21
return () => startFlowing(request);
22
}
23
24
+type Controls = {
25
+ // Cancel any pending I/O and put anything remaining into
26
+ // client rendered mode.
27
+ abort(): void,
28
+};
29
+
30
function pipeToNodeWritable(
31
children: ReactNodeList,
32
destination: Writable,
26
-): void {
33
+): Controls {
34
const request = createRequest(children, destination);
35
destination.on('drain', createDrainHandler(destination, request));
36
startWork(request);
37
+ return {
38
+ abort() {
39
+ abort(request);
40
+ },
41
+ };
42
}
43
44
export {pipeToNodeWritable};
packages/react-noop-renderer/src/ReactNoopServer.js
+3
@@ -216,6 +216,9 @@ function render(children: React$Element<any>): Destination {
216
placeholders: new Map(),
217
segments: new Map(),
218
stack: [],
219
+ abort() {
220
+ ReactNoopServer.abort(request);
221
+ },
222
};
223
const request = ReactNoopServer.createRequest(children, destination);
224
ReactNoopServer.startWork(request);
packages/react-server/src/ReactFizzServer.js
+117
-16
@@ -61,6 +61,7 @@ type SuspenseBoundary = {
61
pendingWork: number, // when it reaches zero we can show this boundary's content
62
completedSegments: Array<Segment>, // completed but not yet flushed segments.
63
byteSize: number, // used to determine whether to inline children boundaries.
64
+ fallbackAbortableWork: Set<SuspendedWork>, // used to cancel work on the fallback if the boundary completes or gets canceled.
65
};
66
67
type SuspendedWork = {
@@ -68,18 +69,20 @@ type SuspendedWork = {
69
ping: () => void,
70
blockedBoundary: Root | SuspenseBoundary,
71
blockedSegment: Segment, // the segment we'll write to
72
+ abortSet: Set<SuspendedWork>, // the abortable set that this work belongs to
73
assignID: null | SuspenseBoundaryID, // id to assign to the content
74
};
75
76
const PENDING = 0;
77
const COMPLETED = 1;
78
const FLUSHED = 2;
77
-const ERRORED = 3;
79
+const ABORTED = 3;
80
+const ERRORED = 4;
81
82
type Root = null;
83
84
type Segment = {
82
- status: 0 | 1 | 2 | 3,
85
+ status: 0 | 1 | 2 | 3 | 4,
86
parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed
87
id: number, // starts as 0 and is lazily assigned if the parent flushes early
88
+index: number, // the index within the parent's chunks or 0 at the root
@@ -102,6 +105,7 @@ type Request = {
105
allPendingWork: number, // when it reaches zero, we can close the connection.
106
pendingRootWork: number, // when this reaches zero, we've finished at least the root boundary.
107
completedRootSegment: null | Segment, // Completed but not yet flushed root segments.
108
+ abortableWork: Set<SuspendedWork>,
109
pingedWork: Array<SuspendedWork>,
110
// Queues to flush in order of priority
111
clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
@@ -114,6 +118,7 @@ export function createRequest(
118
destination: Destination,
119
): Request {
120
const pingedWork = [];
121
+ const abortSet: Set<SuspendedWork> = new Set();
122
const request = {
123
destination,
124
responseState: createResponseState(),
@@ -123,6 +128,7 @@ export function createRequest(
128
allPendingWork: 0,
129
pendingRootWork: 0,
130
completedRootSegment: null,
131
+ abortableWork: abortSet,
132
pingedWork: pingedWork,
133
clientRenderedBoundaries: [],
134
completedBoundaries: [],
@@ -137,6 +143,7 @@ export function createRequest(
143
children,
144
null,
145
rootSegment,
146
+ abortSet,
147
null,
148
);
149
pingedWork.push(rootWork);
@@ -151,7 +158,10 @@ function pingSuspendedWork(request: Request, work: SuspendedWork): void {
158
}
159
}
160
154
-function createSuspenseBoundary(request: Request): SuspenseBoundary {
161
+function createSuspenseBoundary(
162
+ request: Request,
163
+ fallbackAbortableWork: Set<SuspendedWork>,
164
+): SuspenseBoundary {
165
return {
166
id: createSuspenseBoundaryID(request.responseState),
167
rootSegmentID: -1,
@@ -160,6 +170,7 @@ function createSuspenseBoundary(request: Request): SuspenseBoundary {
170
forceClientRender: false,
171
completedSegments: [],
172
byteSize: 0,
173
+ fallbackAbortableWork,
174
};
175
}
176
@@ -168,6 +179,7 @@ function createSuspendedWork(
179
node: ReactNodeList,
180
blockedBoundary: Root | SuspenseBoundary,
181
blockedSegment: Segment,
182
+ abortSet: Set<SuspendedWork>,
183
assignID: null | SuspenseBoundaryID,
184
): SuspendedWork {
185
request.allPendingWork++;
@@ -181,8 +193,10 @@ function createSuspendedWork(
193
ping: () => pingSuspendedWork(request, work),
194
blockedBoundary,
195
blockedSegment,
196
+ abortSet,
197
assignID,
198
};
199
+ abortSet.add(work);
200
return work;
201
}
202
@@ -219,6 +233,7 @@ function renderNode(
233
parentBoundary: Root | SuspenseBoundary,
234
segment: Segment,
235
node: ReactNodeList,
236
+ abortSet: Set<SuspendedWork>,
237
assignID: null | SuspenseBoundaryID,
238
): void {
239
if (typeof node === 'string') {
@@ -229,9 +244,9 @@ function renderNode(
244
if (Array.isArray(node)) {
245
if (node.length > 0) {
246
// Only the first node gets assigned an ID.
232
- renderNode(request, parentBoundary, segment, node[0], assignID);
247
+ renderNode(request, parentBoundary, segment, node[0], abortSet, assignID);
248
for (let i = 1; i < node.length; i++) {
234
- renderNode(request, parentBoundary, segment, node[i], null);
249
+ renderNode(request, parentBoundary, segment, node[i], abortSet, null);
250
}
251
} else {
252
pushEmpty(segment.chunks, request.responseState, assignID);
@@ -252,7 +267,7 @@ function renderNode(
267
if (typeof type === 'function') {
268
try {
269
const result = type(props);
255
- renderNode(request, parentBoundary, segment, result, assignID);
270
+ renderNode(request, parentBoundary, segment, result, abortSet, assignID);
271
} catch (x) {
272
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
273
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -264,6 +279,7 @@ function renderNode(
279
node,
280
parentBoundary,
281
newSegment,
282
+ abortSet,
283
assignID,
284
);
285
const ping = suspendedWork.ping;
@@ -282,7 +298,14 @@ function renderNode(
298
request.responseState,
299
assignID,
300
);
285
- renderNode(request, parentBoundary, segment, props.children, null);
301
+ renderNode(
302
+ request,
303
+ parentBoundary,
304
+ segment,
305
+ props.children,
306
+ abortSet,
307
+ null,
308
+ );
309
pushEndInstance(segment.chunks, type, props);
310
} else if (type === REACT_SUSPENSE_TYPE) {
311
// We need to push an "empty" thing here to identify the parent suspense boundary.
@@ -294,7 +317,8 @@ function renderNode(
317
const fallback: ReactNodeList = props.fallback;
318
const content: ReactNodeList = props.children;
319
297
- const newBoundary = createSuspenseBoundary(request);
320
+ const fallbackAbortSet: Set<SuspendedWork> = new Set();
321
+ const newBoundary = createSuspenseBoundary(request, fallbackAbortSet);
322
323
const insertionIndex = segment.chunks.length;
324
// The children of the boundary segment is actually the fallback.
@@ -304,6 +328,7 @@ function renderNode(
328
newBoundary,
329
);
330
segment.children.push(boundarySegment);
331
+
332
// We create suspended work for the fallback because we don't want to actually work
333
// on it yet in case we finish the main content, so we queue for later.
334
const suspendedFallbackWork = createSuspendedWork(
@@ -311,6 +336,7 @@ function renderNode(
336
fallback,
337
parentBoundary,
338
boundarySegment,
339
+ fallbackAbortSet,
340
newBoundary.id, // This is the ID we want to give this fallback so we can replace it later.
341
);
342
// TODO: This should be queued at a separate lower priority queue so that we only work
@@ -330,6 +356,7 @@ function renderNode(
356
content,
357
newBoundary,
358
contentRootSegment,
359
+ abortSet,
360
null,
361
);
362
retryWork(request, contentWork);
@@ -338,14 +365,12 @@ function renderNode(
365
}
366
}
367
341
-function errorWork(
368
+function erroredWork(
369
request: Request,
370
boundary: Root | SuspenseBoundary,
371
segment: Segment,
372
error: mixed,
373
) {
347
- segment.status = ERRORED;
348
-
374
request.allPendingWork--;
375
if (boundary !== null) {
376
boundary.pendingWork--;
@@ -370,12 +395,55 @@ function errorWork(
395
}
396
}
397
373
-function completeWork(
398
+function abortWorkSoft(suspendedWork: SuspendedWork): void {
399
+ // This aborts work without aborting the parent boundary that it blocks.
400
+ // It's used for when we didn't need this work to complete the tree.
401
+ // If work was needed, then it should use abortWork instead.
402
+ const request: Request = this;
403
+ const boundary = suspendedWork.blockedBoundary;
404
+ const segment = suspendedWork.blockedSegment;
405
+ segment.status = ABORTED;
406
+ finishedWork(request, boundary, segment);
407
+}
408
+
409
+function abortWork(suspendedWork: SuspendedWork): void {
410
+ // This aborts the work and aborts the parent that it blocks, putting it into
411
+ // client rendered mode.
412
+ const request: Request = this;
413
+ const boundary = suspendedWork.blockedBoundary;
414
+ const segment = suspendedWork.blockedSegment;
415
+ segment.status = ABORTED;
416
+
417
+ request.allPendingWork--;
418
+ if (boundary === null) {
419
+ // We didn't complete the root so we have nothing to show. We can close
420
+ // the request;
421
+ if (request.status !== CLOSED) {
422
+ request.status = CLOSED;
423
+ close(request.destination);
424
+ }
425
+ } else {
426
+ boundary.pendingWork--;
427
+
428
+ // If this boundary was still pending then we haven't already cancelled its fallbacks.
429
+ // We'll need to abort the fallbacks, which will also error that parent boundary.
430
+ boundary.fallbackAbortableWork.forEach(abortWork, request);
431
+ boundary.fallbackAbortableWork.clear();
432
+
433
+ if (!boundary.forceClientRender) {
434
+ boundary.forceClientRender = true;
435
+ if (boundary.parentFlushed) {
436
+ request.clientRenderedBoundaries.push(boundary);
437
+ }
438
+ }
439
+ }
440
+}
441
+
442
+function finishedWork(
443
request: Request,
444
boundary: Root | SuspenseBoundary,
445
segment: Segment,
446
) {
378
- segment.status = COMPLETED;
447
request.allPendingWork--;
448
449
if (boundary === null) {
@@ -397,6 +465,9 @@ function completeWork(
465
}
466
if (boundary.pendingWork === 0) {
467
// This must have been the last segment we were waiting on. This boundary is now complete.
468
+ // We can now cancel any pending work on the fallback since we won't need to show it anymore.
469
+ boundary.fallbackAbortableWork.forEach(abortWorkSoft, request);
470
+ boundary.fallbackAbortableWork.clear();
471
if (segment.parentFlushed) {
472
// Our parent segment already flushed, so we need to schedule this segment to be emitted.
473
boundary.completedSegments.push(segment);
@@ -425,7 +496,12 @@ function completeWork(
496
497
function retryWork(request: Request, work: SuspendedWork): void {
498
const segment = work.blockedSegment;
499
+ if (segment.status !== PENDING) {
500
+ // We completed this by other means before we had a chance to retry it.
501
+ return;
502
+ }
503
const boundary = work.blockedBoundary;
504
+ const abortSet = work.abortSet;
505
try {
506
let node = work.node;
507
while (
@@ -442,16 +518,20 @@ function retryWork(request: Request, work: SuspendedWork): void {
518
node = element.type(element.props);
519
}
520
445
- renderNode(request, boundary, segment, node, work.assignID);
521
+ renderNode(request, boundary, segment, node, abortSet, work.assignID);
522
447
- completeWork(request, boundary, segment);
523
+ abortSet.delete(work);
524
+ segment.status = COMPLETED;
525
+ finishedWork(request, boundary, segment);
526
} catch (x) {
527
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
528
// Something suspended again, let's pick it back up later.
529
const ping = work.ping;
530
x.then(ping, ping);
531
} else {
454
- errorWork(request, boundary, segment, x);
532
+ abortSet.delete(work);
533
+ segment.status = ERRORED;
534
+ erroredWork(request, boundary, segment, x);
535
}
536
}
537
}
@@ -797,6 +877,13 @@ function flushCompletedQueues(request: Request): void {
877
// We don't need to check any partially completed segments because
878
// either they have pending work or they're complete.
879
) {
880
+ if (__DEV__) {
881
+ if (request.abortableWork.size !== 0) {
882
+ console.error(
883
+ 'There was still abortable work at the root when we closed. This is a bug in React.',
884
+ );
885
+ }
886
+ }
887
// We're done.
888
close(destination);
889
}
@@ -824,6 +911,20 @@ export function startFlowing(request: Request): void {
911
}
912
}
913
914
+// This is called to early terminate a request. It puts all pending boundaries in client rendered state.
915
+export function abort(request: Request): void {
916
+ try {
917
+ const abortableWork = request.abortableWork;
918
+ abortableWork.forEach(abortWork, request);
919
+ abortableWork.clear();
920
+ if (request.status === FLOWING) {
921
+ flushCompletedQueues(request);
922
+ }
923
+ } catch (error) {
924
+ fatalError(request, error);
925
+ }
926
+}
927
+
928
function notYetImplemented(): void {
929
throw new Error('Not yet implemented.');
930
}