Skip to content

Commit

Permalink
Send errors down to client
Browse files Browse the repository at this point in the history
  • Loading branch information
salazarm committed Mar 29, 2022
1 parent 645ec5d commit 9533ca2
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 70 deletions.
168 changes: 109 additions & 59 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -421,12 +449,13 @@ 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.message);
},
});
};
Expand Down Expand Up @@ -464,10 +493,15 @@ 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([]);
if (__DEV__) {
expectErrors(errors, [
{
error: theError.message,
componentStack: componentStack(['Lazy', 'Suspense', 'div', 'App']),
},
]);
}

// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
Expand Down Expand Up @@ -535,16 +569,20 @@ describe('ReactDOMFizzServer', () => {
onError(x) {
loggedErrors.push(x);
},
getErrorHash(x) {
return 'hash';
},
},
);
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.message);
},
});
Scheduler.unstable_flushAll();
Expand All @@ -565,10 +603,18 @@ 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,
[
{
error: theError.message,
componentStack: componentStack(['div', 'App']),
},
],
['hash'],
);

// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
Expand Down Expand Up @@ -852,10 +898,11 @@ describe('ReactDOMFizzServer', () => {

// 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.message);
},
});
Scheduler.unstable_flushAll();
Expand All @@ -869,10 +916,20 @@ 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,
[
{
error: 'This Suspense boundary was aborted by the server',
componentStack: '',
},
],
// No getErrorHash function was passed so we default to this message
[
'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.
Expand Down Expand Up @@ -1541,17 +1598,21 @@ describe('ReactDOMFizzServer', () => {
onError(x) {
loggedErrors.push(x);
},
getErrorHash(x) {
return 'error hash';
},
},
);
controls.pipe(writable);
});

// 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.message);
},
});
Scheduler.unstable_flushAll();
Expand Down Expand Up @@ -1582,10 +1643,24 @@ 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,
[
{
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(
Expand Down Expand Up @@ -1824,25 +1899,17 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
errors.push(error.message);
},
});

if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(() => {
// The first paint switches to client rendering due to mismatch
expect(Scheduler).toFlushUntilNextPaint([
'client',
'Log recoverable error: Hydration failed because the initial ' +
'UI does not match what was rendered on the server.',
'Log recoverable error: There was an error while hydrating. ' +
'Because the error happened outside of a Suspense boundary, the ' +
'entire root will switch to client rendering.',
]);
expect(Scheduler).toFlushUntilNextPaint(['client']);
}).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
Expand Down Expand Up @@ -1924,26 +1991,18 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
errors.push(error.message);
},
});

if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
// The first paint uses the client due to mismatch forcing client render
expect(() => {
// The first paint switches to client rendering due to mismatch
expect(Scheduler).toFlushUntilNextPaint([
'client',
'Log recoverable error: Hydration failed because the initial ' +
'UI does not match what was rendered on the server.',
'Log recoverable error: There was an error while hydrating. ' +
'Because the error happened outside of a Suspense boundary, the ' +
'entire root will switch to client rendering.',
]);
expect(Scheduler).toFlushUntilNextPaint(['client']);
}).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
Expand Down Expand Up @@ -2026,22 +2085,17 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error.message);
},
});

// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
expect(() => {
expect(Scheduler).toFlushAndYield([
'Yay!',
'Hydration error',
'There was an error while hydrating. Because the error happened ' +
'outside of a Suspense boundary, the entire root will switch ' +
'to client rendering.',
]);
expect(Scheduler).toFlushAndYield(['Yay!']);
}).toErrorDev(
'An error occurred during hydration. The server HTML was replaced',
{withoutStack: true},
Expand Down Expand Up @@ -2117,19 +2171,16 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
errors.push(error.message);
},
});

// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
expect(Scheduler).toFlushAndYield([
'Yay!',
'Hydration error',
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield(['Yay!']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
Expand Down Expand Up @@ -2205,11 +2256,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);
},
});

Expand Down
5 changes: 5 additions & 0 deletions packages/react-dom/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Options = {|
onShellError?: () => void,
onAllReady?: () => void,
onError?: (error: mixed) => void,
getErrorHash?: (error: mixed) => string,
|};

type Controls = {|
Expand All @@ -70,6 +71,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
options ? options.onShellReady : undefined,
options ? options.onShellError : undefined,
undefined,
options ? options.getErrorHash : undefined,
);
}

Expand Down

0 comments on commit 9533ca2

Please sign in to comment.