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