@samitouri / QOS-React-2 / commits / 435cff9866

[Fizz] Expose callbacks in options for when various stages of the content is done (#21056)

* Report errors to a global handler This allows you to log errors or set things like status codes. * Add complete callback * onReadyToStream callback This is typically not needed because if you want to stream when the root is ready you can just start writing immediately. * Rename onComplete -> onCompleteAll

Sebastian Markbåge committed Mar 23, 2021 at 14:39 UTC 435cff9866d38d137aafe6396c776437710a8481
7 files changed +256 -61
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+16 -8
@@ -336,7 +336,6 @@ describe('ReactDOMFizzServer', () => {
336 writable.write(chunk, encoding, next);
337 };
338
339 - writable.write('<div id="container-A">');
339 await act(async () => {
340 const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
341 <Suspense fallback={<Text text="Loading A..." />}>
@@ -346,13 +345,17 @@ describe('ReactDOMFizzServer', () => {
345 </div>
346 </Suspense>,
347 writableA,
349 - {identifierPrefix: 'A_'},
348 + {
349 + identifierPrefix: 'A_',
350 + onReadyToStream() {
351 + writableA.write('<div id="container-A">');
352 + startWriting();
353 + writableA.write('</div>');
354 + },
355 + },
356 );
351 - startWriting();
357 });
353 - writable.write('</div>');
358
355 - writable.write('<div id="container-B">');
359 await act(async () => {
360 const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
361 <Suspense fallback={<Text text="Loading B..." />}>
@@ -362,11 +365,16 @@ describe('ReactDOMFizzServer', () => {
365 </div>
366 </Suspense>,
367 writableB,
365 - {identifierPrefix: 'B_'},
368 + {
369 + identifierPrefix: 'B_',
370 + onReadyToStream() {
371 + writableB.write('<div id="container-B">');
372 + startWriting();
373 + writableB.write('</div>');
374 + },
375 + },
376 );
367 - startWriting();
377 });
369 - writable.write('</div>');
378
379 expect(getVisibleChildren(container)).toEqual([
380 <div id="container-A">Loading A...</div>,
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+59
@@ -58,12 +58,56 @@ describe('ReactDOMFizzServer', () => {
58 expect(result).toBe('<div>hello world</div>');
59 });
60
61 + // @gate experimental
62 + it('emits all HTML as one unit if we wait until the end to start', async () => {
63 + let hasLoaded = false;
64 + let resolve;
65 + const promise = new Promise(r => (resolve = r));
66 + function Wait() {
67 + if (!hasLoaded) {
68 + throw promise;
69 + }
70 + return 'Done';
71 + }
72 + let isComplete = false;
73 + const stream = ReactDOMFizzServer.renderToReadableStream(
74 + <div>
75 + <Suspense fallback="Loading">
76 + <Wait />
77 + </Suspense>
78 + </div>,
79 + {
80 + onCompleteAll() {
81 + isComplete = true;
82 + },
83 + },
84 + );
85 + await jest.runAllTimers();
86 + expect(isComplete).toBe(false);
87 + // Resolve the loading.
88 + hasLoaded = true;
89 + await resolve();
90 +
91 + await jest.runAllTimers();
92 +
93 + expect(isComplete).toBe(true);
94 +
95 + const result = await readResult(stream);
96 + expect(result).toBe('<div><!--$-->Done<!--/$--></div>');
97 + });
98 +
99 // @gate experimental
100 it('should error the stream when an error is thrown at the root', async () => {
101 + const reportedErrors = [];
102 const stream = ReactDOMFizzServer.renderToReadableStream(
103 <div>
104 <Throw />
105 </div>,
106 + {
107 + onError(x) {
108 + reportedErrors.push(x);
109 + },
110 + },
111 );
112
113 let caughtError = null;
@@ -75,16 +119,23 @@ describe('ReactDOMFizzServer', () => {
119 }
120 expect(caughtError).toBe(theError);
121 expect(result).toBe('');
122 + expect(reportedErrors).toEqual([theError]);
123 });
124
125 // @gate experimental
126 it('should error the stream when an error is thrown inside a fallback', async () => {
127 + const reportedErrors = [];
128 const stream = ReactDOMFizzServer.renderToReadableStream(
129 <div>
130 <Suspense fallback={<Throw />}>
131 <InfiniteSuspend />
132 </Suspense>
133 </div>,
134 + {
135 + onError(x) {
136 + reportedErrors.push(x);
137 + },
138 + },
139 );
140
141 let caughtError = null;
@@ -96,20 +147,28 @@ describe('ReactDOMFizzServer', () => {
147 }
148 expect(caughtError).toBe(theError);
149 expect(result).toBe('');
150 + expect(reportedErrors).toEqual([theError]);
151 });
152
153 // @gate experimental
154 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
155 + const reportedErrors = [];
156 const stream = ReactDOMFizzServer.renderToReadableStream(
157 <div>
158 <Suspense fallback={<div>Loading</div>}>
159 <Throw />
160 </Suspense>
161 </div>,
162 + {
163 + onError(x) {
164 + reportedErrors.push(x);
165 + },
166 + },
167 );
168
169 const result = await readResult(stream);
170 expect(result).toContain('Loading');
171 + expect(reportedErrors).toEqual([theError]);
172 });
173
174 // @gate experimental
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+71
@@ -86,14 +86,68 @@ describe('ReactDOMFizzServer', () => {
86 );
87 });
88
89 + // @gate experimental
90 + it('emits all HTML as one unit if we wait until the end to start', async () => {
91 + let hasLoaded = false;
92 + let resolve;
93 + const promise = new Promise(r => (resolve = r));
94 + function Wait() {
95 + if (!hasLoaded) {
96 + throw promise;
97 + }
98 + return 'Done';
99 + }
100 + let isComplete = false;
101 + const {writable, output} = getTestWritable();
102 + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
103 + <div>
104 + <Suspense fallback="Loading">
105 + <Wait />
106 + </Suspense>
107 + </div>,
108 + writable,
109 + {
110 + onCompleteAll() {
111 + isComplete = true;
112 + },
113 + },
114 + );
115 + await jest.runAllTimers();
116 + expect(output.result).toBe('');
117 + expect(isComplete).toBe(false);
118 + // Resolve the loading.
119 + hasLoaded = true;
120 + await resolve();
121 +
122 + await jest.runAllTimers();
123 +
124 + expect(output.result).toBe('');
125 + expect(isComplete).toBe(true);
126 +
127 + // First we write our header.
128 + output.result +=
129 + '<!doctype html><html><head><title>test</title><head><body>';
130 + // Then React starts writing.
131 + startWriting();
132 + expect(output.result).toBe(
133 + '<!doctype html><html><head><title>test</title><head><body><div><!--$-->Done<!--/$--></div>',
134 + );
135 + });
136 +
137 // @gate experimental
138 it('should error the stream when an error is thrown at the root', async () => {
139 + const reportedErrors = [];
140 const {writable, output, completed} = getTestWritable();
141 ReactDOMFizzServer.pipeToNodeWritable(
142 <div>
143 <Throw />
144 </div>,
145 writable,
146 + {
147 + onError(x) {
148 + reportedErrors.push(x);
149 + },
150 + },
151 );
152
153 // The stream is errored even if we haven't started writing.
@@ -102,10 +156,13 @@ describe('ReactDOMFizzServer', () => {
156
157 expect(output.error).toBe(theError);
158 expect(output.result).toBe('');
159 + // This type of error is reported to the error callback too.
160 + expect(reportedErrors).toEqual([theError]);
161 });
162
163 // @gate experimental
164 it('should error the stream when an error is thrown inside a fallback', async () => {
165 + const reportedErrors = [];
166 const {writable, output, completed} = getTestWritable();
167 const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
168 <div>
@@ -114,6 +171,11 @@ describe('ReactDOMFizzServer', () => {
171 </Suspense>
172 </div>,
173 writable,
174 + {
175 + onError(x) {
176 + reportedErrors.push(x);
177 + },
178 + },
179 );
180 startWriting();
181
@@ -121,10 +183,12 @@ describe('ReactDOMFizzServer', () => {
183
184 expect(output.error).toBe(theError);
185 expect(output.result).toBe('');
186 + expect(reportedErrors).toEqual([theError]);
187 });
188
189 // @gate experimental
190 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
191 + const reportedErrors = [];
192 const {writable, output, completed} = getTestWritable();
193 const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
194 <div>
@@ -133,6 +197,11 @@ describe('ReactDOMFizzServer', () => {
197 </Suspense>
198 </div>,
199 writable,
200 + {
201 + onError(x) {
202 + reportedErrors.push(x);
203 + },
204 + },
205 );
206 startWriting();
207
@@ -140,6 +209,8 @@ describe('ReactDOMFizzServer', () => {
209
210 expect(output.error).toBe(undefined);
211 expect(output.result).toContain('Loading');
212 + // While no error is reported to the stream, the error is reported to the callback.
213 + expect(reportedErrors).toEqual([theError]);
214 });
215
216 // @gate experimental
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+15 -2
@@ -22,6 +22,9 @@ type Options = {
22 identifierPrefix?: string,
23 progressiveChunkSize?: number,
24 signal?: AbortSignal,
25 + onReadyToStream?: () => void,
26 + onCompleteAll?: () => void,
27 + onError?: (error: mixed) => void,
28 };
29
30 function renderToReadableStream(
@@ -37,21 +40,31 @@ function renderToReadableStream(
40 };
41 signal.addEventListener('abort', listener);
42 }
40 - return new ReadableStream({
43 + const stream = new ReadableStream({
44 start(controller) {
45 request = createRequest(
46 children,
47 controller,
48 createResponseState(options ? options.identifierPrefix : undefined),
49 options ? options.progressiveChunkSize : undefined,
50 + options ? options.onError : undefined,
51 + options ? options.onCompleteAll : undefined,
52 + options ? options.onReadyToStream : undefined,
53 );
54 startWork(request);
55 },
56 pull(controller) {
51 - startFlowing(request);
57 + // Pull is called immediately even if the stream is not passed to anything.
58 + // That's buffering too early. We want to start buffering once the stream
59 + // is actually used by something so we can give it the best result possible
60 + // at that point.
61 + if (stream.locked) {
62 + startFlowing(request);
63 + }
64 },
65 cancel(reason) {},
66 });
67 + return stream;
68 }
69
70 export {renderToReadableStream};
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+6
@@ -26,6 +26,9 @@ function createDrainHandler(destination, request) {
26 type Options = {
27 identifierPrefix?: string,
28 progressiveChunkSize?: number,
29 + onReadyToStream?: () => void,
30 + onCompleteAll?: () => void,
31 + onError?: (error: mixed) => void,
32 };
33
34 type Controls = {
@@ -44,6 +47,9 @@ function pipeToNodeWritable(
47 destination,
48 createResponseState(options ? options.identifierPrefix : undefined),
49 options ? options.progressiveChunkSize : undefined,
50 + options ? options.onError : undefined,
51 + options ? options.onCompleteAll : undefined,
52 + options ? options.onReadyToStream : undefined,
53 );
54 let hasStartedFlowing = false;
55 startWork(request);
packages/react-noop-renderer/src/ReactNoopServer.js
+6
@@ -217,6 +217,9 @@ const ReactNoopServer = ReactFizzServer({
217
218 type Options = {
219 progressiveChunkSize?: number,
220 + onReadyToStream?: () => void,
221 + onCompleteAll?: () => void,
222 + onError?: (error: mixed) => void,
223 };
224
225 function render(children: React$Element<any>, options?: Options): Destination {
@@ -234,6 +237,9 @@ function render(children: React$Element<any>, options?: Options): Destination {
237 destination,
238 null,
239 options ? options.progressiveChunkSize : undefined,
240 + options ? options.onError : undefined,
241 + options ? options.onCompleteAll : undefined,
242 + options ? options.onReadyToStream : undefined,
243 );
244 ReactNoopServer.startWork(request);
245 ReactNoopServer.startFlowing(request);
packages/react-server/src/ReactFizzServer.js
+83 -51
@@ -110,6 +110,15 @@ type Request = {
110 clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
111 completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
112 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
113 + // onError is called when an error happens anywhere in the tree. It might recover.
114 + onError: (error: mixed) => void,
115 + // onCompleteAll is called when all pending work is done but it may not have flushed yet.
116 + // This is a good time to start writing if you want only HTML and no intermediate steps.
117 + onCompleteAll: () => void,
118 + // onReadyToStream is called when there is at least a root fallback ready to show.
119 + // Typically you don't need this callback because it's best practice to always have a
120 + // root fallback ready so there's no need to wait.
121 + onReadyToStream: () => void,
122 };
123
124 // This is a default heuristic for how to split up the HTML content into progressive
@@ -134,6 +143,9 @@ export function createRequest(
143 destination: Destination,
144 responseState: ResponseState,
145 progressiveChunkSize: number = DEFAULT_PROGRESSIVE_CHUNK_SIZE,
146 + onError: (error: mixed) => void = noop,
147 + onCompleteAll: () => void = noop,
148 + onReadyToStream: () => void = noop,
149 ): Request {
150 const pingedWork = [];
151 const abortSet: Set<SuspendedWork> = new Set();
@@ -151,6 +163,9 @@ export function createRequest(
163 clientRenderedBoundaries: [],
164 completedBoundaries: [],
165 partialBoundaries: [],
166 + onError,
167 + onCompleteAll,
168 + onReadyToStream,
169 };
170 // This segment represents the root fallback.
171 const rootSegment = createPendingSegment(request, 0, null);
@@ -235,7 +250,9 @@ function createPendingSegment(
250 }
251
252 function reportError(request: Request, error: mixed): void {
238 - // TODO: Report errors on the server.
253 + // If this callback errors, we intentionally let that error bubble up to become a fatal error
254 + // so that someone fixes the error reporting instead of hiding it.
255 + request.onError(error);
256 }
257
258 function fatalError(request: Request, error: mixed): void {
@@ -389,28 +406,31 @@ function erroredWork(
406 segment: Segment,
407 error: mixed,
408 ) {
392 - request.allPendingWork--;
393 - if (boundary !== null) {
394 - boundary.pendingWork--;
395 - }
396 -
409 // Report the error to a global handler.
410 reportError(request, error);
411 if (boundary === null) {
412 fatalError(request, error);
401 - } else if (!boundary.forceClientRender) {
402 - boundary.forceClientRender = true;
403 -
404 - // Regardless of what happens next, this boundary won't be displayed,
405 - // so we can flush it, if the parent already flushed.
406 - if (boundary.parentFlushed) {
407 - // We don't have a preference where in the queue this goes since it's likely
408 - // to error on the client anyway. However, intentionally client-rendered
409 - // boundaries should be flushed earlier so that they can start on the client.
410 - // We reuse the same queue for errors.
411 - request.clientRenderedBoundaries.push(boundary);
413 + } else {
414 + boundary.pendingWork--;
415 + if (!boundary.forceClientRender) {
416 + boundary.forceClientRender = true;
417 +
418 + // Regardless of what happens next, this boundary won't be displayed,
419 + // so we can flush it, if the parent already flushed.
420 + if (boundary.parentFlushed) {
421 + // We don't have a preference where in the queue this goes since it's likely
422 + // to error on the client anyway. However, intentionally client-rendered
423 + // boundaries should be flushed earlier so that they can start on the client.
424 + // We reuse the same queue for errors.
425 + request.clientRenderedBoundaries.push(boundary);
426 + }
427 }
428 }
429 +
430 + request.allPendingWork--;
431 + if (request.allPendingWork === 0) {
432 + request.onCompleteAll();
433 + }
434 }
435
436 function abortWorkSoft(suspendedWork: SuspendedWork): void {
@@ -454,6 +474,10 @@ function abortWork(suspendedWork: SuspendedWork): void {
474 request.clientRenderedBoundaries.push(boundary);
475 }
476 }
477 +
478 + if (request.allPendingWork === 0) {
479 + request.onCompleteAll();
480 + }
481 }
482 }
483
@@ -462,10 +486,7 @@ function finishedWork(
486 boundary: Root | SuspenseBoundary,
487 segment: Segment,
488 ) {
465 - request.allPendingWork--;
466 -
489 if (boundary === null) {
468 - request.pendingRootWork--;
490 if (segment.parentFlushed) {
491 invariant(
492 request.completedRootSegment === null,
@@ -473,43 +494,51 @@ function finishedWork(
494 );
495 request.completedRootSegment = segment;
496 }
476 - return;
477 - }
478 -
479 - boundary.pendingWork--;
480 - if (boundary.forceClientRender) {
481 - // This already errored.
482 - return;
483 - }
484 - if (boundary.pendingWork === 0) {
485 - // This must have been the last segment we were waiting on. This boundary is now complete.
486 - // We can now cancel any pending work on the fallback since we won't need to show it anymore.
487 - boundary.fallbackAbortableWork.forEach(abortWorkSoft, request);
488 - boundary.fallbackAbortableWork.clear();
489 - if (segment.parentFlushed) {
490 - // Our parent segment already flushed, so we need to schedule this segment to be emitted.
491 - boundary.completedSegments.push(segment);
492 - }
493 - if (boundary.parentFlushed) {
494 - // The segment might be part of a segment that didn't flush yet, but if the boundary's
495 - // parent flushed, we need to schedule the boundary to be emitted.
496 - request.completedBoundaries.push(boundary);
497 + request.pendingRootWork--;
498 + if (request.pendingRootWork === 0) {
499 + request.onReadyToStream();
500 }
501 } else {
499 - if (segment.parentFlushed) {
500 - // Our parent already flushed, so we need to schedule this segment to be emitted.
501 - const completedSegments = boundary.completedSegments;
502 - completedSegments.push(segment);
503 - if (completedSegments.length === 1) {
504 - // This is the first time since we last flushed that we completed anything.
505 - // We can schedule this boundary to emit its partially completed segments early
506 - // in case the parent has already been flushed.
507 - if (boundary.parentFlushed) {
508 - request.partialBoundaries.push(boundary);
502 + boundary.pendingWork--;
503 + if (boundary.forceClientRender) {
504 + // This already errored.
505 + } else if (boundary.pendingWork === 0) {
506 + // This must have been the last segment we were waiting on. This boundary is now complete.
507 + // We can now cancel any pending work on the fallback since we won't need to show it anymore.
508 + boundary.fallbackAbortableWork.forEach(abortWorkSoft, request);
509 + boundary.fallbackAbortableWork.clear();
510 + if (segment.parentFlushed) {
511 + // Our parent segment already flushed, so we need to schedule this segment to be emitted.
512 + boundary.completedSegments.push(segment);
513 + }
514 + if (boundary.parentFlushed) {
515 + // The segment might be part of a segment that didn't flush yet, but if the boundary's
516 + // parent flushed, we need to schedule the boundary to be emitted.
517 + request.completedBoundaries.push(boundary);
518 + }
519 + } else {
520 + if (segment.parentFlushed) {
521 + // Our parent already flushed, so we need to schedule this segment to be emitted.
522 + const completedSegments = boundary.completedSegments;
523 + completedSegments.push(segment);
524 + if (completedSegments.length === 1) {
525 + // This is the first time since we last flushed that we completed anything.
526 + // We can schedule this boundary to emit its partially completed segments early
527 + // in case the parent has already been flushed.
528 + if (boundary.parentFlushed) {
529 + request.partialBoundaries.push(boundary);
530 + }
531 }
532 }
533 }
534 }
535 +
536 + request.allPendingWork--;
537 + if (request.allPendingWork === 0) {
538 + // This needs to be called at the very end so that we can synchronously write the result
539 + // in the callback if needed.
540 + request.onCompleteAll();
541 + }
542 }
543
544 function retryWork(request: Request, work: SuspendedWork): void {
@@ -573,6 +602,7 @@ function performWork(request: Request): void {
602 flushCompletedQueues(request);
603 }
604 } catch (error) {
605 + reportError(request, error);
606 fatalError(request, error);
607 } finally {
608 ReactCurrentDispatcher.current = prevDispatcher;
@@ -920,6 +950,7 @@ export function startFlowing(request: Request): void {
950 try {
951 flushCompletedQueues(request);
952 } catch (error) {
953 + reportError(request, error);
954 fatalError(request, error);
955 }
956 }
@@ -934,6 +965,7 @@ export function abort(request: Request): void {
965 flushCompletedQueues(request);
966 }
967 } catch (error) {
968 + reportError(request, error);
969 fatalError(request, error);
970 }
971 }