diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 3ced96af29564..12e107eb1c970 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -89,6 +89,34 @@ describe('ReactDOMFizzServer', () => {
});
});
+ function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
+ if (__DEV__) {
+ expect(errorsArr.map(error => normalizeCodeLocInfo(error))).toEqual(
+ toBeDevArr.map(error => {
+ 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(errorsArr).toEqual(toBeProdArr);
+ }
+ }
+
+ 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.
@@ -421,12 +449,13 @@ describe('ReactDOMFizzServer', () => {
}
let bootstrapped = false;
+ const errors = [];
window.__INIT__ = function() {
bootstrapped = true;
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
- Scheduler.unstable_yieldValue(error.message);
+ errors.push(error.message);
},
});
};
@@ -438,6 +467,7 @@ describe('ReactDOMFizzServer', () => {
bootstrapScriptContent: '__INIT__();',
onError(x) {
loggedErrors.push(x);
+ return 'Hash';
},
},
);
@@ -464,10 +494,17 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(
Loading...
);
// 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,
+ [
+ {
+ error: theError.message,
+ componentStack: componentStack(['Lazy', 'Suspense', 'div', 'App']),
+ },
+ ],
+ ['Hash'],
+ );
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(Hello
);
@@ -534,6 +571,7 @@ describe('ReactDOMFizzServer', () => {
{
onError(x) {
loggedErrors.push(x);
+ return 'hash';
},
},
);
@@ -541,10 +579,11 @@ describe('ReactDOMFizzServer', () => {
});
expect(loggedErrors).toEqual([]);
+ const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
- Scheduler.unstable_yieldValue(error.message);
+ errors.push(error.message);
},
});
Scheduler.unstable_flushAll();
@@ -565,10 +604,18 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(Loading...
);
// 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,
+ [
+ {
+ error: theError.message,
+ componentStack: componentStack(['div', 'App']),
+ },
+ ],
+ ['hash'],
+ );
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(Hello
);
@@ -852,10 +899,11 @@ describe('ReactDOMFizzServer', () => {
// We're still showing a fallback.
+ const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
- Scheduler.unstable_yieldValue(error.message);
+ errors.push(error.message);
},
});
Scheduler.unstable_flushAll();
@@ -869,10 +917,12 @@ 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'],
+ ['This Suspense boundary was aborted by the server'],
+ );
expect(getVisibleChildren(container)).toEqual(Loading...
);
// We now resolve it on the client.
@@ -1540,6 +1590,7 @@ describe('ReactDOMFizzServer', () => {
{
onError(x) {
loggedErrors.push(x);
+ return 'error hash';
},
},
);
@@ -1548,10 +1599,11 @@ describe('ReactDOMFizzServer', () => {
// We're still showing a fallback.
+ const errors = [];
// Attempt to hydrate the content.
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
- Scheduler.unstable_yieldValue(error.message);
+ errors.push(error.message);
},
});
Scheduler.unstable_flushAll();
@@ -1582,10 +1634,24 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(Loading...
);
// 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,
+ [
+ {
+ error: theError.message,
+ componentStack: componentStack([
+ 'AsyncText',
+ 'h1',
+ 'Suspense',
+ 'div',
+ 'Suspense',
+ 'App',
+ ]),
+ },
+ ],
+ ['error hash'],
+ );
// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
@@ -2205,11 +2271,10 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during render.
isClient = true;
+ const errors = [];
ReactDOMClient.hydrateRoot(container, , {
onRecoverableError(error) {
- Scheduler.unstable_yieldValue(
- 'Log recoverable error: ' + error.message,
- );
+ errors.push(error.message);
},
});
@@ -2217,6 +2282,8 @@ describe('ReactDOMFizzServer', () => {
// shouldn't be called.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual('Oops!');
+
+ expectErrors(errors, [], []);
},
);
diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js
index 8ded833556c92..22a2b43b23e6c 100644
--- a/packages/react-dom/src/client/ReactDOMHostConfig.js
+++ b/packages/react-dom/src/client/ReactDOMHostConfig.js
@@ -734,6 +734,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance) {
export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
return instance.data === SUSPENSE_FALLBACK_START_DATA;
}
+export function getSuspenseInstanceFallbackError(
+ instance: SuspenseInstance,
+): string {
+ return (instance: any).data2;
+}
export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
index 306724c448b0f..33dc3d6584171 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
@@ -43,7 +43,7 @@ type Options = {|
onShellReady?: () => void,
onShellError?: () => void,
onAllReady?: () => void,
- onError?: (error: mixed) => void,
+ onError?: (error: mixed) => string,
|};
type Controls = {|
diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
index a42cb3188722b..c5acd25792dc2 100644
--- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js
@@ -1681,7 +1681,7 @@ export function writeEndSegment(
// const SUSPENSE_PENDING_START_DATA = '$?';
// const SUSPENSE_FALLBACK_START_DATA = '$!';
//
-// function clientRenderBoundary(suspenseBoundaryID) {
+// function clientRenderBoundary(suspenseBoundaryID, errorMsg) {
// // Find the fallback's first element.
// const suspenseIdNode = document.getElementById(suspenseBoundaryID);
// if (!suspenseIdNode) {
@@ -1693,6 +1693,7 @@ export function writeEndSegment(
// const suspenseNode = suspenseIdNode.previousSibling;
// // Tag it to be client rendered.
// suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
+// suspenseNode.data2 = errorMsg || undefined;
// // Tell React to retry it if the parent already hydrated.
// if (suspenseNode._reactRetry) {
// suspenseNode._reactRetry();
@@ -1780,7 +1781,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(a,b){if(a=document.getElementById(a))a=a.previousSibling,a.data="$!",a.data2=b,a._reactRetry&&a._reactRetry()}';
const completeSegmentScript1Full = stringToPrecomputedChunk(
completeSegmentFunction + ';$RS("',
@@ -1850,15 +1851,17 @@ export function writeCompletedBoundaryInstruction(
}
const clientRenderScript1Full = stringToPrecomputedChunk(
- clientRenderFunction + ';$RX("',
+ clientRenderFunction + ";$RX('",
);
-const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
-const clientRenderScript2 = stringToPrecomputedChunk('")');
+const clientRenderScript1Partial = stringToPrecomputedChunk("$RX('");
+const clientRenderScript2 = stringToPrecomputedChunk("')");
+const clientRenderErrorScript1 = stringToPrecomputedChunk("','");
export function writeClientRenderBoundaryInstruction(
destination: Destination,
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
+ error: ?string,
): boolean {
writeChunk(destination, responseState.startInlineScript);
if (!responseState.sentClientRenderFunction) {
@@ -1877,5 +1880,9 @@ export function writeClientRenderBoundaryInstruction(
}
writeChunk(destination, boundaryID);
+ if (error) {
+ writeChunk(destination, clientRenderErrorScript1);
+ writeChunk(destination, error);
+ }
return writeChunkAndReturn(destination, clientRenderScript2);
}
diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
index e2b5557201637..2e681922ee8d5 100644
--- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
@@ -284,6 +284,8 @@ export function writeClientRenderBoundaryInstruction(
destination: Destination,
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
+ // TODO: encode error for native
+ error: ?string,
): boolean {
writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER);
return writeChunkAndReturn(destination, formatID(boundaryID));
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
index 4bae5d0b7b982..cd4e87bfd4159 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js
@@ -152,6 +152,7 @@ import {
shouldSetTextContent,
isSuspenseInstancePending,
isSuspenseInstanceFallback,
+ getSuspenseInstanceFallbackError,
registerSuspenseInstanceRetry,
supportsHydration,
isPrimaryRenderer,
@@ -2689,6 +2690,7 @@ 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 errorMsg = getSuspenseInstanceFallbackError(suspenseInstance);
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
@@ -2696,11 +2698,14 @@ function updateDehydratedSuspenseComponent(
// 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.',
- ),
+ errorMsg
+ ? // eslint-disable-next-line react-internal/prod-error-codes
+ new Error(errorMsg)
+ : new Error(
+ 'The server could not finish this Suspense boundary, likely ' +
+ 'due to an error during server rendering. Switched to ' +
+ 'client rendering.',
+ ),
);
}
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
index 583539dd08b52..0279e83b80cce 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js
@@ -152,6 +152,7 @@ import {
shouldSetTextContent,
isSuspenseInstancePending,
isSuspenseInstanceFallback,
+ getSuspenseInstanceFallbackError,
registerSuspenseInstanceRetry,
supportsHydration,
isPrimaryRenderer,
@@ -2689,6 +2690,7 @@ 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 errorMsg = getSuspenseInstanceFallbackError(suspenseInstance);
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
@@ -2696,11 +2698,14 @@ function updateDehydratedSuspenseComponent(
// 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.',
- ),
+ errorMsg
+ ? // eslint-disable-next-line react-internal/prod-error-codes
+ new Error(errorMsg)
+ : new Error(
+ 'The server could not finish this Suspense boundary, likely ' +
+ 'due to an error during server rendering. Switched to ' +
+ 'client rendering.',
+ ),
);
}
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index c09d21cb55cf5..6234a65b523d0 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -131,6 +131,8 @@ type LegacyContext = {
type SuspenseBoundary = {
id: SuspenseBoundaryID,
rootSegmentID: number,
+ error: ?string, // the error string if it errors
+ errorComponentStack?: string, // the component stack for the error in DEV
forceClientRender: boolean, // if it errors or infinitely suspends
parentFlushed: boolean,
pendingTasks: number, // when it reaches zero we can show this boundary's content
@@ -193,7 +195,9 @@ export opaque type Request = {
completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show.
partialBoundaries: Array, // 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,
@@ -226,6 +230,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 {}
@@ -235,7 +240,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),
@@ -306,6 +311,7 @@ function createSuspenseBoundary(
completedSegments: [],
byteSize: 0,
fallbackAbortableTasks,
+ error: null,
};
}
@@ -411,11 +417,19 @@ function popComponentStackInDEV(task: Task): void {
}
}
-function logRecoverableError(request: Request, error: mixed): void {
+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);
+ let errorMsg = onError(error);
+ if (__DEV__) {
+ const msg = error && error.message ? error.message : error;
+ errorMsg = JSON.stringify({
+ error: msg,
+ componentStack: getCurrentStackInDEV(),
+ });
+ }
+ return errorMsg;
}
function fatalError(request: Request, error: mixed): void {
@@ -498,8 +512,9 @@ function renderSuspenseBoundary(
}
} catch (error) {
contentRootSegment.status = ERRORED;
- logRecoverableError(request, error);
newBoundary.forceClientRender = true;
+ newBoundary.error = logRecoverableError(request, 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.
@@ -1351,13 +1366,14 @@ function erroredTask(
error: mixed,
) {
// Report the error to a global handler.
- logRecoverableError(request, error);
+ const errorMsg = logRecoverableError(request, error);
if (boundary === null) {
fatalError(request, error);
} else {
boundary.pendingTasks--;
if (!boundary.forceClientRender) {
boundary.forceClientRender = true;
+ boundary.error = errorMsg;
// Regardless of what happens next, this boundary won't be displayed,
// so we can flush it, if the parent already flushed.
@@ -1412,6 +1428,7 @@ function abortTask(task: Task): void {
if (!boundary.forceClientRender) {
boundary.forceClientRender = true;
+ boundary.error = 'This Suspense boundary was aborted by the server';
if (boundary.parentFlushed) {
request.clientRenderedBoundaries.push(boundary);
}
@@ -1762,6 +1779,7 @@ function flushClientRenderedBoundary(
destination,
request.responseState,
boundary.id,
+ boundary.error,
);
}
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index 92d3bfd128c8a..7a93b9a0ac641 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -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"
}