Skip to content

Commit 848e802

Browse files
authored
Add onRecoverableError option to hydrateRoot, createRoot (#23207)
* [RFC] Add onHydrationError option to hydrateRoot This is not the final API but I'm pushing it for discussion purposes. When an error is thrown during hydration, we fallback to client rendering, without triggering an error boundary. This is good because, in many cases, the UI will recover and the user won't even notice that something has gone wrong behind the scenes. However, we shouldn't recover from these errors silently, because the underlying cause might be pretty serious. Server-client mismatches are not supposed to happen, even if UI doesn't break from the users perspective. Ignoring them could lead to worse problems later. De-opting from server to client rendering could also be a significant performance regression, depending on the scope of the UI it affects. So we need a way to log when hydration errors occur. This adds a new option for `hydrateRoot` called `onHydrationError`. It's symmetrical to the server renderer's `onError` option, and serves the same purpose. When no option is provided, the default behavior is to schedule a browser task and rethrow the error. This will trigger the normal browser behavior for errors, including dispatching an error event. If the app already has error monitoring, this likely will just work as expected without additional configuration. However, we can also expose additional metadata about these errors, like which Suspense boundaries were affected by the de-opt to client rendering. (I have not exposed any metadata in this commit; API needs more design work.) There are other situations besides hydration where we recover from an error without surfacing it to the user, or notifying an error boundary. For example, if an error occurs during a concurrent render, it could be due to a data race, so we try again synchronously in case that fixes it. We should probably expose a way to log these types of errors, too. (Also not implemented in this commit.) * Log all recoverable errors This expands the scope of onHydrationError to include all errors that are not surfaced to the UI (an error boundary). In addition to errors that occur during hydration, this also includes errors that recoverable by de-opting to synchronous rendering. Typically (or really, by definition) these errors are the result of a concurrent data race; blocking the main thread fixes them by prevents subsequent races. The logic for de-opting to synchronous rendering already existed. The only thing that has changed is that we now log the errors instead of silently proceeding. The logging API has been renamed from onHydrationError to onRecoverableError. * Don't log recoverable errors until commit phase If the render is interrupted and restarts, we don't want to log the errors multiple times. This change only affects errors that are recovered by de-opting to synchronous rendering; we'll have to do something else for errors during hydration, since they use a different recovery path. * Only log hydration error if client render succeeds Similar to previous step. When an error occurs during hydration, we only want to log it if falling back to client rendering _succeeds_. If client rendering fails, the error will get reported to the nearest error boundary, so there's no need for a duplicate log. To implement this, I added a list of errors to the hydration context. If the Suspense boundary successfully completes, they are added to the main recoverable errors queue (the one I added in the previous step.) * Log error with queueMicrotask instead of Scheduler If onRecoverableError is not provided, we default to rethrowing the error in a separate task. Originally, I scheduled the task with idle priority, but @sebmarkbage made the good point that if there are multiple errors logs, we want to preserve the original order. So I've switched it to a microtask. The priority can be lowered in userspace by scheduling an additional task inside onRecoverableError. * Only use host config method for default behavior Redefines the contract of the host config's logRecoverableError method to be a default implementation for onRecoverableError if a user-provided one is not provided when the root is created. * Log with reportError instead of rethrowing In modern browsers, reportError will dispatch an error event, emulating an uncaught JavaScript error. We can do this instead of rethrowing recoverable errors in a microtask, which is nice because it avoids any subtle ordering issues. In older browsers and test environments, we'll fall back to console.error. * Naming nits queueRecoverableHydrationErrors -> upgradeHydrationErrorsToRecoverable
1 parent 5318971 commit 848e802

39 files changed

+530
-62
lines changed

packages/react-art/src/ReactARTHostConfig.js

+4
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,7 @@ export function preparePortalMount(portalInstance: any): void {
451451
export function detachDeletedInstance(node: Instance): void {
452452
// noop
453453
}
454+
455+
export function logRecoverableError(error) {
456+
// noop
457+
}

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+109-9
Original file line numberDiff line numberDiff line change
@@ -1723,12 +1723,22 @@ describe('ReactDOMFizzServer', () => {
17231723
});
17241724
expect(Scheduler).toHaveYielded(['server']);
17251725

1726-
ReactDOM.hydrateRoot(container, <App />);
1726+
ReactDOM.hydrateRoot(container, <App />, {
1727+
onRecoverableError(error) {
1728+
Scheduler.unstable_yieldValue(
1729+
'Log recoverable error: ' + error.message,
1730+
);
1731+
},
1732+
});
17271733

17281734
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
17291735
expect(() => {
17301736
// The first paint switches to client rendering due to mismatch
1731-
expect(Scheduler).toFlushUntilNextPaint(['client']);
1737+
expect(Scheduler).toFlushUntilNextPaint([
1738+
'client',
1739+
'Log recoverable error: An error occurred during hydration. ' +
1740+
'The server HTML was replaced with client content',
1741+
]);
17321742
}).toErrorDev(
17331743
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
17341744
{withoutStack: true},
@@ -1805,13 +1815,23 @@ describe('ReactDOMFizzServer', () => {
18051815
});
18061816
expect(Scheduler).toHaveYielded(['server']);
18071817

1808-
ReactDOM.hydrateRoot(container, <App />);
1818+
ReactDOM.hydrateRoot(container, <App />, {
1819+
onRecoverableError(error) {
1820+
Scheduler.unstable_yieldValue(
1821+
'Log recoverable error: ' + error.message,
1822+
);
1823+
},
1824+
});
18091825

18101826
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
18111827
// The first paint uses the client due to mismatch forcing client render
18121828
expect(() => {
18131829
// The first paint switches to client rendering due to mismatch
1814-
expect(Scheduler).toFlushUntilNextPaint(['client']);
1830+
expect(Scheduler).toFlushUntilNextPaint([
1831+
'client',
1832+
'Log recoverable error: An error occurred during hydration. ' +
1833+
'The server HTML was replaced with client content',
1834+
]);
18151835
}).toErrorDev(
18161836
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
18171837
{withoutStack: true},
@@ -1897,9 +1917,15 @@ describe('ReactDOMFizzServer', () => {
18971917
// Hydrate the tree. Child will throw during hydration, but not when it
18981918
// falls back to client rendering.
18991919
isClient = true;
1900-
ReactDOM.hydrateRoot(container, <App />);
1920+
ReactDOM.hydrateRoot(container, <App />, {
1921+
onRecoverableError(error) {
1922+
Scheduler.unstable_yieldValue(error.message);
1923+
},
1924+
});
19011925

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

19762002
// Hydrate the tree. Child will throw during render.
19772003
isClient = true;
1978-
ReactDOM.hydrateRoot(container, <App />);
2004+
ReactDOM.hydrateRoot(container, <App />, {
2005+
onRecoverableError(error) {
2006+
Scheduler.unstable_yieldValue(
2007+
'Log recoverable error: ' + error.message,
2008+
);
2009+
},
2010+
});
19792011

2012+
// Because we failed to recover from the error, onRecoverableError
2013+
// shouldn't be called.
19802014
expect(Scheduler).toFlushAndYield([]);
19812015
expect(getVisibleChildren(container)).toEqual('Oops!');
19822016
},
@@ -2049,9 +2083,15 @@ describe('ReactDOMFizzServer', () => {
20492083
// Hydrate the tree. Child will throw during hydration, but not when it
20502084
// falls back to client rendering.
20512085
isClient = true;
2052-
ReactDOM.hydrateRoot(container, <App />);
2086+
ReactDOM.hydrateRoot(container, <App />, {
2087+
onRecoverableError(error) {
2088+
Scheduler.unstable_yieldValue(error.message);
2089+
},
2090+
});
20532091

2054-
expect(Scheduler).toFlushAndYield([]);
2092+
// An error logged but instead of surfacing it to the UI, we switched
2093+
// to client rendering.
2094+
expect(Scheduler).toFlushAndYield(['Hydration error']);
20552095
expect(getVisibleChildren(container)).toEqual(
20562096
<div>
20572097
<span />
@@ -2081,4 +2121,64 @@ describe('ReactDOMFizzServer', () => {
20812121
expect(span3Ref.current).toBe(span3);
20822122
},
20832123
);
2124+
2125+
it('logs regular (non-hydration) errors when the UI recovers', async () => {
2126+
let shouldThrow = true;
2127+
2128+
function A() {
2129+
if (shouldThrow) {
2130+
Scheduler.unstable_yieldValue('Oops!');
2131+
throw new Error('Oops!');
2132+
}
2133+
Scheduler.unstable_yieldValue('A');
2134+
return 'A';
2135+
}
2136+
2137+
function B() {
2138+
Scheduler.unstable_yieldValue('B');
2139+
return 'B';
2140+
}
2141+
2142+
function App() {
2143+
return (
2144+
<>
2145+
<A />
2146+
<B />
2147+
</>
2148+
);
2149+
}
2150+
2151+
const root = ReactDOM.createRoot(container, {
2152+
onRecoverableError(error) {
2153+
Scheduler.unstable_yieldValue(
2154+
'Logged a recoverable error: ' + error.message,
2155+
);
2156+
},
2157+
});
2158+
React.startTransition(() => {
2159+
root.render(<App />);
2160+
});
2161+
2162+
// Partially render A, but yield before the render has finished
2163+
expect(Scheduler).toFlushAndYieldThrough(['Oops!', 'Oops!']);
2164+
2165+
// React will try rendering again synchronously. During the retry, A will
2166+
// not throw. This simulates a concurrent data race that is fixed by
2167+
// blocking the main thread.
2168+
shouldThrow = false;
2169+
expect(Scheduler).toFlushAndYield([
2170+
// Finish initial render attempt
2171+
'B',
2172+
2173+
// Render again, synchronously
2174+
'A',
2175+
'B',
2176+
2177+
// Log the error
2178+
'Logged a recoverable error: Oops!',
2179+
]);
2180+
2181+
// UI looks normal
2182+
expect(container.textContent).toEqual('AB');
2183+
});
20842184
});

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

+47-5
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,11 @@ describe('ReactDOMServerPartialHydration', () => {
208208
// On the client we don't have all data yet but we want to start
209209
// hydrating anyway.
210210
suspend = true;
211-
ReactDOM.hydrateRoot(container, <App />);
211+
ReactDOM.hydrateRoot(container, <App />, {
212+
onRecoverableError(error) {
213+
Scheduler.unstable_yieldValue(error.message);
214+
},
215+
});
212216
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
213217
Scheduler.unstable_flushAll();
214218
} else {
@@ -290,7 +294,11 @@ describe('ReactDOMServerPartialHydration', () => {
290294
suspend = true;
291295
client = true;
292296

293-
ReactDOM.hydrateRoot(container, <App />);
297+
ReactDOM.hydrateRoot(container, <App />, {
298+
onRecoverableError(error) {
299+
Scheduler.unstable_yieldValue(error.message);
300+
},
301+
});
294302
expect(Scheduler).toFlushAndYield([
295303
'Suspend',
296304
'Component',
@@ -316,12 +324,16 @@ describe('ReactDOMServerPartialHydration', () => {
316324
'Component',
317325
'Component',
318326
'Component',
327+
319328
// second pass as client render
320329
'Hello',
321330
'Component',
322331
'Component',
323332
'Component',
324333
'Component',
334+
335+
// Hydration mismatch is logged
336+
'An error occurred during hydration. The server HTML was replaced with client content',
325337
]);
326338

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

574586
expect(() => {
575587
act(() => {
576-
ReactDOM.hydrateRoot(container, <App hasB={false} />);
588+
ReactDOM.hydrateRoot(container, <App hasB={false} />, {
589+
onRecoverableError(error) {
590+
Scheduler.unstable_yieldValue(error.message);
591+
},
592+
});
577593
});
578594
}).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
595+
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
596+
expect(Scheduler).toHaveYielded([
597+
'An error occurred during hydration. The server HTML was replaced ' +
598+
'with client content',
599+
]);
600+
}
579601

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

3000-
ReactDOM.hydrateRoot(container, <App />);
3022+
ReactDOM.hydrateRoot(container, <App />, {
3023+
onRecoverableError(error) {
3024+
Scheduler.unstable_yieldValue(
3025+
'Log recoverable error: ' + error.message,
3026+
);
3027+
},
3028+
});
30013029

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

31433171
expect(() => {
31443172
act(() => {
3145-
ReactDOM.hydrateRoot(container, <App />);
3173+
ReactDOM.hydrateRoot(container, <App />, {
3174+
onRecoverableError(error) {
3175+
Scheduler.unstable_yieldValue(
3176+
'Log recoverable error: ' + error.message,
3177+
);
3178+
},
3179+
});
31463180
});
31473181
}).toErrorDev(
31483182
'Warning: An error occurred during hydration. ' +
31493183
'The server HTML was replaced with client content in <div>.',
31503184
{withoutStack: true},
31513185
);
3186+
expect(Scheduler).toHaveYielded([
3187+
'Log recoverable error: An error occurred during hydration. The server ' +
3188+
'HTML was replaced with client content',
3189+
// TODO: There were multiple mismatches in a single container. Should
3190+
// we attempt to de-dupe them?
3191+
'Log recoverable error: An error occurred during hydration. The server ' +
3192+
'HTML was replaced with client content',
3193+
]);
31523194

31533195
// We show fallback state when mismatch happens at root
31543196
expect(container.innerHTML).toEqual(

packages/react-dom/src/client/ReactDOMHostConfig.js

+14
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,18 @@ export function getCurrentEventPriority(): * {
374374
return getEventPriority(currentEvent.type);
375375
}
376376

377+
/* global reportError */
378+
export const logRecoverableError =
379+
typeof reportError === 'function'
380+
? // In modern browsers, reportError will dispatch an error event,
381+
// emulating an uncaught JavaScript error.
382+
reportError
383+
: (error: mixed) => {
384+
// In older browsers and test environments, fallback to console.error.
385+
// eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args
386+
console.error(error);
387+
};
388+
377389
export const isPrimaryRenderer = true;
378390
export const warnsIfNotActing = true;
379391
// This initialization code may run even on server environments
@@ -1070,6 +1082,8 @@ export function didNotFindHydratableSuspenseInstance(
10701082

10711083
export function errorHydratingContainer(parentContainer: Container): void {
10721084
if (__DEV__) {
1085+
// TODO: This gets logged by onRecoverableError, too, so we should be
1086+
// able to remove it.
10731087
console.error(
10741088
'An error occurred during hydration. The server HTML was replaced with client content in <%s>.',
10751089
parentContainer.nodeName.toLowerCase(),

packages/react-dom/src/client/ReactDOMLegacy.js

+1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ function legacyCreateRootFromDOMContainer(
122122
false, // isStrictMode
123123
false, // concurrentUpdatesByDefaultOverride,
124124
'', // identifierPrefix
125+
null,
125126
);
126127
markContainerAsRoot(root.current, container);
127128

packages/react-dom/src/client/ReactDOMRoot.js

+12
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type CreateRootOptions = {
2424
unstable_strictMode?: boolean,
2525
unstable_concurrentUpdatesByDefault?: boolean,
2626
identifierPrefix?: string,
27+
onRecoverableError?: (error: mixed) => void,
2728
...
2829
};
2930

@@ -36,6 +37,7 @@ export type HydrateRootOptions = {
3637
unstable_strictMode?: boolean,
3738
unstable_concurrentUpdatesByDefault?: boolean,
3839
identifierPrefix?: string,
40+
onRecoverableError?: (error: mixed) => void,
3941
...
4042
};
4143

@@ -143,6 +145,7 @@ export function createRoot(
143145
let isStrictMode = false;
144146
let concurrentUpdatesByDefaultOverride = false;
145147
let identifierPrefix = '';
148+
let onRecoverableError = null;
146149
if (options !== null && options !== undefined) {
147150
if (__DEV__) {
148151
if ((options: any).hydrate) {
@@ -163,6 +166,9 @@ export function createRoot(
163166
if (options.identifierPrefix !== undefined) {
164167
identifierPrefix = options.identifierPrefix;
165168
}
169+
if (options.onRecoverableError !== undefined) {
170+
onRecoverableError = options.onRecoverableError;
171+
}
166172
}
167173

168174
const root = createContainer(
@@ -173,6 +179,7 @@ export function createRoot(
173179
isStrictMode,
174180
concurrentUpdatesByDefaultOverride,
175181
identifierPrefix,
182+
onRecoverableError,
176183
);
177184
markContainerAsRoot(root.current, container);
178185

@@ -213,6 +220,7 @@ export function hydrateRoot(
213220
let isStrictMode = false;
214221
let concurrentUpdatesByDefaultOverride = false;
215222
let identifierPrefix = '';
223+
let onRecoverableError = null;
216224
if (options !== null && options !== undefined) {
217225
if (options.unstable_strictMode === true) {
218226
isStrictMode = true;
@@ -226,6 +234,9 @@ export function hydrateRoot(
226234
if (options.identifierPrefix !== undefined) {
227235
identifierPrefix = options.identifierPrefix;
228236
}
237+
if (options.onRecoverableError !== undefined) {
238+
onRecoverableError = options.onRecoverableError;
239+
}
229240
}
230241

231242
const root = createContainer(
@@ -236,6 +247,7 @@ export function hydrateRoot(
236247
isStrictMode,
237248
concurrentUpdatesByDefaultOverride,
238249
identifierPrefix,
250+
onRecoverableError,
239251
);
240252
markContainerAsRoot(root.current, container);
241253
// This can't be a comment node since hydration doesn't work on comment nodes anyway.

packages/react-native-renderer/src/ReactFabric.js

+1
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ function render(
214214
false,
215215
null,
216216
'',
217+
null,
217218
);
218219
roots.set(containerTag, root);
219220
}

0 commit comments

Comments
 (0)