diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 56ae7c8a09652..66ae2c25e6901 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -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 (
@@ -426,24 +466,32 @@ 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); }, }); }; + 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); - }, + 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(
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, + [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
); @@ -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', () => { , { - onError(x) { - loggedErrors.push(x); - }, + onError, }, ); pipe(writable); }); expect(loggedErrors).toEqual([]); + const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { 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(
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, + [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(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( + , + + { + 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.', + ], + ); + + // The client rendered HTML is now in place. + expect(getVisibleChildren(container)).toEqual(
Hello
); 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(); + controls = ReactDOMFizzServer.renderToPipeableStream(, {onError}); controls.pipe(writable); }); // 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); }, }); 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(
Loading...
); // 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( , { - 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, , { 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(
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, + [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, , { 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 = + '">