[Fizz] Send errors down to client (#24551)

* use return from onError

* export getSuspenseInstanceFallbackError

* stringToChunk

* return string from onError in downstream type signatures

* 1 more type

* support encoding errors in html stream and escape user input

This commit adds another way to get errors to the suspense instance by encoding them as dataset properties of a template element at the head of the boundary. Previously if there was an error before the boundary flushed there was no way to stream the error to the client because there would never be a client render instruction.

Additionally the error is sent in 3 parts

1) error hash - this is always sent (dev or prod) if one is provided
2) error message - Dev only
3) error component stack - Dev only, this now captures the stack at the point of error

Another item addressed in this commit is the escaping of potentially unsafe data. all error components are escaped as test for browers when written into the html and as javascript strings when written into a client render instruction.

* nits

Co-authored-by: Marco Salazar <[email protected]>
This commit is contained in:
Josh Story
2022-05-29 23:07:10 -07:00
committed by GitHub
co-authored by Marco Salazar
parent d13652a8e7
commit 8fda8d3487
17 changed files with 768 additions and 77 deletions
+418 -39
View File
@@ -89,6 +89,48 @@ describe('ReactDOMFizzServer', () => {
});
});
function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
const mappedErrows = errorsArr.map(error => {
if (error.componentStack) {
return [
error.message,
error.hash,
normalizeCodeLocInfo(error.componentStack),
];
} else if (error.hash) {
return [error.message, error.hash];
}
return error.message;
});
if (__DEV__) {
expect(mappedErrows).toEqual(
toBeDevArr,
// .map(([errorMessage, errorHash, errorComponentStack]) => {
// if (typeof error === 'string' || error instanceof String) {
// return error;
// }
// let str = JSON.stringify(error).replace(/\\n/g, '\n');
// // this gets stripped away by normalizeCodeLocInfo...
// // Kind of hacky but lets strip it away here too just so they match...
// // easier than fixing the regex to account for this edge case
// if (str.endsWith('at **)"}')) {
// str = str.replace(/at \*\*\)\"}$/, 'at **)');
// }
// return str;
// }),
);
} else {
expect(mappedErrows).toEqual(toBeProdArr);
}
}
// @TODO we will use this in a followup change once we start exposing componentStacks from server errors
// function componentStack(components) {
// return components
// .map(component => `\n in ${component} (at **)`)
// .join('');
// }
async function act(callback) {
await callback();
// Await one turn around the event loop.
@@ -413,8 +455,6 @@ describe('ReactDOMFizzServer', () => {
});
});
const loggedErrors = [];
function App({isClient}) {
return (
<div>
@@ -426,24 +466,32 @@ describe('ReactDOMFizzServer', () => {
}
let bootstrapped = false;
const errors = [];
window.__INIT__ = function() {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error);
},
});
};
const theError = new Error('Test');
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return 'Hash of (' + x.message + ')';
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App isClient={false} />,
{
bootstrapScriptContent: '__INIT__();',
onError(x) {
loggedErrors.push(x);
},
onError,
},
);
pipe(writable);
@@ -458,7 +506,6 @@ describe('ReactDOMFizzServer', () => {
expect(loggedErrors).toEqual([]);
const theError = new Error('Test');
await act(async () => {
rejectComponent(theError);
});
@@ -469,10 +516,14 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// Now we can client render it instead.
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
@@ -520,7 +571,14 @@ describe('ReactDOMFizzServer', () => {
});
});
const theError = new Error('Test');
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return 'hash of (' + x.message + ')';
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
function App({isClient}) {
return (
@@ -537,19 +595,18 @@ describe('ReactDOMFizzServer', () => {
<App isClient={false} />,
{
onError(x) {
loggedErrors.push(x);
},
onError,
},
);
pipe(writable);
});
expect(loggedErrors).toEqual([]);
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error);
},
});
Scheduler.unstable_flushAll();
@@ -559,7 +616,6 @@ describe('ReactDOMFizzServer', () => {
expect(loggedErrors).toEqual([]);
const theError = new Error('Test');
await act(async () => {
rejectElement(theError);
});
@@ -570,14 +626,161 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// Now we can client render it instead.
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
// The client rendered HTML is now in place.
// expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
expect(loggedErrors).toEqual([theError]);
});
// @gate experimental
it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
function Indirection({level, children}) {
if (level > 0) {
return <Indirection level={level - 1}>{children}</Indirection>;
}
return children;
}
const theError = new Error('uh oh');
function Erroring({isClient}) {
if (isClient) {
return 'Hello World';
}
throw theError;
}
function App({isClient}) {
return (
<div>
<Suspense fallback={<span>loading...</span>}>
<Erroring isClient={isClient} />
</Suspense>
</div>
);
}
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return 'hash(' + x.message + ')';
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App />,
{
onError,
},
);
pipe(writable);
});
expect(loggedErrors).toEqual([theError]);
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
errors.push(error);
},
});
Scheduler.unstable_flushAll();
expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
});
// @gate experimental
it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
let rejectComponent;
const LazyComponent = React.lazy(() => {
return new Promise((resolve, reject) => {
rejectComponent = reject;
});
});
function App({isClient}) {
return (
<div>
<Suspense fallback={<Text text="Loading..." />}>
{isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
</Suspense>
</div>
);
}
const loggedErrors = [];
const theError = new Error('uh oh');
function onError(x) {
loggedErrors.push(x);
return 'hash(' + x.message + ')';
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App />,
{
onError,
},
);
pipe(writable);
});
expect(loggedErrors).toEqual([]);
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
errors.push(error);
},
});
Scheduler.unstable_flushAll();
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
await act(async () => {
rejectComponent(theError);
});
expect(loggedErrors).toEqual([theError]);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// Now we can client render it instead.
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
expect(loggedErrors).toEqual([theError]);
});
@@ -849,18 +1052,25 @@ describe('ReactDOMFizzServer', () => {
);
}
const loggedErrors = [];
function onError(error) {
loggedErrors.push(error);
return `Hash of (${error.message})`;
}
let controls;
await act(async () => {
controls = ReactDOMFizzServer.renderToPipeableStream(<App />);
controls = ReactDOMFizzServer.renderToPipeableStream(<App />, {onError});
controls.pipe(writable);
});
// We're still showing a fallback.
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error);
},
});
Scheduler.unstable_flushAll();
@@ -874,10 +1084,14 @@ describe('ReactDOMFizzServer', () => {
});
// We still can't render it on the client.
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to an ' +
'error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
['This Suspense boundary was aborted by the server'],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// We now resolve it on the client.
@@ -1535,16 +1749,22 @@ describe('ReactDOMFizzServer', () => {
);
}
const theError = new Error('Test');
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return `hash of (${x.message})`;
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
let controls;
await act(async () => {
controls = ReactDOMFizzServer.renderToPipeableStream(
<App isClient={false} />,
{
onError(x) {
loggedErrors.push(x);
},
onError,
},
);
controls.pipe(writable);
@@ -1552,10 +1772,11 @@ describe('ReactDOMFizzServer', () => {
// We're still showing a fallback.
const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error);
},
});
Scheduler.unstable_flushAll();
@@ -1565,7 +1786,6 @@ describe('ReactDOMFizzServer', () => {
expect(loggedErrors).toEqual([]);
const theError = new Error('Test');
// Error the content, but we don't have a fallback yet.
await act(async () => {
rejectText('Hello', theError);
@@ -1586,10 +1806,14 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// That will let us client render it instead.
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
@@ -2178,11 +2402,10 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during render.
isClient = true;
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
errors.push(error.message);
},
});
@@ -2190,6 +2413,8 @@ describe('ReactDOMFizzServer', () => {
// shouldn't be called.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual('Oops!');
expectErrors(errors, [], []);
},
);
@@ -2794,6 +3019,160 @@ describe('ReactDOMFizzServer', () => {
);
});
describe('error escaping', () => {
//@gate experimental
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
window.__outlet = {};
const dangerousErrorString =
'"></template></div><script>window.__outlet.message="from error"</script><div><template data-foo="';
function Erroring() {
throw new Error(dangerousErrorString);
}
// We can't test newline in component stacks because the stack always takes just one line and we end up
// dropping the first part including the \n character
Erroring.displayName =
'DangerousName' +
dangerousErrorString.replace(
'message="from error"',
'stack="from_stack"',
);
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Erroring />
</Suspense>
</div>
);
}
function onError(x) {
return `dangerous hash ${x.message.replace(
'message="from error"',
'hash="from hash"',
)}`;
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError,
});
pipe(writable);
});
expect(window.__outlet).toEqual({});
});
//@gate experimental
it('escapes error hash, message, and component stack values in clientRenderInstruction (javascript escaping)', async () => {
window.__outlet = {};
const dangerousErrorString =
'");window.__outlet.message="from error";</script><script>(() => {})("';
let rejectComponent;
const SuspensyErroring = React.lazy(() => {
return new Promise((resolve, reject) => {
rejectComponent = reject;
});
});
// We can't test newline in component stacks because the stack always takes just one line and we end up
// dropping the first part including the \n character
SuspensyErroring.displayName =
'DangerousName' +
dangerousErrorString.replace(
'message="from error"',
'stack="from_stack"',
);
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<SuspensyErroring />
</Suspense>
</div>
);
}
function onError(x) {
return `dangerous hash ${x.message.replace(
'message="from error"',
'hash="from hash"',
)}`;
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError,
});
pipe(writable);
});
await act(() => {
rejectComponent(new Error(dangerousErrorString));
});
expect(window.__outlet).toEqual({});
});
//@gate experimental
it('escapes such that attributes cannot be masked', async () => {
const dangerousErrorString = '" data-msg="bad message" data-foo="';
const theError = new Error(dangerousErrorString);
function Erroring({isClient}) {
if (isClient) return 'Hello';
throw theError;
}
function App({isClient}) {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Erroring isClient={isClient} />
</Suspense>
</div>
);
}
const loggedErrors = [];
function onError(x) {
loggedErrors.push(x);
return x.message.replace('bad message', 'bad hash');
}
// const expectedHash = onError(theError);
// loggedErrors.length = 0;
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError,
});
pipe(writable);
});
expect(loggedErrors).toEqual([theError]);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
errors.push(error);
},
});
expect(Scheduler).toFlushAndYield([]);
// If escaping were not done we would get a message that says "bad hash"
expectErrors(
errors,
[theError.message],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
],
);
});
});
describe('bootstrapScriptContent escaping', () => {
// @gate experimental
it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
@@ -193,6 +193,7 @@ describe('ReactDOMFizzServer', () => {
// @gate experimental
it('should be able to complete by aborting even if the promise never resolves', async () => {
const errors = [];
const controller = new AbortController();
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
@@ -200,13 +201,22 @@ describe('ReactDOMFizzServer', () => {
<InfiniteSuspend />
</Suspense>
</div>,
{signal: controller.signal},
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
);
controller.abort();
const result = await readResult(stream);
expect(result).toContain('Loading');
expect(errors).toEqual([
'This Suspense boundary was aborted by the server',
]);
});
// @gate experimental
@@ -223,12 +233,18 @@ describe('ReactDOMFizzServer', () => {
rendered = true;
return 'Done';
}
const errors = [];
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait /> />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
},
);
stream.allReady.then(() => (isComplete = true));
@@ -239,6 +255,10 @@ describe('ReactDOMFizzServer', () => {
const reader = stream.getReader();
reader.cancel();
expect(errors).toEqual([
'This Suspense boundary was aborted by the server',
]);
hasLoaded = true;
resolve();
@@ -211,7 +211,7 @@ describe('ReactDOMFizzServer', () => {
{
onError(x) {
reportedErrors.push(x);
reportedErrors.push(x.message);
},
onShellError(x) {
reportedShellErrors.push(x);
@@ -224,7 +224,10 @@ describe('ReactDOMFizzServer', () => {
expect(output.error).toBe(theError);
expect(output.result).toBe('');
expect(reportedErrors).toEqual([theError]);
expect(reportedErrors).toEqual([
theError.message,
'This Suspense boundary was aborted by the server',
]);
expect(reportedShellErrors).toEqual([theError]);
});
@@ -289,6 +292,7 @@ describe('ReactDOMFizzServer', () => {
// @gate experimental
it('should be able to complete by aborting even if the promise never resolves', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
@@ -298,6 +302,9 @@ describe('ReactDOMFizzServer', () => {
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
@@ -314,6 +321,9 @@ describe('ReactDOMFizzServer', () => {
await completed;
expect(errors).toEqual([
'This Suspense boundary was aborted by the server',
]);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
@@ -322,6 +332,7 @@ describe('ReactDOMFizzServer', () => {
// @gate experimental
it('should be able to complete by abort when the fallback is also suspended', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
@@ -333,6 +344,9 @@ describe('ReactDOMFizzServer', () => {
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
@@ -349,6 +363,11 @@ describe('ReactDOMFizzServer', () => {
await completed;
expect(errors).toEqual([
// There are two boundaries that abort
'This Suspense boundary was aborted by the server',
'This Suspense boundary was aborted by the server',
]);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
@@ -552,6 +571,7 @@ describe('ReactDOMFizzServer', () => {
rendered = true;
return 'Done';
}
const errors = [];
const {writable, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
@@ -560,6 +580,9 @@ describe('ReactDOMFizzServer', () => {
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isComplete = true;
},
@@ -579,6 +602,9 @@ describe('ReactDOMFizzServer', () => {
await completed;
expect(errors).toEqual([
'This Suspense boundary was aborted by the server',
]);
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
});
+24
View File
@@ -729,6 +729,30 @@ export function isSuspenseInstancePending(instance: SuspenseInstance) {
export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
return instance.data === SUSPENSE_FALLBACK_START_DATA;
}
export function getSuspenseInstanceFallbackErrorDetails(
instance: SuspenseInstance,
) {
const nextSibling = instance.nextSibling;
let errorMessage /*, errorComponentStack, errorHash*/;
if (
nextSibling &&
nextSibling.nodeType === ELEMENT_NODE &&
nextSibling.nodeName.toLowerCase() === 'template'
) {
const msg = ((nextSibling: any): HTMLTemplateElement).dataset.msg;
if (msg !== null) errorMessage = msg;
// @TODO read and return hash and componentStack once we know how we are goign to
// expose this extra errorInfo to onRecoverableError
// const hash = ((nextSibling: any): HTMLTemplateElement).dataset.hash;
// if (hash !== null) errorHash = hash;
// const stack = ((nextSibling: any): HTMLTemplateElement).dataset.stack;
// if (stack !== null) errorComponentStack = stack;
}
return {errorMessage /*, errorComponentStack, errorHash*/};
}
export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
+1 -1
View File
@@ -32,7 +32,7 @@ type Options = {|
bootstrapModules?: Array<string>,
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => void,
onError?: (error: mixed) => ?string,
|};
// TODO: Move to sub-classing ReadableStream.
+1 -1
View File
@@ -43,7 +43,7 @@ type Options = {|
onShellReady?: () => void,
onShellError?: (error: mixed) => void,
onAllReady?: () => void,
onError?: (error: mixed) => void,
onError?: (error: mixed) => ?string,
|};
type PipeableStream = {|
+106 -4
View File
@@ -1526,6 +1526,19 @@ const startClientRenderedSuspenseBoundary = stringToPrecomputedChunk(
);
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');
const clientRenderedSuspenseBoundaryError1 = stringToPrecomputedChunk(
'<template data-hash="',
);
const clientRenderedSuspenseBoundaryError1A = stringToPrecomputedChunk(
'" data-msg="',
);
const clientRenderedSuspenseBoundaryError1B = stringToPrecomputedChunk(
'" data-stack="',
);
const clientRenderedSuspenseBoundaryError2 = stringToPrecomputedChunk(
'"></template>',
);
export function pushStartCompletedSuspenseBoundary(
target: Array<Chunk | PrecomputedChunk>,
) {
@@ -1563,8 +1576,43 @@ export function writeStartPendingSuspenseBoundary(
export function writeStartClientRenderedSuspenseBoundary(
destination: Destination,
responseState: ResponseState,
errorHash: ?string,
errorMesssage: ?string,
errorComponentStack: ?string,
): boolean {
return writeChunkAndReturn(destination, startClientRenderedSuspenseBoundary);
let result;
result = writeChunkAndReturn(
destination,
startClientRenderedSuspenseBoundary,
);
if (errorHash) {
writeChunk(destination, clientRenderedSuspenseBoundaryError1);
writeChunk(destination, stringToChunk(escapeTextForBrowser(errorHash)));
// In prod errorMessage will usually be nullish but there is one case where
// it is used (currently when the server aborts the task) so we leave it ungated.
if (errorMesssage) {
writeChunk(destination, clientRenderedSuspenseBoundaryError1A);
writeChunk(
destination,
stringToChunk(escapeTextForBrowser(errorMesssage)),
);
}
if (__DEV__) {
// Component stacks are currently only captured in dev
if (errorComponentStack) {
writeChunk(destination, clientRenderedSuspenseBoundaryError1B);
writeChunk(
destination,
stringToChunk(escapeTextForBrowser(errorComponentStack)),
);
}
}
result = writeChunkAndReturn(
destination,
clientRenderedSuspenseBoundaryError2,
);
}
return result;
}
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
@@ -1724,7 +1772,7 @@ export function writeEndSegment(
// const SUSPENSE_PENDING_START_DATA = '$?';
// const SUSPENSE_FALLBACK_START_DATA = '$!';
//
// function clientRenderBoundary(suspenseBoundaryID) {
// function clientRenderBoundary(suspenseBoundaryID, errorHash, errorMsg, errorComponentStack) {
// // Find the fallback's first element.
// const suspenseIdNode = document.getElementById(suspenseBoundaryID);
// if (!suspenseIdNode) {
@@ -1736,6 +1784,11 @@ export function writeEndSegment(
// const suspenseNode = suspenseIdNode.previousSibling;
// // Tag it to be client rendered.
// suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
// // assign error metadata to first sibling
// let dataset = suspenseIdNode.dataset;
// if (errorHash) dataset.hash = errorHash;
// if (errorMsg) dataset.msg = errorMsg;
// if (errorComponentStack) dataset.stack = errorComponentStack;
// // Tell React to retry it if the parent already hydrated.
// if (suspenseNode._reactRetry) {
// suspenseNode._reactRetry();
@@ -1823,7 +1876,7 @@ const completeSegmentFunction =
const completeBoundaryFunction =
'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}';
const clientRenderFunction =
'function $RX(a){if(a=document.getElementById(a))a=a.previousSibling,a.data="$!",a._reactRetry&&a._reactRetry()}';
'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.hash=c),d&&(a.msg=d),e&&(a.stack=e),b._reactRetry&&b._reactRetry())}';
const completeSegmentScript1Full = stringToPrecomputedChunk(
completeSegmentFunction + ';$RS("',
@@ -1896,12 +1949,17 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
clientRenderFunction + ';$RX("',
);
const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
const clientRenderScript2 = stringToPrecomputedChunk('")</script>');
const clientRenderScript1A = stringToPrecomputedChunk('"');
const clientRenderScript2 = stringToPrecomputedChunk(')</script>');
const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
export function writeClientRenderBoundaryInstruction(
destination: Destination,
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
errorHash: ?string,
errorMessage?: string,
errorComponentStack?: string,
): boolean {
writeChunk(destination, responseState.startInlineScript);
if (!responseState.sentClientRenderFunction) {
@@ -1920,5 +1978,49 @@ export function writeClientRenderBoundaryInstruction(
}
writeChunk(destination, boundaryID);
writeChunk(destination, clientRenderScript1A);
if (errorHash || errorMessage || errorComponentStack) {
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
writeChunk(
destination,
stringToChunk(escapeJSStringsForInstructionScripts(errorHash || '')),
);
}
if (errorMessage || errorComponentStack) {
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
writeChunk(
destination,
stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
);
}
if (errorComponentStack) {
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
writeChunk(
destination,
stringToChunk(escapeJSStringsForInstructionScripts(errorComponentStack)),
);
}
return writeChunkAndReturn(destination, clientRenderScript2);
}
const regexForJSStringsInScripts = /[<\u2028\u2029]/g;
function escapeJSStringsForInstructionScripts(input: string): string {
const escaped = JSON.stringify(input);
return escaped.replace(regexForJSStringsInScripts, match => {
switch (match) {
// santizing breaking out of strings and script tags
case '<':
return '\\u003c';
case '\u2028':
return '\\u2028';
case '\u2029':
return '\\u2029';
default: {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'escapeJSStringsForInstructionScripts encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React',
);
}
}
});
}
@@ -148,6 +148,10 @@ export function writeStartCompletedSuspenseBoundary(
export function writeStartClientRenderedSuspenseBoundary(
destination: Destination,
responseState: ResponseState,
// flushing these error arguments are not currently supported in this legacy streaming format.
errorHash: ?string,
errorMessage?: string,
errorComponentStack?: string,
): boolean {
if (responseState.generateStaticMarkup) {
// A client rendered boundary is done and doesn't need a representation in the HTML
@@ -225,6 +225,10 @@ export function writeStartPendingSuspenseBoundary(
export function writeStartClientRenderedSuspenseBoundary(
destination: Destination,
responseState: ResponseState,
// TODO: encode error for native
errorHash: ?string,
errorMessage: ?string,
errorComponentStack: ?string,
): boolean {
return writeChunkAndReturn(destination, SUSPENSE_CLIENT_RENDER);
}
@@ -295,6 +299,10 @@ export function writeClientRenderBoundaryInstruction(
destination: Destination,
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
// TODO: encode error for native
errorHash: ?string,
errorMessage: ?string,
errorComponentStack: ?string,
): boolean {
writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER);
return writeChunkAndReturn(destination, formatID(boundaryID));
+1 -1
View File
@@ -267,7 +267,7 @@ type Options = {
progressiveChunkSize?: number,
onShellReady?: () => void,
onAllReady?: () => void,
onError?: (error: mixed) => void,
onError?: (error: mixed) => ?string,
};
function render(children: React$Element<any>, options?: Options): Destination {
@@ -157,6 +157,7 @@ import {
shouldSetTextContent,
isSuspenseInstancePending,
isSuspenseInstanceFallback,
getSuspenseInstanceFallbackErrorDetails,
registerSuspenseInstanceRetry,
supportsHydration,
isPrimaryRenderer,
@@ -2572,18 +2573,22 @@ function updateDehydratedSuspenseComponent(
// This boundary is in a permanent fallback state. In this case, we'll never
// get an update and we'll never be able to hydrate the final content. Let's just try the
// client side render instead.
const {errorMessage} = getSuspenseInstanceFallbackErrorDetails(
suspenseInstance,
);
const error = errorMessage
? // eslint-disable-next-line react-internal/prod-error-codes
new Error(errorMessage)
: new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
);
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
// TODO: The server should serialize the error message so we can log it
// here on the client. Or, in production, a hash/id that corresponds to
// the error.
new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
),
error,
);
}
@@ -157,6 +157,7 @@ import {
shouldSetTextContent,
isSuspenseInstancePending,
isSuspenseInstanceFallback,
getSuspenseInstanceFallbackErrorDetails,
registerSuspenseInstanceRetry,
supportsHydration,
isPrimaryRenderer,
@@ -2572,18 +2573,22 @@ function updateDehydratedSuspenseComponent(
// This boundary is in a permanent fallback state. In this case, we'll never
// get an update and we'll never be able to hydrate the final content. Let's just try the
// client side render instead.
const {errorMessage} = getSuspenseInstanceFallbackErrorDetails(
suspenseInstance,
);
const error = errorMessage
? // eslint-disable-next-line react-internal/prod-error-codes
new Error(errorMessage)
: new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
);
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
// TODO: The server should serialize the error message so we can log it
// here on the client. Or, in production, a hash/id that corresponds to
// the error.
new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
),
error,
);
}
@@ -26,6 +26,7 @@ export const canHydrateTextInstance = shim;
export const canHydrateSuspenseInstance = shim;
export const isSuspenseInstancePending = shim;
export const isSuspenseInstanceFallback = shim;
export const getSuspenseInstanceFallbackErrorDetails = shim;
export const registerSuspenseInstanceRetry = shim;
export const getNextHydratableSibling = shim;
export const getFirstHydratableChild = shim;
@@ -136,6 +136,8 @@ export const isSuspenseInstancePending =
$$$hostConfig.isSuspenseInstancePending;
export const isSuspenseInstanceFallback =
$$$hostConfig.isSuspenseInstanceFallback;
export const getSuspenseInstanceFallbackErrorDetails =
$$$hostConfig.getSuspenseInstanceFallbackErrorDetails;
export const registerSuspenseInstanceRetry =
$$$hostConfig.registerSuspenseInstanceRetry;
export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
@@ -169,6 +169,7 @@ describe('ReactDOMServerFB', () => {
});
it('should be able to complete by aborting even if the promise never resolves', () => {
const errors = [];
const stream = ReactDOMServer.renderToStream(
<div>
<Suspense fallback={<div>Loading</div>}>
@@ -177,7 +178,7 @@ describe('ReactDOMServerFB', () => {
</div>,
{
onError(x) {
console.error(x);
errors.push(x.message);
},
},
);
@@ -189,5 +190,9 @@ describe('ReactDOMServerFB', () => {
const remaining = readResult(stream);
expect(remaining).toEqual('');
expect(errors).toEqual([
'This Suspense boundary was aborted by the server',
]);
});
});
+119 -10
View File
@@ -131,6 +131,9 @@ type LegacyContext = {
type SuspenseBoundary = {
id: SuspenseBoundaryID,
rootSegmentID: number,
errorHash: ?string, // the error hash if it errors
errorMessage?: string, // the error string if it errors
errorComponentStack?: string, // the error component stack if it errors
forceClientRender: boolean, // if it errors or infinitely suspends
parentFlushed: boolean,
pendingTasks: number, // when it reaches zero we can show this boundary's content
@@ -196,7 +199,9 @@ export opaque type Request = {
completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
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,
// The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
// Returning null/undefined will cause a defualt error message in production
onError: (error: mixed) => ?string,
// 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.
onAllReady: () => void,
@@ -229,6 +234,7 @@ const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
function defaultErrorHandler(error: mixed) {
console['error'](error); // Don't transform to our wrapper
return null;
}
function noop(): void {}
@@ -238,7 +244,7 @@ export function createRequest(
responseState: ResponseState,
rootFormatContext: FormatContext,
progressiveChunkSize: void | number,
onError: void | ((error: mixed) => void),
onError: void | ((error: mixed) => ?string),
onAllReady: void | (() => void),
onShellReady: void | (() => void),
onShellError: void | ((error: mixed) => void),
@@ -317,6 +323,7 @@ function createSuspenseBoundary(
completedSegments: [],
byteSize: 0,
fallbackAbortableTasks,
errorHash: null,
};
}
@@ -426,11 +433,44 @@ function popComponentStackInDEV(task: Task): void {
}
}
function logRecoverableError(request: Request, error: mixed): void {
// stash the component stack of an unwinding error until it is processed
let lastBoundaryErrorComponentStackDev: ?string = null;
function captureBoundaryErrorDetailsDev(
boundary: SuspenseBoundary,
error: mixed,
) {
if (__DEV__) {
let errorMessage;
if (typeof error === 'string') {
errorMessage = error;
} else if (error && typeof error.message === 'string') {
errorMessage = error.message;
} else {
// eslint-disable-next-line react-internal/safe-string-coercion
errorMessage = String(error);
}
const errorComponentStack =
lastBoundaryErrorComponentStackDev || getCurrentStackInDEV();
lastBoundaryErrorComponentStackDev = null;
boundary.errorMessage = errorMessage;
boundary.errorComponentStack = errorComponentStack;
}
}
function logRecoverableError(request: Request, error: any): ?string {
// If this callback errors, we intentionally let that error bubble up to become a fatal error
// so that someone fixes the error reporting instead of hiding it.
const onError = request.onError;
onError(error);
const errorHash = request.onError(error);
if (errorHash != null && typeof errorHash !== 'string') {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
`onError returned something with a type other than "string". onError should return a string and may return null or undefined but must not return anything else. It received something of type "${typeof errorHash}" instead`,
);
}
return errorHash;
}
function fatalError(request: Request, error: mixed): void {
@@ -527,8 +567,12 @@ function renderSuspenseBoundary(
}
} catch (error) {
contentRootSegment.status = ERRORED;
logRecoverableError(request, error);
newBoundary.forceClientRender = true;
newBoundary.errorHash = logRecoverableError(request, error);
if (__DEV__) {
captureBoundaryErrorDetailsDev(newBoundary, error);
}
// We don't need to decrement any task numbers because we didn't spawn any new task.
// We don't need to schedule any task because we know the parent has written yet.
// We do need to fallthrough to create the fallback though.
@@ -1165,9 +1209,38 @@ function validateIterable(iterable, iteratorFn: Function): void {
}
}
function renderNodeDestructive(
request: Request,
task: Task,
node: ReactNodeList,
): void {
if (__DEV__) {
// In Dev we wrap renderNodeDestructiveImpl in a try / catch so we can capture
// a component stack at the right place in the tree. We don't do this in renderNode
// becuase it is not called at every layer of the tree and we may lose frames
try {
return renderNodeDestructiveImpl(request, task, node);
} catch (x) {
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// This is a Wakable, noop
} else {
// This is an error, stash the component stack if it is null.
lastBoundaryErrorComponentStackDev =
lastBoundaryErrorComponentStackDev !== null
? lastBoundaryErrorComponentStackDev
: getCurrentStackInDEV();
}
// rethrow so normal suspense logic can handle thrown value accordingly
throw x;
}
} else {
return renderNodeDestructiveImpl(request, task, node);
}
}
// This function by it self renders a node and consumes the task by mutating it
// to update the current execution state.
function renderNodeDestructive(
function renderNodeDestructiveImpl(
request: Request,
task: Task,
node: ReactNodeList,
@@ -1197,7 +1270,27 @@ function renderNodeDestructive(
const lazyNode: LazyComponentType<any, any> = (node: any);
const payload = lazyNode._payload;
const init = lazyNode._init;
const resolvedNode = init(payload);
let resolvedNode;
if (__DEV__) {
try {
resolvedNode = init(payload);
} catch (x) {
if (
typeof x === 'object' &&
x !== null &&
typeof x.then === 'function'
) {
// this Lazy initializer is suspending. push a temporary frame onto the stack so it can be
// popped off in spawnNewSuspendedTask. This aligns stack behavior between Lazy in element position
// vs Component position. We do not want the frame for Errors so we exclusively do this in
// the wakeable branch
pushBuiltInComponentStackInDEV(task, 'Lazy');
}
throw x;
}
} else {
resolvedNode = init(payload);
}
renderNodeDestructive(request, task, resolvedNode);
return;
}
@@ -1395,13 +1488,17 @@ function erroredTask(
error: mixed,
) {
// Report the error to a global handler.
logRecoverableError(request, error);
const errorHash = logRecoverableError(request, error);
if (boundary === null) {
fatalError(request, error);
} else {
boundary.pendingTasks--;
if (!boundary.forceClientRender) {
boundary.forceClientRender = true;
boundary.errorHash = errorHash;
if (__DEV__) {
captureBoundaryErrorDetailsDev(boundary, error);
}
// Regardless of what happens next, this boundary won't be displayed,
// so we can flush it, if the parent already flushed.
@@ -1456,6 +1553,13 @@ function abortTask(task: Task): void {
if (!boundary.forceClientRender) {
boundary.forceClientRender = true;
const error = new Error(
'This Suspense boundary was aborted by the server',
);
boundary.errorHash = request.onError(error);
if (__DEV__) {
captureBoundaryErrorDetailsDev(boundary, error);
}
if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
}
@@ -1734,8 +1838,10 @@ function flushSegment(
writeStartClientRenderedSuspenseBoundary(
destination,
request.responseState,
boundary.errorHash,
boundary.errorMessage,
boundary.errorComponentStack,
);
// Flush the fallback.
flushSubtree(request, destination, segment);
@@ -1815,6 +1921,9 @@ function flushClientRenderedBoundary(
destination,
request.responseState,
boundary.id,
boundary.errorHash,
boundary.errorMessage,
boundary.errorComponentStack,
);
}
+2 -1
View File
@@ -416,5 +416,6 @@
"428": "useServerContext is only supported while rendering.",
"429": "ServerContext: %s already defined",
"430": "ServerContext can only have a value prop and children. Found: %s",
"431": "React elements are not allowed in ServerContext"
"431": "React elements are not allowed in ServerContext",
"432": "This Suspense boundary was aborted by the server"
}