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

Add onErrorShell Callback (#23247)

This indicates that an error has happened before the shell completed and there's no point in emitting the result of this stream. This is not quite the same as other fatal errors that can happen even after streaming as started. It's also not quite the same as onError before onCompleteShell because onError can be called for an error inside a Suspense boundary before the shell completes. Implement shell error handling in Node SSR fixtures Instead of hanging indefinitely. Update Browser Fixture Expose onErrorShell to the Node build This API is not Promisified so it's just a separate callback instead. Promisify the Browser Fizz API It's now a Promise of a readable stream. The Promise resolves when the shell completes. If the shell errors, the Promise is rejected.

Sebastian Markbåge committed Feb 8, 2022 at 22:38 UTC 5690932765b65998ea9f054f0740002257b7675c
8 files changed +137 -96
fixtures/fizz-ssr-browser/index.html
+22 -14
@@ -20,22 +20,29 @@
20 <script src="../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js"></script>
21 <script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
22 <script type="text/babel">
23 - let controller = new AbortController();
24 - let stream = ReactDOMServer.renderToReadableStream(
25 - <html>
26 - <body>Success</body>
27 - </html>,
28 - {
29 - signal: controller.signal,
23 + async function render() {
24 + let controller = new AbortController();
25 + let response;
26 + try {
27 + let stream = await ReactDOMServer.renderToReadableStream(
28 + <html>
29 + <body>Success</body>
30 + </html>,
31 + {
32 + signal: controller.signal,
33 + }
34 + );
35 + response = new Response(stream, {
36 + headers: {'Content-Type': 'text/html'},
37 + });
38 + } catch (x) {
39 + response = new Response('<!doctype><p>Error</p>', {
40 + status: 500,
41 + headers: {'Content-Type': 'text/html'},
42 + });
43 }
31 - );
32 - let response = new Response(stream, {
33 - headers: {'Content-Type': 'text/html'},
34 - });
35 - display(response);
44
37 - async function display(responseToDisplay) {
38 - let blob = await responseToDisplay.blob();
45 + let blob = await response.blob();
46 let url = URL.createObjectURL(blob);
47 let iframe = document.createElement('iframe');
48 iframe.src = url;
@@ -43,6 +50,7 @@
50 container.innerHTML = '';
51 container.appendChild(iframe);
52 }
53 + render();
54 </script>
55 </body>
56 </html>
fixtures/ssr/server/render.js
+5
@@ -28,6 +28,11 @@ export default function render(url, res) {
28 res.setHeader('Content-type', 'text/html');
29 pipe(res);
30 },
31 + onErrorShell(x) {
32 + // Something errored before we could complete the shell so we emit an alternative shell.
33 + res.statusCode = 500;
34 + res.send('<!doctype><p>Error</p>');
35 + },
36 onError(x) {
37 didError = true;
38 console.error(x);
fixtures/ssr2/server/render.js
+5
@@ -49,6 +49,11 @@ module.exports = function render(url, res) {
49 res.setHeader('Content-type', 'text/html');
50 pipe(res);
51 },
52 + onErrorShell(x) {
53 + // Something errored before we could complete the shell so we emit an alternative shell.
54 + res.statusCode = 500;
55 + res.send('<!doctype><p>Error</p>');
56 + },
57 onError(x) {
58 didError = true;
59 console.error(x);
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+34 -42
@@ -51,7 +51,7 @@ describe('ReactDOMFizzServer', () => {
51
52 // @gate experimental
53 it('should call renderToReadableStream', async () => {
54 - const stream = ReactDOMFizzServer.renderToReadableStream(
54 + const stream = await ReactDOMFizzServer.renderToReadableStream(
55 <div>hello world</div>,
56 );
57 const result = await readResult(stream);
@@ -60,7 +60,7 @@ describe('ReactDOMFizzServer', () => {
60
61 // @gate experimental
62 it('should emit DOCTYPE at the root of the document', async () => {
63 - const stream = ReactDOMFizzServer.renderToReadableStream(
63 + const stream = await ReactDOMFizzServer.renderToReadableStream(
64 <html>
65 <body>hello world</body>
66 </html>,
@@ -73,7 +73,7 @@ describe('ReactDOMFizzServer', () => {
73
74 // @gate experimental
75 it('should emit bootstrap script src at the end', async () => {
76 - const stream = ReactDOMFizzServer.renderToReadableStream(
76 + const stream = await ReactDOMFizzServer.renderToReadableStream(
77 <div>hello world</div>,
78 {
79 bootstrapScriptContent: 'INIT();',
@@ -99,7 +99,7 @@ describe('ReactDOMFizzServer', () => {
99 return 'Done';
100 }
101 let isComplete = false;
102 - const stream = ReactDOMFizzServer.renderToReadableStream(
102 + const stream = await ReactDOMFizzServer.renderToReadableStream(
103 <div>
104 <Suspense fallback="Loading">
105 <Wait />
@@ -128,63 +128,55 @@ describe('ReactDOMFizzServer', () => {
128 });
129
130 // @gate experimental
131 - it('should error the stream when an error is thrown at the root', async () => {
131 + it('should reject the promise when an error is thrown at the root', async () => {
132 const reportedErrors = [];
133 - const stream = ReactDOMFizzServer.renderToReadableStream(
134 - <div>
135 - <Throw />
136 - </div>,
137 - {
138 - onError(x) {
139 - reportedErrors.push(x);
140 - },
141 - },
142 - );
143 -
133 let caughtError = null;
145 - let result = '';
134 try {
147 - result = await readResult(stream);
148 - } catch (x) {
149 - caughtError = x;
135 + await ReactDOMFizzServer.renderToReadableStream(
136 + <div>
137 + <Throw />
138 + </div>,
139 + {
140 + onError(x) {
141 + reportedErrors.push(x);
142 + },
143 + },
144 + );
145 + } catch (error) {
146 + caughtError = error;
147 }
148 expect(caughtError).toBe(theError);
152 - expect(result).toBe('');
149 expect(reportedErrors).toEqual([theError]);
150 });
151
152 // @gate experimental
157 - it('should error the stream when an error is thrown inside a fallback', async () => {
153 + it('should reject the promise when an error is thrown inside a fallback', async () => {
154 const reportedErrors = [];
159 - const stream = ReactDOMFizzServer.renderToReadableStream(
160 - <div>
161 - <Suspense fallback={<Throw />}>
162 - <InfiniteSuspend />
163 - </Suspense>
164 - </div>,
165 - {
166 - onError(x) {
167 - reportedErrors.push(x);
168 - },
169 - },
170 - );
171 -
155 let caughtError = null;
173 - let result = '';
156 try {
175 - result = await readResult(stream);
176 - } catch (x) {
177 - caughtError = x;
157 + await ReactDOMFizzServer.renderToReadableStream(
158 + <div>
159 + <Suspense fallback={<Throw />}>
160 + <InfiniteSuspend />
161 + </Suspense>
162 + </div>,
163 + {
164 + onError(x) {
165 + reportedErrors.push(x);
166 + },
167 + },
168 + );
169 + } catch (error) {
170 + caughtError = error;
171 }
172 expect(caughtError).toBe(theError);
180 - expect(result).toBe('');
173 expect(reportedErrors).toEqual([theError]);
174 });
175
176 // @gate experimental
177 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
178 const reportedErrors = [];
187 - const stream = ReactDOMFizzServer.renderToReadableStream(
179 + const stream = await ReactDOMFizzServer.renderToReadableStream(
180 <div>
181 <Suspense fallback={<div>Loading</div>}>
182 <Throw />
@@ -205,7 +197,7 @@ describe('ReactDOMFizzServer', () => {
197 // @gate experimental
198 it('should be able to complete by aborting even if the promise never resolves', async () => {
199 const controller = new AbortController();
208 - const stream = ReactDOMFizzServer.renderToReadableStream(
200 + const stream = await ReactDOMFizzServer.renderToReadableStream(
201 <div>
202 <Suspense fallback={<div>Loading</div>}>
203 <InfiniteSuspend />
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+15
@@ -168,6 +168,7 @@ describe('ReactDOMFizzServer', () => {
168 // @gate experimental
169 it('should error the stream when an error is thrown at the root', async () => {
170 const reportedErrors = [];
171 + const reportedShellErrors = [];
172 const {writable, output, completed} = getTestWritable();
173 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
174 <div>
@@ -178,6 +179,9 @@ describe('ReactDOMFizzServer', () => {
179 onError(x) {
180 reportedErrors.push(x);
181 },
182 + onErrorShell(x) {
183 + reportedShellErrors.push(x);
184 + },
185 },
186 );
187
@@ -190,11 +194,13 @@ describe('ReactDOMFizzServer', () => {
194 expect(output.result).toBe('');
195 // This type of error is reported to the error callback too.
196 expect(reportedErrors).toEqual([theError]);
197 + expect(reportedShellErrors).toEqual([theError]);
198 });
199
200 // @gate experimental
201 it('should error the stream when an error is thrown inside a fallback', async () => {
202 const reportedErrors = [];
203 + const reportedShellErrors = [];
204 const {writable, output, completed} = getTestWritable();
205 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
206 <div>
@@ -207,6 +213,9 @@ describe('ReactDOMFizzServer', () => {
213 onError(x) {
214 reportedErrors.push(x);
215 },
216 + onErrorShell(x) {
217 + reportedShellErrors.push(x);
218 + },
219 },
220 );
221 pipe(writable);
@@ -216,11 +225,13 @@ describe('ReactDOMFizzServer', () => {
225 expect(output.error).toBe(theError);
226 expect(output.result).toBe('');
227 expect(reportedErrors).toEqual([theError]);
228 + expect(reportedShellErrors).toEqual([theError]);
229 });
230
231 // @gate experimental
232 it('should not error the stream when an error is thrown inside suspense boundary', async () => {
233 const reportedErrors = [];
234 + const reportedShellErrors = [];
235 const {writable, output, completed} = getTestWritable();
236 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
237 <div>
@@ -233,6 +244,9 @@ describe('ReactDOMFizzServer', () => {
244 onError(x) {
245 reportedErrors.push(x);
246 },
247 + onErrorShell(x) {
248 + reportedShellErrors.push(x);
249 + },
250 },
251 );
252 pipe(writable);
@@ -243,6 +257,7 @@ describe('ReactDOMFizzServer', () => {
257 expect(output.result).toContain('Loading');
258 // While no error is reported to the stream, the error is reported to the callback.
259 expect(reportedErrors).toEqual([theError]);
260 + expect(reportedShellErrors).toEqual([]);
261 });
262
263 // @gate experimental
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+45 -40
@@ -32,7 +32,6 @@ type Options = {|
32 bootstrapModules?: Array<string>,
33 progressiveChunkSize?: number,
34 signal?: AbortSignal,
35 - onCompleteShell?: () => void,
35 onCompleteAll?: () => void,
36 onError?: (error: mixed) => void,
37 |};
@@ -40,46 +39,52 @@ type Options = {|
39 function renderToReadableStream(
40 children: ReactNodeList,
41 options?: Options,
43 -): ReadableStream {
44 - const request = createRequest(
45 - children,
46 - createResponseState(
47 - options ? options.identifierPrefix : undefined,
48 - options ? options.nonce : undefined,
49 - options ? options.bootstrapScriptContent : undefined,
50 - options ? options.bootstrapScripts : undefined,
51 - options ? options.bootstrapModules : undefined,
52 - ),
53 - createRootFormatContext(options ? options.namespaceURI : undefined),
54 - options ? options.progressiveChunkSize : undefined,
55 - options ? options.onError : undefined,
56 - options ? options.onCompleteAll : undefined,
57 - options ? options.onCompleteShell : undefined,
58 - );
59 - if (options && options.signal) {
60 - const signal = options.signal;
61 - const listener = () => {
62 - abort(request);
63 - signal.removeEventListener('abort', listener);
64 - };
65 - signal.addEventListener('abort', listener);
66 - }
67 - const stream = new ReadableStream({
68 - start(controller) {
69 - startWork(request);
70 - },
71 - pull(controller) {
72 - // Pull is called immediately even if the stream is not passed to anything.
73 - // That's buffering too early. We want to start buffering once the stream
74 - // is actually used by something so we can give it the best result possible
75 - // at that point.
76 - if (stream.locked) {
77 - startFlowing(request, controller);
78 - }
79 - },
80 - cancel(reason) {},
42 +): Promise<ReadableStream> {
43 + return new Promise((resolve, reject) => {
44 + function onCompleteShell() {
45 + const stream = new ReadableStream({
46 + pull(controller) {
47 + // Pull is called immediately even if the stream is not passed to anything.
48 + // That's buffering too early. We want to start buffering once the stream
49 + // is actually used by something so we can give it the best result possible
50 + // at that point.
51 + if (stream.locked) {
52 + startFlowing(request, controller);
53 + }
54 + },
55 + cancel(reason) {},
56 + });
57 + resolve(stream);
58 + }
59 + function onErrorShell(error: mixed) {
60 + reject(error);
61 + }
62 + const request = createRequest(
63 + children,
64 + createResponseState(
65 + options ? options.identifierPrefix : undefined,
66 + options ? options.nonce : undefined,
67 + options ? options.bootstrapScriptContent : undefined,
68 + options ? options.bootstrapScripts : undefined,
69 + options ? options.bootstrapModules : undefined,
70 + ),
71 + createRootFormatContext(options ? options.namespaceURI : undefined),
72 + options ? options.progressiveChunkSize : undefined,
73 + options ? options.onError : undefined,
74 + options ? options.onCompleteAll : undefined,
75 + onCompleteShell,
76 + onErrorShell,
77 + );
78 + if (options && options.signal) {
79 + const signal = options.signal;
80 + const listener = () => {
81 + abort(request);
82 + signal.removeEventListener('abort', listener);
83 + };
84 + signal.addEventListener('abort', listener);
85 + }
86 + startWork(request);
87 });
82 - return stream;
88 }
89
90 export {renderToReadableStream, ReactVersion as version};
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+2
@@ -37,6 +37,7 @@ type Options = {|
37 bootstrapModules?: Array<string>,
38 progressiveChunkSize?: number,
39 onCompleteShell?: () => void,
40 + onErrorShell?: () => void,
41 onCompleteAll?: () => void,
42 onError?: (error: mixed) => void,
43 |};
@@ -63,6 +64,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
64 options ? options.onError : undefined,
65 options ? options.onCompleteAll : undefined,
66 options ? options.onCompleteShell : undefined,
67 + options ? options.onErrorShell : undefined,
68 );
69 }
70
packages/react-server/src/ReactFizzServer.js
+9
@@ -199,6 +199,9 @@ export opaque type Request = {
199 // Typically you don't need this callback because it's best practice to always have a
200 // root fallback ready so there's no need to wait.
201 onCompleteShell: () => void,
202 + // onErrorShell is called when the shell didn't complete. That means you probably want to
203 + // emit a different response to the stream instead.
204 + onErrorShell: (error: mixed) => void,
205 };
206
207 // This is a default heuristic for how to split up the HTML content into progressive
@@ -232,6 +235,7 @@ export function createRequest(
235 onError: void | ((error: mixed) => void),
236 onCompleteAll: void | (() => void),
237 onCompleteShell: void | (() => void),
238 + onErrorShell: void | ((error: mixed) => void),
239 ): Request {
240 const pingedTasks = [];
241 const abortSet: Set<Task> = new Set();
@@ -256,6 +260,7 @@ export function createRequest(
260 onError: onError === undefined ? defaultErrorHandler : onError,
261 onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll,
262 onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell,
263 + onErrorShell: onErrorShell === undefined ? noop : onErrorShell,
264 };
265 // This segment represents the root fallback.
266 const rootSegment = createPendingSegment(request, 0, null, rootFormatContext);
@@ -412,6 +417,8 @@ function fatalError(request: Request, error: mixed): void {
417 // This is called outside error handling code such as if the root errors outside
418 // a suspense boundary or if the root suspense boundary's fallback errors.
419 // It's also called if React itself or its host configs errors.
420 + const onErrorShell = request.onErrorShell;
421 + onErrorShell(error);
422 if (request.destination !== null) {
423 request.status = CLOSED;
424 closeWithError(request.destination, error);
@@ -1433,6 +1440,8 @@ function finishedTask(
1440 }
1441 request.pendingRootTasks--;
1442 if (request.pendingRootTasks === 0) {
1443 + // We have completed the shell so the shell can't error anymore.
1444 + request.onErrorShell = noop;
1445 const onCompleteShell = request.onCompleteShell;
1446 onCompleteShell();
1447 }