@samitouri / QOS-React-2 / commits / cf485e6f6b

[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 }