Rename Node SSR Callbacks to onShellReady/onAllReady and Other Fixes (#24030)
* I forgot to call onFatalError I can't figure out how to write a test for this because it only happens when there is a bug in React itself which would then be fixed if we found it. We're also covered by the protection of ReadableStream which doesn't leak other errors to us. * Abort requests if the reader cancels No need to continue computing at this point. * Abort requests if node streams get destroyed This is if the downstream cancels is for example. * Rename Node APIs for Parity with allReady The "Complete" terminology is a little misleading because not everything has been written yet. It's just "Ready" to be written now. onShellReady onShellError onAllReady * 'close' should be enough
Sebastian Markbåge committed
Mar 4, 2022 at 14:38 UTC
14c2be8dac2d5482fda8a0906a31d239df8551fc
12 files changed
+151
-58
fixtures/ssr/server/render.js
+2
-2
@@ -22,13 +22,13 @@ export default function render(url, res) {
22
let didError = false;
23
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
24
bootstrapScripts: [assets['main.js']],
25
- onCompleteShell() {
25
+ onShellReady() {
26
// If something errored before we started streaming, we set the error code appropriately.
27
res.statusCode = didError ? 500 : 200;
28
res.setHeader('Content-type', 'text/html');
29
pipe(res);
30
},
31
- onErrorShell(x) {
31
+ onShellError(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>');
fixtures/ssr2/server/render.js
+1
-1
@@ -49,7 +49,7 @@ module.exports = function render(url, res) {
49
res.setHeader('Content-type', 'text/html');
50
pipe(res);
51
},
52
- onErrorShell(x) {
52
+ onShellError(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>');
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+3
-3
@@ -914,7 +914,7 @@ describe('ReactDOMFizzServer', () => {
914
</Suspense>,
915
{
916
identifierPrefix: 'A_',
917
- onCompleteShell() {
917
+ onShellReady() {
918
writableA.write('<div id="container-A">');
919
pipe(writableA);
920
writableA.write('</div>');
@@ -933,7 +933,7 @@ describe('ReactDOMFizzServer', () => {
933
</Suspense>,
934
{
935
identifierPrefix: 'B_',
936
- onCompleteShell() {
936
+ onShellReady() {
937
writableB.write('<div id="container-B">');
938
pipe(writableB);
939
writableB.write('</div>');
@@ -1168,7 +1168,7 @@ describe('ReactDOMFizzServer', () => {
1168
1169
{
1170
namespaceURI: 'http://www.w3.org/2000/svg',
1171
- onCompleteShell() {
1171
+ onShellReady() {
1172
writable.write('<svg>');
1173
pipe(writable);
1174
writable.write('</svg>');
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+39
@@ -209,4 +209,43 @@ describe('ReactDOMFizzServer', () => {
209
const result = await readResult(stream);
210
expect(result).toContain('Loading');
211
});
212
+
213
+ // @gate experimental
214
+ it('should not continue rendering after the reader cancels', async () => {
215
+ let hasLoaded = false;
216
+ let resolve;
217
+ let isComplete = false;
218
+ let rendered = false;
219
+ const promise = new Promise(r => (resolve = r));
220
+ function Wait() {
221
+ if (!hasLoaded) {
222
+ throw promise;
223
+ }
224
+ rendered = true;
225
+ return 'Done';
226
+ }
227
+ const stream = await ReactDOMFizzServer.renderToReadableStream(
228
+ <div>
229
+ <Suspense fallback={<div>Loading</div>}>
230
+ <Wait /> />
231
+ </Suspense>
232
+ </div>,
233
+ );
234
+
235
+ stream.allReady.then(() => (isComplete = true));
236
+
237
+ expect(rendered).toBe(false);
238
+ expect(isComplete).toBe(false);
239
+
240
+ const reader = stream.getReader();
241
+ reader.cancel();
242
+
243
+ hasLoaded = true;
244
+ resolve();
245
+
246
+ await jest.runAllTimers();
247
+
248
+ expect(rendered).toBe(false);
249
+ expect(isComplete).toBe(true);
250
+ });
251
});
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+51
-6
@@ -138,7 +138,7 @@ describe('ReactDOMFizzServer', () => {
138
</div>,
139
140
{
141
- onCompleteAll() {
141
+ onAllReady() {
142
isCompleteCalls++;
143
},
144
},
@@ -179,7 +179,7 @@ describe('ReactDOMFizzServer', () => {
179
onError(x) {
180
reportedErrors.push(x);
181
},
182
- onErrorShell(x) {
182
+ onShellError(x) {
183
reportedShellErrors.push(x);
184
},
185
},
@@ -213,7 +213,7 @@ describe('ReactDOMFizzServer', () => {
213
onError(x) {
214
reportedErrors.push(x);
215
},
216
- onErrorShell(x) {
216
+ onShellError(x) {
217
reportedShellErrors.push(x);
218
},
219
},
@@ -244,7 +244,7 @@ describe('ReactDOMFizzServer', () => {
244
onError(x) {
245
reportedErrors.push(x);
246
},
247
- onErrorShell(x) {
247
+ onShellError(x) {
248
reportedShellErrors.push(x);
249
},
250
},
@@ -298,7 +298,7 @@ describe('ReactDOMFizzServer', () => {
298
</div>,
299
300
{
301
- onCompleteAll() {
301
+ onAllReady() {
302
isCompleteCalls++;
303
},
304
},
@@ -333,7 +333,7 @@ describe('ReactDOMFizzServer', () => {
333
</div>,
334
335
{
336
- onCompleteAll() {
336
+ onAllReady() {
337
isCompleteCalls++;
338
},
339
},
@@ -537,4 +537,49 @@ describe('ReactDOMFizzServer', () => {
537
expect(output.result).not.toContain('context never found');
538
expect(output.result).toContain('OK');
539
});
540
+
541
+ // @gate experimental
542
+ it('should not continue rendering after the writable ends unexpectedly', async () => {
543
+ let hasLoaded = false;
544
+ let resolve;
545
+ let isComplete = false;
546
+ let rendered = false;
547
+ const promise = new Promise(r => (resolve = r));
548
+ function Wait() {
549
+ if (!hasLoaded) {
550
+ throw promise;
551
+ }
552
+ rendered = true;
553
+ return 'Done';
554
+ }
555
+ const {writable, completed} = getTestWritable();
556
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
557
+ <div>
558
+ <Suspense fallback={<div>Loading</div>}>
559
+ <Wait />
560
+ </Suspense>
561
+ </div>,
562
+ {
563
+ onAllReady() {
564
+ isComplete = true;
565
+ },
566
+ },
567
+ );
568
+ pipe(writable);
569
+
570
+ expect(rendered).toBe(false);
571
+ expect(isComplete).toBe(false);
572
+
573
+ writable.end();
574
+
575
+ await jest.runAllTimers();
576
+
577
+ hasLoaded = true;
578
+ resolve();
579
+
580
+ await completed;
581
+
582
+ expect(rendered).toBe(false);
583
+ expect(isComplete).toBe(true);
584
+ });
585
});
packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js
+1
-1
@@ -155,7 +155,7 @@ module.exports = function(initModules) {
155
new Promise((resolve, reject) => {
156
const writable = new DrainWritable();
157
const s = ReactDOMServer.renderToPipeableStream(reactElement, {
158
- onErrorShell(e) {
158
+ onShellError(e) {
159
reject(e);
160
},
161
});
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+10
-8
@@ -46,25 +46,27 @@ function renderToReadableStream(
46
): Promise<ReactDOMServerReadableStream> {
47
return new Promise((resolve, reject) => {
48
let onFatalError;
49
- let onCompleteAll;
49
+ let onAllReady;
50
const allReady = new Promise((res, rej) => {
51
- onCompleteAll = res;
51
+ onAllReady = res;
52
onFatalError = rej;
53
});
54
55
- function onCompleteShell() {
55
+ function onShellReady() {
56
const stream: ReactDOMServerReadableStream = (new ReadableStream({
57
type: 'bytes',
58
pull(controller) {
59
startFlowing(request, controller);
60
},
61
- cancel(reason) {},
61
+ cancel(reason) {
62
+ abort(request);
63
+ },
64
}): any);
65
// TODO: Move to sub-classing ReadableStream.
66
stream.allReady = allReady;
67
resolve(stream);
68
}
67
- function onErrorShell(error: mixed) {
69
+ function onShellError(error: mixed) {
70
reject(error);
71
}
72
const request = createRequest(
@@ -79,9 +81,9 @@ function renderToReadableStream(
81
createRootFormatContext(options ? options.namespaceURI : undefined),
82
options ? options.progressiveChunkSize : undefined,
83
options ? options.onError : undefined,
82
- onCompleteAll,
83
- onCompleteShell,
84
- onErrorShell,
84
+ onAllReady,
85
+ onShellReady,
86
+ onShellError,
87
onFatalError,
88
);
89
if (options && options.signal) {
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+11
-6
@@ -28,6 +28,10 @@ function createDrainHandler(destination, request) {
28
return () => startFlowing(request, destination);
29
}
30
31
+function createAbortHandler(request) {
32
+ return () => abort(request);
33
+}
34
+
35
type Options = {|
36
identifierPrefix?: string,
37
namespaceURI?: string,
@@ -36,9 +40,9 @@ type Options = {|
40
bootstrapScripts?: Array<string>,
41
bootstrapModules?: Array<string>,
42
progressiveChunkSize?: number,
39
- onCompleteShell?: () => void,
40
- onErrorShell?: () => void,
41
- onCompleteAll?: () => void,
43
+ onShellReady?: () => void,
44
+ onShellError?: () => void,
45
+ onAllReady?: () => void,
46
onError?: (error: mixed) => void,
47
|};
48
@@ -62,9 +66,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
66
createRootFormatContext(options ? options.namespaceURI : undefined),
67
options ? options.progressiveChunkSize : undefined,
68
options ? options.onError : undefined,
65
- options ? options.onCompleteAll : undefined,
66
- options ? options.onCompleteShell : undefined,
67
- options ? options.onErrorShell : undefined,
69
+ options ? options.onAllReady : undefined,
70
+ options ? options.onShellReady : undefined,
71
+ options ? options.onShellError : undefined,
72
undefined,
73
);
74
}
@@ -86,6 +90,7 @@ function renderToPipeableStream(
90
hasStartedFlowing = true;
91
startFlowing(request, destination);
92
destination.on('drain', createDrainHandler(destination, request));
93
+ destination.on('close', createAbortHandler(request));
94
return destination;
95
},
96
abort() {
packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js
+2
-2
@@ -53,7 +53,7 @@ function renderToStringImpl(
53
};
54
55
let readyToStream = false;
56
- function onCompleteShell() {
56
+ function onShellReady() {
57
readyToStream = true;
58
}
59
const request = createRequest(
@@ -66,7 +66,7 @@ function renderToStringImpl(
66
Infinity,
67
onError,
68
undefined,
69
- onCompleteShell,
69
+ onShellReady,
70
undefined,
71
undefined,
72
);
packages/react-dom/src/server/ReactDOMLegacyServerNode.js
+2
-2
@@ -68,7 +68,7 @@ function renderToNodeStreamImpl(
68
options: void | ServerOptions,
69
generateStaticMarkup: boolean,
70
): Readable {
71
- function onCompleteAll() {
71
+ function onAllReady() {
72
// We wait until everything has loaded before starting to write.
73
// That way we only end up with fully resolved HTML even if we suspend.
74
destination.startedFlowing = true;
@@ -81,7 +81,7 @@ function renderToNodeStreamImpl(
81
createRootFormatContext(),
82
Infinity,
83
onError,
84
- onCompleteAll,
84
+ onAllReady,
85
undefined,
86
undefined,
87
);
packages/react-noop-renderer/src/ReactNoopServer.js
+4
-4
@@ -251,8 +251,8 @@ const ReactNoopServer = ReactFizzServer({
251
252
type Options = {
253
progressiveChunkSize?: number,
254
- onCompleteShell?: () => void,
255
- onCompleteAll?: () => void,
254
+ onShellReady?: () => void,
255
+ onAllReady?: () => void,
256
onError?: (error: mixed) => void,
257
};
258
@@ -272,8 +272,8 @@ function render(children: React$Element<any>, options?: Options): Destination {
272
null,
273
options ? options.progressiveChunkSize : undefined,
274
options ? options.onError : undefined,
275
- options ? options.onCompleteAll : undefined,
276
- options ? options.onCompleteShell : undefined,
275
+ options ? options.onAllReady : undefined,
276
+ options ? options.onShellReady : undefined,
277
);
278
ReactNoopServer.startWork(request);
279
ReactNoopServer.startFlowing(request, destination);
packages/react-server/src/ReactFizzServer.js
+25
-23
@@ -194,16 +194,16 @@ export opaque type Request = {
194
partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
195
// onError is called when an error happens anywhere in the tree. It might recover.
196
onError: (error: mixed) => void,
197
- // onCompleteAll is called when all pending task is done but it may not have flushed yet.
197
+ // onAllReady is called when all pending task is done but it may not have flushed yet.
198
// This is a good time to start writing if you want only HTML and no intermediate steps.
199
- onCompleteAll: () => void,
200
- // onCompleteShell is called when there is at least a root fallback ready to show.
199
+ onAllReady: () => void,
200
+ // onShellReady is called when there is at least a root fallback ready to show.
201
// Typically you don't need this callback because it's best practice to always have a
202
// root fallback ready so there's no need to wait.
203
- onCompleteShell: () => void,
204
- // onErrorShell is called when the shell didn't complete. That means you probably want to
203
+ onShellReady: () => void,
204
+ // onShellError is called when the shell didn't complete. That means you probably want to
205
// emit a different response to the stream instead.
206
- onErrorShell: (error: mixed) => void,
206
+ onShellError: (error: mixed) => void,
207
onFatalError: (error: mixed) => void,
208
};
209
@@ -236,9 +236,9 @@ export function createRequest(
236
rootFormatContext: FormatContext,
237
progressiveChunkSize: void | number,
238
onError: void | ((error: mixed) => void),
239
- onCompleteAll: void | (() => void),
240
- onCompleteShell: void | (() => void),
241
- onErrorShell: void | ((error: mixed) => void),
239
+ onAllReady: void | (() => void),
240
+ onShellReady: void | (() => void),
241
+ onShellError: void | ((error: mixed) => void),
242
onFatalError: void | ((error: mixed) => void),
243
): Request {
244
const pingedTasks = [];
@@ -262,9 +262,9 @@ export function createRequest(
262
completedBoundaries: [],
263
partialBoundaries: [],
264
onError: onError === undefined ? defaultErrorHandler : onError,
265
- onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll,
266
- onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell,
267
- onErrorShell: onErrorShell === undefined ? noop : onErrorShell,
265
+ onAllReady: onAllReady === undefined ? noop : onAllReady,
266
+ onShellReady: onShellReady === undefined ? noop : onShellReady,
267
+ onShellError: onShellError === undefined ? noop : onShellError,
268
onFatalError: onFatalError === undefined ? noop : onFatalError,
269
};
270
// This segment represents the root fallback.
@@ -422,8 +422,10 @@ function fatalError(request: Request, error: mixed): void {
422
// This is called outside error handling code such as if the root errors outside
423
// a suspense boundary or if the root suspense boundary's fallback errors.
424
// It's also called if React itself or its host configs errors.
425
- const onErrorShell = request.onErrorShell;
426
- onErrorShell(error);
425
+ const onShellError = request.onShellError;
426
+ onShellError(error);
427
+ const onFatalError = request.onFatalError;
428
+ onFatalError(error);
429
if (request.destination !== null) {
430
request.status = CLOSED;
431
closeWithError(request.destination, error);
@@ -1371,8 +1373,8 @@ function erroredTask(
1373
1374
request.allPendingTasks--;
1375
if (request.allPendingTasks === 0) {
1374
- const onCompleteAll = request.onCompleteAll;
1375
- onCompleteAll();
1376
+ const onAllReady = request.onAllReady;
1377
+ onAllReady();
1378
}
1379
}
1380
@@ -1422,8 +1424,8 @@ function abortTask(task: Task): void {
1424
1425
request.allPendingTasks--;
1426
if (request.allPendingTasks === 0) {
1425
- const onCompleteAll = request.onCompleteAll;
1426
- onCompleteAll();
1427
+ const onAllReady = request.onAllReady;
1428
+ onAllReady();
1429
}
1430
}
1431
}
@@ -1446,9 +1448,9 @@ function finishedTask(
1448
request.pendingRootTasks--;
1449
if (request.pendingRootTasks === 0) {
1450
// We have completed the shell so the shell can't error anymore.
1449
- request.onErrorShell = noop;
1450
- const onCompleteShell = request.onCompleteShell;
1451
- onCompleteShell();
1451
+ request.onShellError = noop;
1452
+ const onShellReady = request.onShellReady;
1453
+ onShellReady();
1454
}
1455
} else {
1456
boundary.pendingTasks--;
@@ -1499,8 +1501,8 @@ function finishedTask(
1501
if (request.allPendingTasks === 0) {
1502
// This needs to be called at the very end so that we can synchronously write the result
1503
// in the callback if needed.
1502
- const onCompleteAll = request.onCompleteAll;
1503
- onCompleteAll();
1504
+ const onAllReady = request.onAllReady;
1505
+ onAllReady();
1506
}
1507
}
1508