Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onRecoverableError option to hydrateRoot, createRoot #23207

Merged
merged 8 commits into from
Feb 4, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/react-art/src/ReactARTHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,7 @@ export function preparePortalMount(portalInstance: any): void {
export function detachDeletedInstance(node: Instance): void {
// noop
}

export function logRecoverableError(error) {
// noop
}
118 changes: 109 additions & 9 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1723,12 +1723,22 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
},
});

if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(() => {
// The first paint switches to client rendering due to mismatch
expect(Scheduler).toFlushUntilNextPaint(['client']);
expect(Scheduler).toFlushUntilNextPaint([
'client',
'Log recoverable error: An error occurred during hydration. ' +
'The server HTML was replaced with client content',
]);
}).toErrorDev(
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
{withoutStack: true},
Expand Down Expand Up @@ -1805,13 +1815,23 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toHaveYielded(['server']);

ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + 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']);
expect(Scheduler).toFlushUntilNextPaint([
'client',
'Log recoverable error: An error occurred during hydration. ' +
'The server HTML was replaced with client content',
]);
}).toErrorDev(
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
{withoutStack: true},
Expand Down Expand Up @@ -1897,9 +1917,15 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});

expect(Scheduler).toFlushAndYield(['Yay!']);
// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
Expand Down Expand Up @@ -1975,8 +2001,16 @@ describe('ReactDOMFizzServer', () => {

// Hydrate the tree. Child will throw during render.
isClient = true;
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
},
});

// Because we failed to recover from the error, onRecoverableError
// shouldn't be called.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual('Oops!');
},
Expand Down Expand Up @@ -2049,9 +2083,15 @@ describe('ReactDOMFizzServer', () => {
// Hydrate the tree. Child will throw during hydration, but not when it
// falls back to client rendering.
isClient = true;
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});

expect(Scheduler).toFlushAndYield([]);
// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
expect(Scheduler).toFlushAndYield(['Hydration error']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
Expand Down Expand Up @@ -2081,4 +2121,64 @@ describe('ReactDOMFizzServer', () => {
expect(span3Ref.current).toBe(span3);
},
);

it('logs regular (non-hydration) errors when the UI recovers', async () => {
let shouldThrow = true;

function A() {
if (shouldThrow) {
Scheduler.unstable_yieldValue('Oops!');
throw new Error('Oops!');
}
Scheduler.unstable_yieldValue('A');
return 'A';
}

function B() {
Scheduler.unstable_yieldValue('B');
return 'B';
}

function App() {
return (
<>
<A />
<B />
</>
);
}

const root = ReactDOM.createRoot(container, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Logged a recoverable error: ' + error.message,
);
},
});
React.startTransition(() => {
root.render(<App />);
});

// Partially render A, but yield before the render has finished
expect(Scheduler).toFlushAndYieldThrough(['Oops!', 'Oops!']);

// React will try rendering again synchronously. During the retry, A will
// not throw. This simulates a concurrent data race that is fixed by
// blocking the main thread.
shouldThrow = false;
expect(Scheduler).toFlushAndYield([
// Finish initial render attempt
'B',

// Render again, synchronously
'A',
'B',

// Log the error
'Logged a recoverable error: Oops!',
]);

// UI looks normal
expect(container.textContent).toEqual('AB');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ describe('ReactDOMServerPartialHydration', () => {
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
Scheduler.unstable_flushAll();
} else {
Expand Down Expand Up @@ -290,7 +294,11 @@ describe('ReactDOMServerPartialHydration', () => {
suspend = true;
client = true;

ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
expect(Scheduler).toFlushAndYield([
'Suspend',
'Component',
Expand All @@ -316,12 +324,16 @@ describe('ReactDOMServerPartialHydration', () => {
'Component',
'Component',
'Component',

// second pass as client render
'Hello',
'Component',
'Component',
'Component',
'Component',

// Hydration mismatch is logged
'An error occurred during hydration. The server HTML was replaced with client content',
]);

// Client rendered - suspense comment nodes removed
Expand Down Expand Up @@ -573,9 +585,19 @@ describe('ReactDOMServerPartialHydration', () => {

expect(() => {
act(() => {
ReactDOM.hydrateRoot(container, <App hasB={false} />);
ReactDOM.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(error.message);
},
});
});
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
expect(Scheduler).toHaveYielded([
'An error occurred during hydration. The server HTML was replaced ' +
'with client content',
]);
}

expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
Expand Down Expand Up @@ -2997,7 +3019,13 @@ describe('ReactDOMServerPartialHydration', () => {
const span = container.getElementsByTagName('span')[0];
expect(span.innerHTML).toBe('Hidden child');

ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
},
});

Scheduler.unstable_flushAll();
expect(ref.current).toBe(span);
Expand Down Expand Up @@ -3142,13 +3170,27 @@ describe('ReactDOMServerPartialHydration', () => {

expect(() => {
act(() => {
ReactDOM.hydrateRoot(container, <App />);
ReactDOM.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Log recoverable error: ' + error.message,
);
},
});
});
}).toErrorDev(
'Warning: An error occurred during hydration. ' +
'The server HTML was replaced with client content in <div>.',
{withoutStack: true},
);
expect(Scheduler).toHaveYielded([
'Log recoverable error: An error occurred during hydration. The server ' +
'HTML was replaced with client content',
// TODO: There were multiple mismatches in a single container. Should
// we attempt to de-dupe them?
'Log recoverable error: An error occurred during hydration. The server ' +
'HTML was replaced with client content',
]);

// We show fallback state when mismatch happens at root
expect(container.innerHTML).toEqual(
Expand Down
10 changes: 10 additions & 0 deletions packages/react-dom/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,14 @@ export function getCurrentEventPriority(): * {
return getEventPriority(currentEvent.type);
}

export function logRecoverableError(error: mixed): void {
// Default behavior is to rethrow the error in a separate task. This will
// trigger a browser error event.
queueMicrotask(() => {
throw error;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any worries about the Safari bug that can execute this on unclean stack if iframe is added?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sigh. Yeah.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really know what the workaround is, though, other than to avoid queueMicrotask. I'm tempted to leave it as-is and leave it to Safari to fix their shit.

Copy link
Collaborator

@sebmarkbage sebmarkbage Feb 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a user space one. It doesn't catch on break etc. but gives you something in the DEV console and in prod.

console.error(...);
window.dispatchEvent(new ErrorEvent(...));

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is one of those options better than the other?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that queueMicrotask doesn't actually solve the problem, does it? Because the rethrown error will be logged before the microtask. So in the default case, the order is still hard error followed by recovered error. So there's no difference from postTask.

Where as the user space log + dispatch would be recovered error followed by hard error.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have a recovered error and then a useLayoutEffect or anything causing a second commit in the same task, that then errors because the recovering didn't quite work. Then recovered errors should ideally come first.

Copy link
Collaborator Author

@acdlite acdlite Feb 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah when we were discussing error ordering I was thinking about other errors that happen in subsequent events, not the hard error that we throw at the top when there's no error boundary. (I don't think about those usually because I assume most apps have a top-level error boundary.) That makes sense.

});
}

export const isPrimaryRenderer = true;
export const warnsIfNotActing = true;
// This initialization code may run even on server environments
Expand Down Expand Up @@ -1070,6 +1078,8 @@ export function didNotFindHydratableSuspenseInstance(

export function errorHydratingContainer(parentContainer: Container): void {
if (__DEV__) {
// TODO: This gets logged by onRecoverableError, too, so we should be
// able to remove it.
console.error(
'An error occurred during hydration. The server HTML was replaced with client content in <%s>.',
parentContainer.nodeName.toLowerCase(),
Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/src/client/ReactDOMLegacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function legacyCreateRootFromDOMContainer(
false, // isStrictMode
false, // concurrentUpdatesByDefaultOverride,
'', // identifierPrefix
null,
);
markContainerAsRoot(root.current, container);

Expand Down
12 changes: 12 additions & 0 deletions packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type CreateRootOptions = {
unstable_strictMode?: boolean,
unstable_concurrentUpdatesByDefault?: boolean,
identifierPrefix?: string,
onRecoverableError?: (error: mixed) => void,
...
};

Expand All @@ -36,6 +37,7 @@ export type HydrateRootOptions = {
unstable_strictMode?: boolean,
unstable_concurrentUpdatesByDefault?: boolean,
identifierPrefix?: string,
onRecoverableError?: (error: mixed) => void,
...
};

Expand Down Expand Up @@ -143,6 +145,7 @@ export function createRoot(
let isStrictMode = false;
let concurrentUpdatesByDefaultOverride = false;
let identifierPrefix = '';
let onRecoverableError = null;
if (options !== null && options !== undefined) {
if (__DEV__) {
if ((options: any).hydrate) {
Expand All @@ -163,6 +166,9 @@ export function createRoot(
if (options.identifierPrefix !== undefined) {
identifierPrefix = options.identifierPrefix;
}
if (options.onRecoverableError !== undefined) {
onRecoverableError = options.onRecoverableError;
}
}

const root = createContainer(
Expand All @@ -173,6 +179,7 @@ export function createRoot(
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onRecoverableError,
);
markContainerAsRoot(root.current, container);

Expand Down Expand Up @@ -213,6 +220,7 @@ export function hydrateRoot(
let isStrictMode = false;
let concurrentUpdatesByDefaultOverride = false;
let identifierPrefix = '';
let onRecoverableError = null;
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
Expand All @@ -226,6 +234,9 @@ export function hydrateRoot(
if (options.identifierPrefix !== undefined) {
identifierPrefix = options.identifierPrefix;
}
if (options.onRecoverableError !== undefined) {
onRecoverableError = options.onRecoverableError;
}
}

const root = createContainer(
Expand All @@ -236,6 +247,7 @@ export function hydrateRoot(
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onRecoverableError,
);
markContainerAsRoot(root.current, container);
// This can't be a comment node since hydration doesn't work on comment nodes anyway.
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-renderer/src/ReactFabric.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ function render(
false,
null,
'',
null,
);
roots.set(containerTag, root);
}
Expand Down
Loading