diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index e519d92691ead..1710161227302 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -90,32 +90,46 @@ 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(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; - }), + 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(errorsArr).toEqual(toBeProdArr); + expect(mappedErrows).toEqual(toBeProdArr); } } - function componentStack(components) { - return components - .map(component => `\n in ${component} (at **)`) - .join(''); - } + // @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(); @@ -436,8 +450,6 @@ describe('ReactDOMFizzServer', () => { }); }); - const loggedErrors = []; - function App({isClient}) { return (
@@ -455,20 +467,26 @@ describe('ReactDOMFizzServer', () => { // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - errors.push(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( , { bootstrapScriptContent: '__INIT__();', - onError(x) { - loggedErrors.push(x); - return 'Hash'; - }, + onError, }, ); pipe(writable); @@ -483,7 +501,6 @@ describe('ReactDOMFizzServer', () => { expect(loggedErrors).toEqual([]); - const theError = new Error('Test'); await act(async () => { rejectComponent(theError); }); @@ -497,13 +514,10 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); expectErrors( errors, + [theError.message], [ - { - error: theError.message, - componentStack: componentStack(['Lazy', 'Suspense', 'div', 'App']), - }, + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', ], - ['Hash'], ); // The client rendered HTML is now in place. @@ -552,7 +566,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 ( @@ -569,10 +590,7 @@ describe('ReactDOMFizzServer', () => { , { - onError(x) { - loggedErrors.push(x); - return 'hash'; - }, + onError, }, ); pipe(writable); @@ -583,7 +601,7 @@ describe('ReactDOMFizzServer', () => { // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - errors.push(error.message); + errors.push(error); }, }); Scheduler.unstable_flushAll(); @@ -593,7 +611,6 @@ describe('ReactDOMFizzServer', () => { expect(loggedErrors).toEqual([]); - const theError = new Error('Test'); await act(async () => { rejectElement(theError); }); @@ -608,18 +625,157 @@ describe('ReactDOMFizzServer', () => { 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(
Hello
); + + 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 {children}; + } + return children; + } + + const theError = new Error('uh oh'); + + function Erroring({isClient}) { + if (isClient) { + return 'Hello World'; + } + throw theError; + } + + function App({isClient}) { + return ( +
+ loading...}> + + +
+ ); + } + + 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( + , + + { + onError, + }, + ); + pipe(writable); + }); + expect(loggedErrors).toEqual([theError]); + + const errors = []; + // Attempt to hydrate the content. + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error); + }, + }); + Scheduler.unstable_flushAll(); + + expect(getVisibleChildren(container)).toEqual(
Hello World
); + + 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 ( +
+ }> + {isClient ? : } + +
+ ); + } + + 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( + , + { - error: theError.message, - componentStack: componentStack(['div', 'App']), + onError, }, + ); + pipe(writable); + }); + expect(loggedErrors).toEqual([]); + + const errors = []; + // Attempt to hydrate the content. + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + errors.push(error); + }, + }); + Scheduler.unstable_flushAll(); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await act(async () => { + rejectComponent(theError); + }); + + expect(loggedErrors).toEqual([theError]); + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + // 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.', ], - ['hash'], ); // The client rendered HTML is now in place. expect(getVisibleChildren(container)).toEqual(
Hello
); - expect(loggedErrors).toEqual([theError]); }); @@ -891,9 +1047,15 @@ describe('ReactDOMFizzServer', () => { ); } + const loggedErrors = []; + function onError(error) { + loggedErrors.push(error); + return `Hash of (${error.message})`; + } + let controls; await act(async () => { - controls = ReactDOMFizzServer.renderToPipeableStream(); + controls = ReactDOMFizzServer.renderToPipeableStream(, {onError}); controls.pipe(writable); }); @@ -903,7 +1065,7 @@ describe('ReactDOMFizzServer', () => { // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - errors.push(error.message); + errors.push(error); }, }); Scheduler.unstable_flushAll(); @@ -921,7 +1083,9 @@ describe('ReactDOMFizzServer', () => { expectErrors( errors, ['This Suspense boundary was aborted by the server'], - ['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(
Loading...
); @@ -1580,17 +1744,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( , { - onError(x) { - loggedErrors.push(x); - return 'error hash'; - }, + onError, }, ); controls.pipe(writable); @@ -1602,7 +1771,7 @@ describe('ReactDOMFizzServer', () => { // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - errors.push(error.message); + errors.push(error); }, }); Scheduler.unstable_flushAll(); @@ -1612,7 +1781,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); @@ -1636,20 +1804,10 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); expectErrors( errors, + [theError.message], [ - { - error: theError.message, - componentStack: componentStack([ - 'AsyncText', - 'h1', - 'Suspense', - 'div', - 'Suspense', - 'App', - ]), - }, + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', ], - ['error hash'], ); // The client rendered HTML is now in place. @@ -2856,6 +3014,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 = + '">