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" }