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

View File

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

View File

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

View File

@ -209,4 +209,43 @@ describe('ReactDOMFizzServer', () => {
const result = await readResult(stream); const result = await readResult(stream);
expect(result).toContain('Loading'); 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>, </div>,
{ {
onCompleteAll() { onAllReady() {
isCompleteCalls++; isCompleteCalls++;
}, },
}, },
@ -179,7 +179,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) { onError(x) {
reportedErrors.push(x); reportedErrors.push(x);
}, },
onErrorShell(x) { onShellError(x) {
reportedShellErrors.push(x); reportedShellErrors.push(x);
}, },
}, },
@ -213,7 +213,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) { onError(x) {
reportedErrors.push(x); reportedErrors.push(x);
}, },
onErrorShell(x) { onShellError(x) {
reportedShellErrors.push(x); reportedShellErrors.push(x);
}, },
}, },
@ -244,7 +244,7 @@ describe('ReactDOMFizzServer', () => {
onError(x) { onError(x) {
reportedErrors.push(x); reportedErrors.push(x);
}, },
onErrorShell(x) { onShellError(x) {
reportedShellErrors.push(x); reportedShellErrors.push(x);
}, },
}, },
@ -298,7 +298,7 @@ describe('ReactDOMFizzServer', () => {
</div>, </div>,
{ {
onCompleteAll() { onAllReady() {
isCompleteCalls++; isCompleteCalls++;
}, },
}, },
@ -333,7 +333,7 @@ describe('ReactDOMFizzServer', () => {
</div>, </div>,
{ {
onCompleteAll() { onAllReady() {
isCompleteCalls++; isCompleteCalls++;
}, },
}, },
@ -537,4 +537,49 @@ describe('ReactDOMFizzServer', () => {
expect(output.result).not.toContain('context never found'); expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK'); 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) => { new Promise((resolve, reject) => {
const writable = new DrainWritable(); const writable = new DrainWritable();
const s = ReactDOMServer.renderToPipeableStream(reactElement, { const s = ReactDOMServer.renderToPipeableStream(reactElement, {
onErrorShell(e) { onShellError(e) {
reject(e); reject(e);
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -251,8 +251,8 @@ const ReactNoopServer = ReactFizzServer({
type Options = { type Options = {
progressiveChunkSize?: number, progressiveChunkSize?: number,
onCompleteShell?: () => void, onShellReady?: () => void,
onCompleteAll?: () => void, onAllReady?: () => void,
onError?: (error: mixed) => void, onError?: (error: mixed) => void,
}; };
@ -272,8 +272,8 @@ function render(children: React$Element<any>, options?: Options): Destination {
null, null,
options ? options.progressiveChunkSize : undefined, options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined, options ? options.onError : undefined,
options ? options.onCompleteAll : undefined, options ? options.onAllReady : undefined,
options ? options.onCompleteShell : undefined, options ? options.onShellReady : undefined,
); );
ReactNoopServer.startWork(request); ReactNoopServer.startWork(request);
ReactNoopServer.startFlowing(request, destination); 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. 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 is called when an error happens anywhere in the tree. It might recover.
onError: (error: mixed) => void, 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. // This is a good time to start writing if you want only HTML and no intermediate steps.
onCompleteAll: () => void, onAllReady: () => void,
// onCompleteShell is called when there is at least a root fallback ready to show. // 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 // 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. // root fallback ready so there's no need to wait.
onCompleteShell: () => void, onShellReady: () => void,
// onErrorShell is called when the shell didn't complete. That means you probably want to // onShellError is called when the shell didn't complete. That means you probably want to
// emit a different response to the stream instead. // emit a different response to the stream instead.
onErrorShell: (error: mixed) => void, onShellError: (error: mixed) => void,
onFatalError: (error: mixed) => void, onFatalError: (error: mixed) => void,
}; };
@ -236,9 +236,9 @@ export function createRequest(
rootFormatContext: FormatContext, rootFormatContext: FormatContext,
progressiveChunkSize: void | number, progressiveChunkSize: void | number,
onError: void | ((error: mixed) => void), onError: void | ((error: mixed) => void),
onCompleteAll: void | (() => void), onAllReady: void | (() => void),
onCompleteShell: void | (() => void), onShellReady: void | (() => void),
onErrorShell: void | ((error: mixed) => void), onShellError: void | ((error: mixed) => void),
onFatalError: void | ((error: mixed) => void), onFatalError: void | ((error: mixed) => void),
): Request { ): Request {
const pingedTasks = []; const pingedTasks = [];
@ -262,9 +262,9 @@ export function createRequest(
completedBoundaries: [], completedBoundaries: [],
partialBoundaries: [], partialBoundaries: [],
onError: onError === undefined ? defaultErrorHandler : onError, onError: onError === undefined ? defaultErrorHandler : onError,
onCompleteAll: onCompleteAll === undefined ? noop : onCompleteAll, onAllReady: onAllReady === undefined ? noop : onAllReady,
onCompleteShell: onCompleteShell === undefined ? noop : onCompleteShell, onShellReady: onShellReady === undefined ? noop : onShellReady,
onErrorShell: onErrorShell === undefined ? noop : onErrorShell, onShellError: onShellError === undefined ? noop : onShellError,
onFatalError: onFatalError === undefined ? noop : onFatalError, onFatalError: onFatalError === undefined ? noop : onFatalError,
}; };
// This segment represents the root fallback. // 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 // 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. // a suspense boundary or if the root suspense boundary's fallback errors.
// It's also called if React itself or its host configs errors. // It's also called if React itself or its host configs errors.
const onErrorShell = request.onErrorShell; const onShellError = request.onShellError;
onErrorShell(error); onShellError(error);
const onFatalError = request.onFatalError;
onFatalError(error);
if (request.destination !== null) { if (request.destination !== null) {
request.status = CLOSED; request.status = CLOSED;
closeWithError(request.destination, error); closeWithError(request.destination, error);
@ -1371,8 +1373,8 @@ function erroredTask(
request.allPendingTasks--; request.allPendingTasks--;
if (request.allPendingTasks === 0) { if (request.allPendingTasks === 0) {
const onCompleteAll = request.onCompleteAll; const onAllReady = request.onAllReady;
onCompleteAll(); onAllReady();
} }
} }
@ -1422,8 +1424,8 @@ function abortTask(task: Task): void {
request.allPendingTasks--; request.allPendingTasks--;
if (request.allPendingTasks === 0) { if (request.allPendingTasks === 0) {
const onCompleteAll = request.onCompleteAll; const onAllReady = request.onAllReady;
onCompleteAll(); onAllReady();
} }
} }
} }
@ -1446,9 +1448,9 @@ function finishedTask(
request.pendingRootTasks--; request.pendingRootTasks--;
if (request.pendingRootTasks === 0) { if (request.pendingRootTasks === 0) {
// We have completed the shell so the shell can't error anymore. // We have completed the shell so the shell can't error anymore.
request.onErrorShell = noop; request.onShellError = noop;
const onCompleteShell = request.onCompleteShell; const onShellReady = request.onShellReady;
onCompleteShell(); onShellReady();
} }
} else { } else {
boundary.pendingTasks--; boundary.pendingTasks--;
@ -1499,8 +1501,8 @@ function finishedTask(
if (request.allPendingTasks === 0) { if (request.allPendingTasks === 0) {
// This needs to be called at the very end so that we can synchronously write the result // This needs to be called at the very end so that we can synchronously write the result
// in the callback if needed. // in the callback if needed.
const onCompleteAll = request.onCompleteAll; const onAllReady = request.onAllReady;
onCompleteAll(); onAllReady();
} }
} }