Skip to content

Commit 6af98f4

Browse files
committed
[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.)
1 parent 13036bf commit 6af98f4

22 files changed

+185
-12
lines changed

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 4 additions & 0 deletions
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 logHydrationError(config, error) {
456+
// noop
457+
}

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,9 +1897,15 @@ describe('ReactDOMFizzServer', () => {
18971897
// Hydrate the tree. Child will throw during hydration, but not when it
18981898
// falls back to client rendering.
18991899
isClient = true;
1900-
ReactDOM.hydrateRoot(container, <App />);
1900+
ReactDOM.hydrateRoot(container, <App />, {
1901+
onHydrationError(error) {
1902+
Scheduler.unstable_yieldValue(error.message);
1903+
},
1904+
});
19011905

1902-
expect(Scheduler).toFlushAndYield(['Yay!']);
1906+
// An error logged but instead of surfacing it to the UI, we switched
1907+
// to client rendering.
1908+
expect(Scheduler).toFlushAndYield(['Yay!', 'Hydration error']);
19031909
expect(getVisibleChildren(container)).toEqual(
19041910
<div>
19051911
<span />
@@ -1975,9 +1981,16 @@ describe('ReactDOMFizzServer', () => {
19751981

19761982
// Hydrate the tree. Child will throw during render.
19771983
isClient = true;
1978-
ReactDOM.hydrateRoot(container, <App />);
1984+
ReactDOM.hydrateRoot(container, <App />, {
1985+
onHydrationError(error) {
1986+
// TODO: We logged a hydration error, but the same error ends up
1987+
// being thrown during the fallback to client rendering, too. Maybe
1988+
// we should only log if the client render succeeds.
1989+
Scheduler.unstable_yieldValue(error.message);
1990+
},
1991+
});
19791992

1980-
expect(Scheduler).toFlushAndYield([]);
1993+
expect(Scheduler).toFlushAndYield(['Oops!']);
19811994
expect(getVisibleChildren(container)).toEqual('Oops!');
19821995
},
19831996
);
@@ -2049,9 +2062,15 @@ describe('ReactDOMFizzServer', () => {
20492062
// Hydrate the tree. Child will throw during hydration, but not when it
20502063
// falls back to client rendering.
20512064
isClient = true;
2052-
ReactDOM.hydrateRoot(container, <App />);
2065+
ReactDOM.hydrateRoot(container, <App />, {
2066+
onHydrationError(error) {
2067+
Scheduler.unstable_yieldValue(error.message);
2068+
},
2069+
});
20532070

2054-
expect(Scheduler).toFlushAndYield([]);
2071+
// An error logged but instead of surfacing it to the UI, we switched
2072+
// to client rendering.
2073+
expect(Scheduler).toFlushAndYield(['Hydration error']);
20552074
expect(getVisibleChildren(container)).toEqual(
20562075
<div>
20572076
<span />

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,17 @@ 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+
onHydrationError(error) {
213+
Scheduler.unstable_yieldValue(error.message);
214+
},
215+
});
212216
if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
213-
Scheduler.unstable_flushAll();
217+
// Hydration error is logged
218+
expect(Scheduler).toFlushAndYield([
219+
'An error occurred during hydration. The server HTML was replaced ' +
220+
'with client content',
221+
]);
214222
} else {
215223
expect(() => {
216224
Scheduler.unstable_flushAll();
@@ -290,13 +298,24 @@ describe('ReactDOMServerPartialHydration', () => {
290298
suspend = true;
291299
client = true;
292300

293-
ReactDOM.hydrateRoot(container, <App />);
301+
ReactDOM.hydrateRoot(container, <App />, {
302+
onHydrationError(error) {
303+
Scheduler.unstable_yieldValue(error.message);
304+
},
305+
});
294306
expect(Scheduler).toFlushAndYield([
295307
'Suspend',
296308
'Component',
297309
'Component',
298310
'Component',
299311
'Component',
312+
313+
// Hydration mismatch errors are logged.
314+
// TODO: This could get noisy. Is there some way to dedupe?
315+
'An error occurred during hydration. The server HTML was replaced with client content',
316+
'An error occurred during hydration. The server HTML was replaced with client content',
317+
'An error occurred during hydration. The server HTML was replaced with client content',
318+
'An error occurred during hydration. The server HTML was replaced with client content',
300319
]);
301320
jest.runAllTimers();
302321

@@ -316,12 +335,16 @@ describe('ReactDOMServerPartialHydration', () => {
316335
'Component',
317336
'Component',
318337
'Component',
338+
319339
// second pass as client render
320340
'Hello',
321341
'Component',
322342
'Component',
323343
'Component',
324344
'Component',
345+
346+
// Hydration mismatch is logged
347+
'An error occurred during hydration. The server HTML was replaced with client content',
325348
]);
326349

327350
// Client rendered - suspense comment nodes removed

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
7070
import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
7171

7272
import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities';
73+
import {scheduleCallback, IdlePriority} from 'react-reconciler/src/Scheduler';
7374

7475
export type Type = string;
7576
export type Props = {
@@ -123,6 +124,10 @@ export type TimeoutHandle = TimeoutID;
123124
export type NoTimeout = -1;
124125
export type RendererInspectionConfig = $ReadOnly<{||}>;
125126

127+
// Right now this is a single callback, but could be multiple in the in the
128+
// future.
129+
export type ErrorLoggingConfig = null | ((error: mixed) => void);
130+
126131
type SelectionInformation = {|
127132
focusedElem: null | HTMLElement,
128133
selectionRange: mixed,
@@ -374,6 +379,25 @@ export function getCurrentEventPriority(): * {
374379
return getEventPriority(currentEvent.type);
375380
}
376381

382+
export function logHydrationError(
383+
config: ErrorLoggingConfig,
384+
error: mixed,
385+
): void {
386+
const onHydrationError = config;
387+
if (onHydrationError !== null) {
388+
// Schedule a callback to invoke the user-provided logging function.
389+
scheduleCallback(IdlePriority, () => {
390+
onHydrationError(error);
391+
});
392+
} else {
393+
// Default behavior is to rethrow the error in a separate task. This will
394+
// trigger a browser error event.
395+
scheduleCallback(IdlePriority, () => {
396+
throw error;
397+
});
398+
}
399+
}
400+
377401
export const isPrimaryRenderer = true;
378402
export const warnsIfNotActing = true;
379403
// This initialization code may run even on server environments

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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type HydrateRootOptions = {
3636
unstable_strictMode?: boolean,
3737
unstable_concurrentUpdatesByDefault?: boolean,
3838
identifierPrefix?: string,
39+
onHydrationError?: (error: mixed) => void,
3940
...
4041
};
4142

@@ -173,6 +174,7 @@ export function createRoot(
173174
isStrictMode,
174175
concurrentUpdatesByDefaultOverride,
175176
identifierPrefix,
177+
null,
176178
);
177179
markContainerAsRoot(root.current, container);
178180

@@ -213,6 +215,7 @@ export function hydrateRoot(
213215
let isStrictMode = false;
214216
let concurrentUpdatesByDefaultOverride = false;
215217
let identifierPrefix = '';
218+
let onHydrationError = null;
216219
if (options !== null && options !== undefined) {
217220
if (options.unstable_strictMode === true) {
218221
isStrictMode = true;
@@ -226,6 +229,9 @@ export function hydrateRoot(
226229
if (options.identifierPrefix !== undefined) {
227230
identifierPrefix = options.identifierPrefix;
228231
}
232+
if (options.onHydrationError !== undefined) {
233+
onHydrationError = options.onHydrationError;
234+
}
229235
}
230236

231237
const root = createContainer(
@@ -236,6 +242,7 @@ export function hydrateRoot(
236242
isStrictMode,
237243
concurrentUpdatesByDefaultOverride,
238244
identifierPrefix,
245+
onHydrationError,
239246
);
240247
markContainerAsRoot(root.current, container);
241248
// This can't be a comment node since hydration doesn't work on comment nodes anyway.

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

Lines changed: 1 addition & 0 deletions
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
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export type RendererInspectionConfig = $ReadOnly<{|
9595
) => void,
9696
|}>;
9797

98+
export type ErrorLoggingConfig = null;
99+
98100
// TODO: Remove this conditional once all changes have propagated.
99101
if (registerEventHandler) {
100102
/**
@@ -525,3 +527,10 @@ export function preparePortalMount(portalInstance: Instance): void {
525527
export function detachDeletedInstance(node: Instance): void {
526528
// noop
527529
}
530+
531+
export function logHydrationError(
532+
config: ErrorLoggingConfig,
533+
error: mixed,
534+
): void {
535+
// noop
536+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export type RendererInspectionConfig = $ReadOnly<{|
5555
) => void,
5656
|}>;
5757

58+
export type ErrorLoggingConfig = null;
59+
5860
const UPDATE_SIGNAL = {};
5961
if (__DEV__) {
6062
Object.freeze(UPDATE_SIGNAL);
@@ -513,3 +515,10 @@ export function preparePortalMount(portalInstance: Instance): void {
513515
export function detachDeletedInstance(node: Instance): void {
514516
// noop
515517
}
518+
519+
export function logHydrationError(
520+
config: ErrorLoggingConfig,
521+
error: mixed,
522+
): void {
523+
// noop
524+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ function render(
210210
false,
211211
null,
212212
'',
213+
null,
213214
);
214215
roots.set(containerTag, root);
215216
}

0 commit comments

Comments
 (0)