Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 435cff9

Browse files
authoredMar 23, 2021
[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
1 parent 25bfa28 commit 435cff9

File tree

7 files changed

+256
-61
lines changed

7 files changed

+256
-61
lines changed
 

‎packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,6 @@ describe('ReactDOMFizzServer', () => {
336336
writable.write(chunk, encoding, next);
337337
};
338338

339-
writable.write('<div id="container-A">');
340339
await act(async () => {
341340
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
342341
<Suspense fallback={<Text text="Loading A..." />}>
@@ -346,13 +345,17 @@ describe('ReactDOMFizzServer', () => {
346345
</div>
347346
</Suspense>,
348347
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+
},
350356
);
351-
startWriting();
352357
});
353-
writable.write('</div>');
354358

355-
writable.write('<div id="container-B">');
356359
await act(async () => {
357360
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
358361
<Suspense fallback={<Text text="Loading B..." />}>
@@ -362,11 +365,16 @@ describe('ReactDOMFizzServer', () => {
362365
</div>
363366
</Suspense>,
364367
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+
},
366376
);
367-
startWriting();
368377
});
369-
writable.write('</div>');
370378

371379
expect(getVisibleChildren(container)).toEqual([
372380
<div id="container-A">Loading A...</div>,

‎packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,56 @@ describe('ReactDOMFizzServer', () => {
5858
expect(result).toBe('<div>hello world</div>');
5959
});
6060

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+
6199
// @gate experimental
62100
it('should error the stream when an error is thrown at the root', async () => {
101+
const reportedErrors = [];
63102
const stream = ReactDOMFizzServer.renderToReadableStream(
64103
<div>
65104
<Throw />
66105
</div>,
106+
{
107+
onError(x) {
108+
reportedErrors.push(x);
109+
},
110+
},
67111
);
68112

69113
let caughtError = null;
@@ -75,16 +119,23 @@ describe('ReactDOMFizzServer', () => {
75119
}
76120
expect(caughtError).toBe(theError);
77121
expect(result).toBe('');
122+
expect(reportedErrors).toEqual([theError]);
78123
});
79124

80125
// @gate experimental
81126
it('should error the stream when an error is thrown inside a fallback', async () => {
127+
const reportedErrors = [];
82128
const stream = ReactDOMFizzServer.renderToReadableStream(
83129
<div>
84130
<Suspense fallback={<Throw />}>
85131
<InfiniteSuspend />
86132
</Suspense>
87133
</div>,
134+
{
135+
onError(x) {
136+
reportedErrors.push(x);
137+
},
138+
},
88139
);
89140

90141
let caughtError = null;
@@ -96,20 +147,28 @@ describe('ReactDOMFizzServer', () => {
96147
}
97148
expect(caughtError).toBe(theError);
98149
expect(result).toBe('');
150+
expect(reportedErrors).toEqual([theError]);
99151
});
100152

101153
// @gate experimental
102154
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
155+
const reportedErrors = [];
103156
const stream = ReactDOMFizzServer.renderToReadableStream(
104157
<div>
105158
<Suspense fallback={<div>Loading</div>}>
106159
<Throw />
107160
</Suspense>
108161
</div>,
162+
{
163+
onError(x) {
164+
reportedErrors.push(x);
165+
},
166+
},
109167
);
110168

111169
const result = await readResult(stream);
112170
expect(result).toContain('Loading');
171+
expect(reportedErrors).toEqual([theError]);
113172
});
114173

115174
// @gate experimental

‎packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,68 @@ describe('ReactDOMFizzServer', () => {
8686
);
8787
});
8888

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+
89137
// @gate experimental
90138
it('should error the stream when an error is thrown at the root', async () => {
139+
const reportedErrors = [];
91140
const {writable, output, completed} = getTestWritable();
92141
ReactDOMFizzServer.pipeToNodeWritable(
93142
<div>
94143
<Throw />
95144
</div>,
96145
writable,
146+
{
147+
onError(x) {
148+
reportedErrors.push(x);
149+
},
150+
},
97151
);
98152

99153
// The stream is errored even if we haven't started writing.
@@ -102,10 +156,13 @@ describe('ReactDOMFizzServer', () => {
102156

103157
expect(output.error).toBe(theError);
104158
expect(output.result).toBe('');
159+
// This type of error is reported to the error callback too.
160+
expect(reportedErrors).toEqual([theError]);
105161
});
106162

107163
// @gate experimental
108164
it('should error the stream when an error is thrown inside a fallback', async () => {
165+
const reportedErrors = [];
109166
const {writable, output, completed} = getTestWritable();
110167
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
111168
<div>
@@ -114,17 +171,24 @@ describe('ReactDOMFizzServer', () => {
114171
</Suspense>
115172
</div>,
116173
writable,
174+
{
175+
onError(x) {
176+
reportedErrors.push(x);
177+
},
178+
},
117179
);
118180
startWriting();
119181

120182
await completed;
121183

122184
expect(output.error).toBe(theError);
123185
expect(output.result).toBe('');
186+
expect(reportedErrors).toEqual([theError]);
124187
});
125188

126189
// @gate experimental
127190
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
191+
const reportedErrors = [];
128192
const {writable, output, completed} = getTestWritable();
129193
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
130194
<div>
@@ -133,13 +197,20 @@ describe('ReactDOMFizzServer', () => {
133197
</Suspense>
134198
</div>,
135199
writable,
200+
{
201+
onError(x) {
202+
reportedErrors.push(x);
203+
},
204+
},
136205
);
137206
startWriting();
138207

139208
await completed;
140209

141210
expect(output.error).toBe(undefined);
142211
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]);
143214
});
144215

145216
// @gate experimental

‎packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type Options = {
2222
identifierPrefix?: string,
2323
progressiveChunkSize?: number,
2424
signal?: AbortSignal,
25+
onReadyToStream?: () => void,
26+
onCompleteAll?: () => void,
27+
onError?: (error: mixed) => void,
2528
};
2629

2730
function renderToReadableStream(
@@ -37,21 +40,31 @@ function renderToReadableStream(
3740
};
3841
signal.addEventListener('abort', listener);
3942
}
40-
return new ReadableStream({
43+
const stream = new ReadableStream({
4144
start(controller) {
4245
request = createRequest(
4346
children,
4447
controller,
4548
createResponseState(options ? options.identifierPrefix : undefined),
4649
options ? options.progressiveChunkSize : undefined,
50+
options ? options.onError : undefined,
51+
options ? options.onCompleteAll : undefined,
52+
options ? options.onReadyToStream : undefined,
4753
);
4854
startWork(request);
4955
},
5056
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+
}
5264
},
5365
cancel(reason) {},
5466
});
67+
return stream;
5568
}
5669

5770
export {renderToReadableStream};

‎packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ function createDrainHandler(destination, request) {
2626
type Options = {
2727
identifierPrefix?: string,
2828
progressiveChunkSize?: number,
29+
onReadyToStream?: () => void,
30+
onCompleteAll?: () => void,
31+
onError?: (error: mixed) => void,
2932
};
3033

3134
type Controls = {
@@ -44,6 +47,9 @@ function pipeToNodeWritable(
4447
destination,
4548
createResponseState(options ? options.identifierPrefix : undefined),
4649
options ? options.progressiveChunkSize : undefined,
50+
options ? options.onError : undefined,
51+
options ? options.onCompleteAll : undefined,
52+
options ? options.onReadyToStream : undefined,
4753
);
4854
let hasStartedFlowing = false;
4955
startWork(request);

‎packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ const ReactNoopServer = ReactFizzServer({
217217

218218
type Options = {
219219
progressiveChunkSize?: number,
220+
onReadyToStream?: () => void,
221+
onCompleteAll?: () => void,
222+
onError?: (error: mixed) => void,
220223
};
221224

222225
function render(children: React$Element<any>, options?: Options): Destination {
@@ -234,6 +237,9 @@ function render(children: React$Element<any>, options?: Options): Destination {
234237
destination,
235238
null,
236239
options ? options.progressiveChunkSize : undefined,
240+
options ? options.onError : undefined,
241+
options ? options.onCompleteAll : undefined,
242+
options ? options.onReadyToStream : undefined,
237243
);
238244
ReactNoopServer.startWork(request);
239245
ReactNoopServer.startFlowing(request);

‎packages/react-server/src/ReactFizzServer.js

Lines changed: 83 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ type Request = {
110110
clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
111111
completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
112112
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,
113122
};
114123

115124
// This is a default heuristic for how to split up the HTML content into progressive
@@ -134,6 +143,9 @@ export function createRequest(
134143
destination: Destination,
135144
responseState: ResponseState,
136145
progressiveChunkSize: number = DEFAULT_PROGRESSIVE_CHUNK_SIZE,
146+
onError: (error: mixed) => void = noop,
147+
onCompleteAll: () => void = noop,
148+
onReadyToStream: () => void = noop,
137149
): Request {
138150
const pingedWork = [];
139151
const abortSet: Set<SuspendedWork> = new Set();
@@ -151,6 +163,9 @@ export function createRequest(
151163
clientRenderedBoundaries: [],
152164
completedBoundaries: [],
153165
partialBoundaries: [],
166+
onError,
167+
onCompleteAll,
168+
onReadyToStream,
154169
};
155170
// This segment represents the root fallback.
156171
const rootSegment = createPendingSegment(request, 0, null);
@@ -235,7 +250,9 @@ function createPendingSegment(
235250
}
236251

237252
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);
239256
}
240257

241258
function fatalError(request: Request, error: mixed): void {
@@ -389,28 +406,31 @@ function erroredWork(
389406
segment: Segment,
390407
error: mixed,
391408
) {
392-
request.allPendingWork--;
393-
if (boundary !== null) {
394-
boundary.pendingWork--;
395-
}
396-
397409
// Report the error to a global handler.
398410
reportError(request, error);
399411
if (boundary === null) {
400412
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+
}
412427
}
413428
}
429+
430+
request.allPendingWork--;
431+
if (request.allPendingWork === 0) {
432+
request.onCompleteAll();
433+
}
414434
}
415435

416436
function abortWorkSoft(suspendedWork: SuspendedWork): void {
@@ -454,6 +474,10 @@ function abortWork(suspendedWork: SuspendedWork): void {
454474
request.clientRenderedBoundaries.push(boundary);
455475
}
456476
}
477+
478+
if (request.allPendingWork === 0) {
479+
request.onCompleteAll();
480+
}
457481
}
458482
}
459483

@@ -462,54 +486,59 @@ function finishedWork(
462486
boundary: Root | SuspenseBoundary,
463487
segment: Segment,
464488
) {
465-
request.allPendingWork--;
466-
467489
if (boundary === null) {
468-
request.pendingRootWork--;
469490
if (segment.parentFlushed) {
470491
invariant(
471492
request.completedRootSegment === null,
472493
'There can only be one root segment. This is a bug in React.',
473494
);
474495
request.completedRootSegment = segment;
475496
}
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();
497500
}
498501
} 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+
}
509531
}
510532
}
511533
}
512534
}
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+
}
513542
}
514543

515544
function retryWork(request: Request, work: SuspendedWork): void {
@@ -573,6 +602,7 @@ function performWork(request: Request): void {
573602
flushCompletedQueues(request);
574603
}
575604
} catch (error) {
605+
reportError(request, error);
576606
fatalError(request, error);
577607
} finally {
578608
ReactCurrentDispatcher.current = prevDispatcher;
@@ -920,6 +950,7 @@ export function startFlowing(request: Request): void {
920950
try {
921951
flushCompletedQueues(request);
922952
} catch (error) {
953+
reportError(request, error);
923954
fatalError(request, error);
924955
}
925956
}
@@ -934,6 +965,7 @@ export function abort(request: Request): void {
934965
flushCompletedQueues(request);
935966
}
936967
} catch (error) {
968+
reportError(request, error);
937969
fatalError(request, error);
938970
}
939971
}

0 commit comments

Comments
 (0)
Please sign in to comment.