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
This commit is contained in:
Sebastian Markbåge 2022-03-04 14:38:46 -05:00 committed by GitHub
parent cb1e7b1c6c
commit 14c2be8dac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 151 additions and 58 deletions

View File

@ -22,13 +22,13 @@ export default function render(url, res) {
let didError = false;
const {pipe, abort} = renderToPipeableStream(<App assets={assets} />, {
bootstrapScripts: [assets['main.js']],
onCompleteShell() {
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onErrorShell(x) {
onShellError(x) {
// Something errored before we could complete the shell so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype><p>Error</p>');

View File

@ -49,7 +49,7 @@ module.exports = function render(url, res) {
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onErrorShell(x) {
onShellError(x) {
// Something errored before we could complete the shell so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype><p>Error</p>');

View File

@ -914,7 +914,7 @@ describe('ReactDOMFizzServer', () => {
</Suspense>,
{
identifierPrefix: 'A_',
onCompleteShell() {
onShellReady() {
writableA.write('<div id="container-A">');
pipe(writableA);
writableA.write('</div>');
@ -933,7 +933,7 @@ describe('ReactDOMFizzServer', () => {
</Suspense>,
{
identifierPrefix: 'B_',
onCompleteShell() {
onShellReady() {
writableB.write('<div id="container-B">');
pipe(writableB);
writableB.write('</div>');
@ -1168,7 +1168,7 @@ describe('ReactDOMFizzServer', () => {
{
namespaceURI: 'http://www.w3.org/2000/svg',
onCompleteShell() {
onShellReady() {
writable.write('<svg>');
pipe(writable);
writable.write('</svg>');

View File

@ -209,4 +209,43 @@ describe('ReactDOMFizzServer', () => {
const result = await readResult(stream);
expect(result).toContain('Loading');
});
// @gate experimental
it('should not continue rendering after the reader cancels', async () => {
let hasLoaded = false;
let resolve;
let isComplete = false;
let rendered = false;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
rendered = true;
return 'Done';
}
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait /> />
</Suspense>
</div>,
);
stream.allReady.then(() => (isComplete = true));
expect(rendered).toBe(false);
expect(isComplete).toBe(false);
const reader = stream.getReader();
reader.cancel();
hasLoaded = true;
resolve();
await jest.runAllTimers();
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
});
});

View File

@ -138,7 +138,7 @@ describe('ReactDOMFizzServer', () => {
</div>,
{
onCompleteAll() {
onAllReady() {
isCompleteCalls++;
},
},
@ -179,7 +179,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) {
reportedErrors.push(x);
},
onErrorShell(x) {
onShellError(x) {
reportedShellErrors.push(x);
},
},
@ -213,7 +213,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) {
reportedErrors.push(x);
},
onErrorShell(x) {
onShellError(x) {
reportedShellErrors.push(x);
},
},
@ -244,7 +244,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) {
reportedErrors.push(x);
},
onErrorShell(x) {
onShellError(x) {
reportedShellErrors.push(x);
},
},
@ -298,7 +298,7 @@ describe('ReactDOMFizzServer', () => {
</div>,
{
onCompleteAll() {
onAllReady() {
isCompleteCalls++;
},
},
@ -333,7 +333,7 @@ describe('ReactDOMFizzServer', () => {
</div>,
{
onCompleteAll() {
onAllReady() {
isCompleteCalls++;
},
},
@ -537,4 +537,49 @@ describe('ReactDOMFizzServer', () => {
expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK');
});
// @gate experimental
it('should not continue rendering after the writable ends unexpectedly', async () => {
let hasLoaded = false;
let resolve;
let isComplete = false;
let rendered = false;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
rendered = true;
return 'Done';
}
const {writable, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait />
</Suspense>
</div>,
{
onAllReady() {
isComplete = true;
},
},
);
pipe(writable);
expect(rendered).toBe(false);
expect(isComplete).toBe(false);
writable.end();
await jest.runAllTimers();
hasLoaded = true;
resolve();
await completed;
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
});
});

View File

@ -155,7 +155,7 @@ module.exports = function(initModules) {
new Promise((resolve, reject) => {
const writable = new DrainWritable();
const s = ReactDOMServer.renderToPipeableStream(reactElement, {
onErrorShell(e) {
onShellError(e) {
reject(e);
},
});

View File

@ -46,25 +46,27 @@ function renderToReadableStream(
): Promise<ReactDOMServerReadableStream> {
return new Promise((resolve, reject) => {
let onFatalError;
let onCompleteAll;
let onAllReady;
const allReady = new Promise((res, rej) => {
onCompleteAll = res;
onAllReady = res;
onFatalError = rej;
});
function onCompleteShell() {
function onShellReady() {
const stream: ReactDOMServerReadableStream = (new ReadableStream({
type: 'bytes',
pull(controller) {
startFlowing(request, controller);
},
cancel(reason) {},
cancel(reason) {
abort(request);
},
}): any);
// TODO: Move to sub-classing ReadableStream.
stream.allReady = allReady;
resolve(stream);
}
function onErrorShell(error: mixed) {
function onShellError(error: mixed) {
reject(error);
}
const request = createRequest(
@ -79,9 +81,9 @@ function renderToReadableStream(
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
onCompleteAll,
onCompleteShell,
onErrorShell,
onAllReady,
onShellReady,
onShellError,
onFatalError,
);
if (options && options.signal) {

View File

@ -28,6 +28,10 @@ function createDrainHandler(destination, request) {
return () => startFlowing(request, destination);
}
function createAbortHandler(request) {
return () => abort(request);
}
type Options = {|
identifierPrefix?: string,
namespaceURI?: string,
@ -36,9 +40,9 @@ type Options = {|
bootstrapScripts?: Array<string>,
bootstrapModules?: Array<string>,
progressiveChunkSize?: number,
onCompleteShell?: () => void,
onErrorShell?: () => void,
onCompleteAll?: () => void,
onShellReady?: () => void,
onShellError?: () => void,
onAllReady?: () => void,
onError?: (error: mixed) => void,
|};
@ -62,9 +66,9 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
options ? options.onCompleteAll : undefined,
options ? options.onCompleteShell : undefined,
options ? options.onErrorShell : undefined,
options ? options.onAllReady : undefined,
options ? options.onShellReady : undefined,
options ? options.onShellError : undefined,
undefined,
);
}
@ -86,6 +90,7 @@ function renderToPipeableStream(
hasStartedFlowing = true;
startFlowing(request, destination);
destination.on('drain', createDrainHandler(destination, request));
destination.on('close', createAbortHandler(request));
return destination;
},
abort() {

View File

@ -53,7 +53,7 @@ function renderToStringImpl(
};
let readyToStream = false;
function onCompleteShell() {
function onShellReady() {
readyToStream = true;
}
const request = createRequest(
@ -66,7 +66,7 @@ function renderToStringImpl(
Infinity,
onError,
undefined,
onCompleteShell,
onShellReady,
undefined,
undefined,
);

View File

@ -68,7 +68,7 @@ function renderToNodeStreamImpl(
options: void | ServerOptions,
generateStaticMarkup: boolean,
): Readable {
function onCompleteAll() {
function onAllReady() {
// We wait until everything has loaded before starting to write.
// That way we only end up with fully resolved HTML even if we suspend.
destination.startedFlowing = true;
@ -81,7 +81,7 @@ function renderToNodeStreamImpl(
createRootFormatContext(),
Infinity,
onError,
onCompleteAll,
onAllReady,
undefined,
undefined,
);

View File

@ -251,8 +251,8 @@ const ReactNoopServer = ReactFizzServer({
type Options = {
progressiveChunkSize?: number,
onCompleteShell?: () => void,
onCompleteAll?: () => void,
onShellReady?: () => void,
onAllReady?: () => void,
onError?: (error: mixed) => void,
};
@ -272,8 +272,8 @@ function render(children: React$Element<any>, options?: Options): Destination {
null,
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
options ? options.onCompleteAll : undefined,
options ? options.onCompleteShell : undefined,
options ? options.onAllReady : undefined,
options ? options.onShellReady : undefined,
);
ReactNoopServer.startWork(request);
ReactNoopServer.startFlowing(request, destination);

View File

@ -194,16 +194,16 @@ export opaque type Request = {
partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
// onError is called when an error happens anywhere in the tree. It might recover.
onError: (error: mixed) => void,
// onCompleteAll is called when all pending task is done but it may not have flushed yet.
// onAllReady is called when all pending task is done but it may not have flushed yet.
// This is a good time to start writing if you want only HTML and no intermediate steps.
onCompleteAll: () => void,
// onCompleteShell is called when there is at least a root fallback ready to show.
onAllReady: () => void,
// onShellReady is called when there is at least a root fallback ready to show.
// Typically you don't need this callback because it's best practice to always have a
// root fallback ready so there's no need to wait.
onCompleteShell: () => void,
// onErrorShell is called when the shell didn't complete. That means you probably want to
onShellReady: () => void,
// onShellError is called when the shell didn't complete. That means you probably want to
// emit a different response to the stream instead.
onErrorShell: (error: mixed) => void,
onShellError: (error: mixed) => void,
onFatalError: (error: mixed) => void,
};
@ -236,9 +236,9 @@ export function createRequest(
rootFormatContext: FormatContext,
progressiveChunkSize: void | number,
onError: void | ((error: mixed) => void),
onCompleteAll: void | (() => void),
onCompleteShell: void | (() => void),
onErrorShell: void | ((error: mixed) => void),
onAllReady: void | (() => void),
onShellReady: void | (() => void),
onShellError: void | ((error: mixed) => void),
onFatalError: void | ((error: mixed) => void),
): Request {
const pingedTasks = [];
@ -262,9 +262,9 @@ export function createRequest(
completedBoundaries: [],
partialBoundaries: [],
onError: onError === undefined ? defaultErrorHandler : onError,
onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll,
onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell,
onErrorShell: onErrorShell === undefined ? noop : onErrorShell,
onAllReady: onAllReady === undefined ? noop : onAllReady,
onShellReady: onShellReady === undefined ? noop : onShellReady,
onShellError: onShellError === undefined ? noop : onShellError,
onFatalError: onFatalError === undefined ? noop : onFatalError,
};
// This segment represents the root fallback.
@ -422,8 +422,10 @@ function fatalError(request: Request, error: mixed): void {
// This is called outside error handling code such as if the root errors outside
// a suspense boundary or if the root suspense boundary's fallback errors.
// It's also called if React itself or its host configs errors.
const onErrorShell = request.onErrorShell;
onErrorShell(error);
const onShellError = request.onShellError;
onShellError(error);
const onFatalError = request.onFatalError;
onFatalError(error);
if (request.destination !== null) {
request.status = CLOSED;
closeWithError(request.destination, error);
@ -1371,8 +1373,8 @@ function erroredTask(
request.allPendingTasks--;
if (request.allPendingTasks === 0) {
const onCompleteAll = request.onCompleteAll;
onCompleteAll();
const onAllReady = request.onAllReady;
onAllReady();
}
}
@ -1422,8 +1424,8 @@ function abortTask(task: Task): void {
request.allPendingTasks--;
if (request.allPendingTasks === 0) {
const onCompleteAll = request.onCompleteAll;
onCompleteAll();
const onAllReady = request.onAllReady;
onAllReady();
}
}
}
@ -1446,9 +1448,9 @@ function finishedTask(
request.pendingRootTasks--;
if (request.pendingRootTasks === 0) {
// We have completed the shell so the shell can't error anymore.
request.onErrorShell = noop;
const onCompleteShell = request.onCompleteShell;
onCompleteShell();
request.onShellError = noop;
const onShellReady = request.onShellReady;
onShellReady();
}
} else {
boundary.pendingTasks--;
@ -1499,8 +1501,8 @@ function finishedTask(
if (request.allPendingTasks === 0) {
// This needs to be called at the very end so that we can synchronously write the result
// in the callback if needed.
const onCompleteAll = request.onCompleteAll;
onCompleteAll();
const onAllReady = request.onAllReady;
onAllReady();
}
}