@samitouri / QOS-React / commits / 848e802d20

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

Andrew Clark committed Feb 4, 2022 at 07:57 UTC 848e802d203e531daf2b9b0edb281a1eb6c5415d
39 files changed +530 -62
packages/react-art/src/ReactARTHostConfig.js
+4
@@ -451,3 +451,7 @@ export function preparePortalMount(portalInstance: any): void {
451 export function detachDeletedInstance(node: Instance): void {
452 // noop
453 }
454 +
455 +export function logRecoverableError(error) {
456 + // noop
457 +}
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+109 -9
@@ -1723,12 +1723,22 @@ describe('ReactDOMFizzServer', () => {
1723 });
1724 expect(Scheduler).toHaveYielded(['server']);
1725
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 + });
1733
1734 if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
1735 expect(() => {
1736 // 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 + ]);
1742 }).toErrorDev(
1743 'Warning: An error occurred during hydration. The server HTML was replaced with client content',
1744 {withoutStack: true},
@@ -1805,13 +1815,23 @@ describe('ReactDOMFizzServer', () => {
1815 });
1816 expect(Scheduler).toHaveYielded(['server']);
1817
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 + });
1825
1826 if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
1827 // The first paint uses the client due to mismatch forcing client render
1828 expect(() => {
1829 // 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 + ]);
1835 }).toErrorDev(
1836 'Warning: An error occurred during hydration. The server HTML was replaced with client content',
1837 {withoutStack: true},
@@ -1897,9 +1917,15 @@ describe('ReactDOMFizzServer', () => {
1917 // Hydrate the tree. Child will throw during hydration, but not when it
1918 // falls back to client rendering.
1919 isClient = true;
1900 - ReactDOM.hydrateRoot(container, <App />);
1920 + ReactDOM.hydrateRoot(container, <App />, {
1921 + onRecoverableError(error) {
1922 + Scheduler.unstable_yieldValue(error.message);
1923 + },
1924 + });
1925
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']);
1929 expect(getVisibleChildren(container)).toEqual(
1930 <div>
1931 <span />
@@ -1975,8 +2001,16 @@ describe('ReactDOMFizzServer', () => {
2001
2002 // Hydrate the tree. Child will throw during render.
2003 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 + });
2011
2012 + // Because we failed to recover from the error, onRecoverableError
2013 + // shouldn't be called.
2014 expect(Scheduler).toFlushAndYield([]);
2015 expect(getVisibleChildren(container)).toEqual('Oops!');
2016 },
@@ -2049,9 +2083,15 @@ describe('ReactDOMFizzServer', () => {
2083 // Hydrate the tree. Child will throw during hydration, but not when it
2084 // falls back to client rendering.
2085 isClient = true;
2052 - ReactDOM.hydrateRoot(container, <App />);
2086 + ReactDOM.hydrateRoot(container, <App />, {
2087 + onRecoverableError(error) {
2088 + Scheduler.unstable_yieldValue(error.message);
2089 + },
2090 + });
2091
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']);
2095 expect(getVisibleChildren(container)).toEqual(
2096 <div>
2097 <span />
@@ -2081,4 +2121,64 @@ describe('ReactDOMFizzServer', () => {
2121 expect(span3Ref.current).toBe(span3);
2122 },
2123 );
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 + });
2184 });
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+47 -5
@@ -208,7 +208,11 @@ describe('ReactDOMServerPartialHydration', () => {
208 // On the client we don't have all data yet but we want to start
209 // hydrating anyway.
210 suspend = true;
211 - ReactDOM.hydrateRoot(container, <App />);
211 + ReactDOM.hydrateRoot(container, <App />, {
212 + onRecoverableError(error) {
213 + Scheduler.unstable_yieldValue(error.message);
214 + },
215 + });
216 if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
217 Scheduler.unstable_flushAll();
218 } else {
@@ -290,7 +294,11 @@ describe('ReactDOMServerPartialHydration', () => {
294 suspend = true;
295 client = true;
296
293 - ReactDOM.hydrateRoot(container, <App />);
297 + ReactDOM.hydrateRoot(container, <App />, {
298 + onRecoverableError(error) {
299 + Scheduler.unstable_yieldValue(error.message);
300 + },
301 + });
302 expect(Scheduler).toFlushAndYield([
303 'Suspend',
304 'Component',
@@ -316,12 +324,16 @@ describe('ReactDOMServerPartialHydration', () => {
324 'Component',
325 'Component',
326 'Component',
327 +
328 // second pass as client render
329 'Hello',
330 'Component',
331 'Component',
332 'Component',
333 'Component',
334 +
335 + // Hydration mismatch is logged
336 + 'An error occurred during hydration. The server HTML was replaced with client content',
337 ]);
338
339 // Client rendered - suspense comment nodes removed
@@ -573,9 +585,19 @@ describe('ReactDOMServerPartialHydration', () => {
585
586 expect(() => {
587 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 + });
593 });
594 }).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 + }
601
602 expect(container.innerHTML).toContain('<span>A</span>');
603 expect(container.innerHTML).not.toContain('<span>B</span>');
@@ -2997,7 +3019,13 @@ describe('ReactDOMServerPartialHydration', () => {
3019 const span = container.getElementsByTagName('span')[0];
3020 expect(span.innerHTML).toBe('Hidden child');
3021
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 + });
3029
3030 Scheduler.unstable_flushAll();
3031 expect(ref.current).toBe(span);
@@ -3142,13 +3170,27 @@ describe('ReactDOMServerPartialHydration', () => {
3170
3171 expect(() => {
3172 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 + });
3180 });
3181 }).toErrorDev(
3182 'Warning: An error occurred during hydration. ' +
3183 'The server HTML was replaced with client content in <div>.',
3184 {withoutStack: true},
3185 );
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 + ]);
3194
3195 // We show fallback state when mismatch happens at root
3196 expect(container.innerHTML).toEqual(
packages/react-dom/src/client/ReactDOMHostConfig.js
+14
@@ -374,6 +374,18 @@ export function getCurrentEventPriority(): * {
374 return getEventPriority(currentEvent.type);
375 }
376
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 +
389 export const isPrimaryRenderer = true;
390 export const warnsIfNotActing = true;
391 // This initialization code may run even on server environments
@@ -1070,6 +1082,8 @@ export function didNotFindHydratableSuspenseInstance(
1082
1083 export function errorHydratingContainer(parentContainer: Container): void {
1084 if (__DEV__) {
1085 + // TODO: This gets logged by onRecoverableError, too, so we should be
1086 + // able to remove it.
1087 console.error(
1088 'An error occurred during hydration. The server HTML was replaced with client content in <%s>.',
1089 parentContainer.nodeName.toLowerCase(),
packages/react-dom/src/client/ReactDOMLegacy.js
+1
@@ -122,6 +122,7 @@ function legacyCreateRootFromDOMContainer(
122 false, // isStrictMode
123 false, // concurrentUpdatesByDefaultOverride,
124 '', // identifierPrefix
125 + null,
126 );
127 markContainerAsRoot(root.current, container);
128
packages/react-dom/src/client/ReactDOMRoot.js
+12
@@ -24,6 +24,7 @@ export type CreateRootOptions = {
24 unstable_strictMode?: boolean,
25 unstable_concurrentUpdatesByDefault?: boolean,
26 identifierPrefix?: string,
27 + onRecoverableError?: (error: mixed) => void,
28 ...
29 };
30
@@ -36,6 +37,7 @@ export type HydrateRootOptions = {
37 unstable_strictMode?: boolean,
38 unstable_concurrentUpdatesByDefault?: boolean,
39 identifierPrefix?: string,
40 + onRecoverableError?: (error: mixed) => void,
41 ...
42 };
43
@@ -143,6 +145,7 @@ export function createRoot(
145 let isStrictMode = false;
146 let concurrentUpdatesByDefaultOverride = false;
147 let identifierPrefix = '';
148 + let onRecoverableError = null;
149 if (options !== null && options !== undefined) {
150 if (__DEV__) {
151 if ((options: any).hydrate) {
@@ -163,6 +166,9 @@ export function createRoot(
166 if (options.identifierPrefix !== undefined) {
167 identifierPrefix = options.identifierPrefix;
168 }
169 + if (options.onRecoverableError !== undefined) {
170 + onRecoverableError = options.onRecoverableError;
171 + }
172 }
173
174 const root = createContainer(
@@ -173,6 +179,7 @@ export function createRoot(
179 isStrictMode,
180 concurrentUpdatesByDefaultOverride,
181 identifierPrefix,
182 + onRecoverableError,
183 );
184 markContainerAsRoot(root.current, container);
185
@@ -213,6 +220,7 @@ export function hydrateRoot(
220 let isStrictMode = false;
221 let concurrentUpdatesByDefaultOverride = false;
222 let identifierPrefix = '';
223 + let onRecoverableError = null;
224 if (options !== null && options !== undefined) {
225 if (options.unstable_strictMode === true) {
226 isStrictMode = true;
@@ -226,6 +234,9 @@ export function hydrateRoot(
234 if (options.identifierPrefix !== undefined) {
235 identifierPrefix = options.identifierPrefix;
236 }
237 + if (options.onRecoverableError !== undefined) {
238 + onRecoverableError = options.onRecoverableError;
239 + }
240 }
241
242 const root = createContainer(
@@ -236,6 +247,7 @@ export function hydrateRoot(
247 isStrictMode,
248 concurrentUpdatesByDefaultOverride,
249 identifierPrefix,
250 + onRecoverableError,
251 );
252 markContainerAsRoot(root.current, container);
253 // This can't be a comment node since hydration doesn't work on comment nodes anyway.
packages/react-native-renderer/src/ReactFabric.js
+1
@@ -214,6 +214,7 @@ function render(
214 false,
215 null,
216 '',
217 + null,
218 );
219 roots.set(containerTag, root);
220 }
packages/react-native-renderer/src/ReactFabricHostConfig.js
+4
@@ -525,3 +525,7 @@ export function preparePortalMount(portalInstance: Instance): void {
525 export function detachDeletedInstance(node: Instance): void {
526 // noop
527 }
528 +
529 +export function logRecoverableError(error: mixed): void {
530 + // noop
531 +}
packages/react-native-renderer/src/ReactNativeHostConfig.js
+4
@@ -513,3 +513,7 @@ export function preparePortalMount(portalInstance: Instance): void {
513 export function detachDeletedInstance(node: Instance): void {
514 // noop
515 }
516 +
517 +export function logRecoverableError(error: mixed): void {
518 + // noop
519 +}
packages/react-native-renderer/src/ReactNativeRenderer.js
+1
@@ -210,6 +210,7 @@ function render(
210 false,
211 null,
212 '',
213 + null,
214 );
215 roots.set(containerTag, root);
216 }
packages/react-noop-renderer/src/createReactNoop.js
+16 -1
@@ -466,6 +466,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
466 },
467
468 detachDeletedInstance() {},
469 +
470 + logRecoverableError() {
471 + // no-op
472 + },
473 };
474
475 const hostConfig = useMutation
@@ -954,7 +958,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
958 if (!root) {
959 const container = {rootID: rootID, pendingChildren: [], children: []};
960 rootContainers.set(rootID, container);
957 - root = NoopRenderer.createContainer(container, tag, false, null, null);
961 + root = NoopRenderer.createContainer(
962 + container,
963 + tag,
964 + false,
965 + null,
966 + null,
967 + false,
968 + '',
969 + null,
970 + );
971 roots.set(rootID, root);
972 }
973 return root.current.stateNode.containerInfo;
@@ -975,6 +988,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
988 null,
989 false,
990 '',
991 + null,
992 );
993 return {
994 _Scheduler: Scheduler,
@@ -1004,6 +1018,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
1018 null,
1019 false,
1020 '',
1021 + null,
1022 );
1023 return {
1024 _Scheduler: Scheduler,
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+7
@@ -131,6 +131,7 @@ import {
131 resetHydrationState,
132 getIsHydrating,
133 hasUnhydratedTailNodes,
134 + upgradeHydrationErrorsToRecoverable,
135 } from './ReactFiberHydrationContext.new';
136 import {
137 enableSuspenseCallback,
@@ -1099,6 +1100,12 @@ function completeWork(
1100 return null;
1101 }
1102 }
1103 +
1104 + // Successfully completed this tree. If this was a forced client render,
1105 + // there may have been recoverable errors during first hydration
1106 + // attempt. If so, add them to a queue so we can log them in the
1107 + // commit phase.
1108 + upgradeHydrationErrorsToRecoverable();
1109 }
1110
1111 if ((workInProgress.flags & DidCapture) !== NoFlags) {
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+7
@@ -131,6 +131,7 @@ import {
131 resetHydrationState,
132 getIsHydrating,
133 hasUnhydratedTailNodes,
134 + upgradeHydrationErrorsToRecoverable,
135 } from './ReactFiberHydrationContext.old';
136 import {
137 enableSuspenseCallback,
@@ -1099,6 +1100,12 @@ function completeWork(
1100 return null;
1101 }
1102 }
1103 +
1104 + // Successfully completed this tree. If this was a forced client render,
1105 + // there may have been recoverable errors during first hydration
1106 + // attempt. If so, add them to a queue so we can log them in the
1107 + // commit phase.
1108 + upgradeHydrationErrorsToRecoverable();
1109 }
1110
1111 if ((workInProgress.flags & DidCapture) !== NoFlags) {
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+24
@@ -77,6 +77,7 @@ import {
77 getSuspendedTreeContext,
78 restoreSuspendedTreeContext,
79 } from './ReactFiberTreeContext.new';
80 +import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
81
82 // The deepest Fiber on the stack involved in a hydration context.
83 // This may have been an insertion or a hydration.
@@ -84,6 +85,9 @@ let hydrationParentFiber: null | Fiber = null;
85 let nextHydratableInstance: null | HydratableInstance = null;
86 let isHydrating: boolean = false;
87
88 +// Hydration errors that were thrown inside this boundary
89 +let hydrationErrors: Array<mixed> | null = null;
90 +
91 function warnIfHydrating() {
92 if (__DEV__) {
93 if (isHydrating) {
@@ -105,6 +109,7 @@ function enterHydrationState(fiber: Fiber): boolean {
109 );
110 hydrationParentFiber = fiber;
111 isHydrating = true;
112 + hydrationErrors = null;
113 return true;
114 }
115
@@ -121,6 +126,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
126 );
127 hydrationParentFiber = fiber;
128 isHydrating = true;
129 + hydrationErrors = null;
130 if (treeContext !== null) {
131 restoreSuspendedTreeContext(fiber, treeContext);
132 }
@@ -601,10 +607,28 @@ function resetHydrationState(): void {
607 isHydrating = false;
608 }
609
610 +export function upgradeHydrationErrorsToRecoverable(): void {
611 + if (hydrationErrors !== null) {
612 + // Successfully completed a forced client render. The errors that occurred
613 + // during the hydration attempt are now recovered. We will log them in
614 + // commit phase, once the entire tree has finished.
615 + queueRecoverableErrors(hydrationErrors);
616 + hydrationErrors = null;
617 + }
618 +}
619 +
620 function getIsHydrating(): boolean {
621 return isHydrating;
622 }
623
624 +export function queueHydrationError(error: mixed): void {
625 + if (hydrationErrors === null) {
626 + hydrationErrors = [error];
627 + } else {
628 + hydrationErrors.push(error);
629 + }
630 +}
631 +
632 export {
633 warnIfHydrating,
634 enterHydrationState,
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+24
@@ -77,6 +77,7 @@ import {
77 getSuspendedTreeContext,
78 restoreSuspendedTreeContext,
79 } from './ReactFiberTreeContext.old';
80 +import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
81
82 // The deepest Fiber on the stack involved in a hydration context.
83 // This may have been an insertion or a hydration.
@@ -84,6 +85,9 @@ let hydrationParentFiber: null | Fiber = null;
85 let nextHydratableInstance: null | HydratableInstance = null;
86 let isHydrating: boolean = false;
87
88 +// Hydration errors that were thrown inside this boundary
89 +let hydrationErrors: Array<mixed> | null = null;
90 +
91 function warnIfHydrating() {
92 if (__DEV__) {
93 if (isHydrating) {
@@ -105,6 +109,7 @@ function enterHydrationState(fiber: Fiber): boolean {
109 );
110 hydrationParentFiber = fiber;
111 isHydrating = true;
112 + hydrationErrors = null;
113 return true;
114 }
115
@@ -121,6 +126,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
126 );
127 hydrationParentFiber = fiber;
128 isHydrating = true;
129 + hydrationErrors = null;
130 if (treeContext !== null) {
131 restoreSuspendedTreeContext(fiber, treeContext);
132 }
@@ -601,10 +607,28 @@ function resetHydrationState(): void {
607 isHydrating = false;
608 }
609
610 +export function upgradeHydrationErrorsToRecoverable(): void {
611 + if (hydrationErrors !== null) {
612 + // Successfully completed a forced client render. The errors that occurred
613 + // during the hydration attempt are now recovered. We will log them in
614 + // commit phase, once the entire tree has finished.
615 + queueRecoverableErrors(hydrationErrors);
616 + hydrationErrors = null;
617 + }
618 +}
619 +
620 function getIsHydrating(): boolean {
621 return isHydrating;
622 }
623
624 +export function queueHydrationError(error: mixed): void {
625 + if (hydrationErrors === null) {
626 + hydrationErrors = [error];
627 + } else {
628 + hydrationErrors.push(error);
629 + }
630 +}
631 +
632 export {
633 warnIfHydrating,
634 enterHydrationState,
packages/react-reconciler/src/ReactFiberReconciler.new.js
+2
@@ -245,6 +245,7 @@ export function createContainer(
245 isStrictMode: boolean,
246 concurrentUpdatesByDefaultOverride: null | boolean,
247 identifierPrefix: string,
248 + onRecoverableError: null | ((error: mixed) => void),
249 ): OpaqueRoot {
250 return createFiberRoot(
251 containerInfo,
@@ -254,6 +255,7 @@ export function createContainer(
255 isStrictMode,
256 concurrentUpdatesByDefaultOverride,
257 identifierPrefix,
258 + onRecoverableError,
259 );
260 }
261
packages/react-reconciler/src/ReactFiberReconciler.old.js
+2
@@ -245,6 +245,7 @@ export function createContainer(
245 isStrictMode: boolean,
246 concurrentUpdatesByDefaultOverride: null | boolean,
247 identifierPrefix: string,
248 + onRecoverableError: null | ((error: mixed) => void),
249 ): OpaqueRoot {
250 return createFiberRoot(
251 containerInfo,
@@ -254,6 +255,7 @@ export function createContainer(
255 isStrictMode,
256 concurrentUpdatesByDefaultOverride,
257 identifierPrefix,
258 + onRecoverableError,
259 );
260 }
261
packages/react-reconciler/src/ReactFiberRoot.new.js
+14 -1
@@ -30,7 +30,13 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.new';
30 import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
31 import {createCache, retainCache} from './ReactFiberCacheComponent.new';
32
33 -function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
33 +function FiberRootNode(
34 + containerInfo,
35 + tag,
36 + hydrate,
37 + identifierPrefix,
38 + onRecoverableError,
39 +) {
40 this.tag = tag;
41 this.containerInfo = containerInfo;
42 this.pendingChildren = null;
@@ -57,6 +63,7 @@ function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
63 this.entanglements = createLaneMap(NoLanes);
64
65 this.identifierPrefix = identifierPrefix;
66 + this.onRecoverableError = onRecoverableError;
67
68 if (enableCache) {
69 this.pooledCache = null;
@@ -103,13 +110,19 @@ export function createFiberRoot(
110 hydrationCallbacks: null | SuspenseHydrationCallbacks,
111 isStrictMode: boolean,
112 concurrentUpdatesByDefaultOverride: null | boolean,
113 + // TODO: We have several of these arguments that are conceptually part of the
114 + // host config, but because they are passed in at runtime, we have to thread
115 + // them through the root constructor. Perhaps we should put them all into a
116 + // single type, like a DynamicHostConfig that is defined by the renderer.
117 identifierPrefix: string,
118 + onRecoverableError: null | ((error: mixed) => void),
119 ): FiberRoot {
120 const root: FiberRoot = (new FiberRootNode(
121 containerInfo,
122 tag,
123 hydrate,
124 identifierPrefix,
125 + onRecoverableError,
126 ): any);
127 if (enableSuspenseCallback) {
128 root.hydrationCallbacks = hydrationCallbacks;
packages/react-reconciler/src/ReactFiberRoot.old.js
+14 -1
@@ -30,7 +30,13 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.old';
30 import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
31 import {createCache, retainCache} from './ReactFiberCacheComponent.old';
32
33 -function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
33 +function FiberRootNode(
34 + containerInfo,
35 + tag,
36 + hydrate,
37 + identifierPrefix,
38 + onRecoverableError,
39 +) {
40 this.tag = tag;
41 this.containerInfo = containerInfo;
42 this.pendingChildren = null;
@@ -57,6 +63,7 @@ function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
63 this.entanglements = createLaneMap(NoLanes);
64
65 this.identifierPrefix = identifierPrefix;
66 + this.onRecoverableError = onRecoverableError;
67
68 if (enableCache) {
69 this.pooledCache = null;
@@ -103,13 +110,19 @@ export function createFiberRoot(
110 hydrationCallbacks: null | SuspenseHydrationCallbacks,
111 isStrictMode: boolean,
112 concurrentUpdatesByDefaultOverride: null | boolean,
113 + // TODO: We have several of these arguments that are conceptually part of the
114 + // host config, but because they are passed in at runtime, we have to thread
115 + // them through the root constructor. Perhaps we should put them all into a
116 + // single type, like a DynamicHostConfig that is defined by the renderer.
117 identifierPrefix: string,
118 + onRecoverableError: null | ((error: mixed) => void),
119 ): FiberRoot {
120 const root: FiberRoot = (new FiberRootNode(
121 containerInfo,
122 tag,
123 hydrate,
124 identifierPrefix,
125 + onRecoverableError,
126 ): any);
127 if (enableSuspenseCallback) {
128 root.hydrationCallbacks = hydrationCallbacks;
packages/react-reconciler/src/ReactFiberThrow.new.js
+9 -2
@@ -79,7 +79,10 @@ import {
79 mergeLanes,
80 pickArbitraryLane,
81 } from './ReactFiberLane.new';
82 -import {getIsHydrating} from './ReactFiberHydrationContext.new';
82 +import {
83 + getIsHydrating,
84 + queueHydrationError,
85 +} from './ReactFiberHydrationContext.new';
86
87 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
88
@@ -507,6 +510,10 @@ function throwException(
510 root,
511 rootRenderLanes,
512 );
513 +
514 + // Even though the user may not be affected by this error, we should
515 + // still log it so it can be fixed.
516 + queueHydrationError(value);
517 return;
518 }
519 } else {
@@ -517,7 +524,7 @@ function throwException(
524 // We didn't find a boundary that could handle this type of exception. Start
525 // over and traverse parent path again, this time treating the exception
526 // as an error.
520 - renderDidError();
527 + renderDidError(value);
528
529 value = createCapturedValue(value, sourceFiber);
530 let workInProgress = returnFiber;
packages/react-reconciler/src/ReactFiberThrow.old.js
+9 -2
@@ -79,7 +79,10 @@ import {
79 mergeLanes,
80 pickArbitraryLane,
81 } from './ReactFiberLane.old';
82 -import {getIsHydrating} from './ReactFiberHydrationContext.old';
82 +import {
83 + getIsHydrating,
84 + queueHydrationError,
85 +} from './ReactFiberHydrationContext.old';
86
87 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
88
@@ -507,6 +510,10 @@ function throwException(
510 root,
511 rootRenderLanes,
512 );
513 +
514 + // Even though the user may not be affected by this error, we should
515 + // still log it so it can be fixed.
516 + queueHydrationError(value);
517 return;
518 }
519 } else {
@@ -517,7 +524,7 @@ function throwException(
524 // We didn't find a boundary that could handle this type of exception. Start
525 // over and traverse parent path again, this time treating the exception
526 // as an error.
520 - renderDidError();
527 + renderDidError(value);
528
529 value = createCapturedValue(value, sourceFiber);
530 let workInProgress = returnFiber;
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+68 -11
@@ -14,6 +14,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
14 import type {StackCursor} from './ReactFiberStack.new';
15 import type {Flags} from './ReactFiberFlags';
16 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new';
17 +import type {EventPriority} from './ReactEventPriorities.new';
18
19 import {
20 warnAboutDeprecatedLifecycles,
@@ -76,6 +77,7 @@ import {
77 supportsMicrotasks,
78 errorHydratingContainer,
79 scheduleMicrotask,
80 + logRecoverableError,
81 } from './ReactFiberHostConfig';
82
83 import {
@@ -296,6 +298,11 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes;
298 let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes;
299 // Lanes that were pinged (in an interleaved event) during this render.
300 let workInProgressRootPingedLanes: Lanes = NoLanes;
301 +// Errors that are thrown during the render phase.
302 +let workInProgressRootConcurrentErrors: Array<mixed> | null = null;
303 +// These are errors that we recovered from without surfacing them to the UI.
304 +// We will log them once the tree commits.
305 +let workInProgressRootRecoverableErrors: Array<mixed> | null = null;
306
307 // The most recent time we committed a fallback. This lets us ensure a train
308 // model where we don't commit new loading states in too quick succession.
@@ -894,13 +901,36 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
901 }
902 }
903
904 + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;
905 const exitStatus = renderRootSync(root, errorRetryLanes);
906 + if (exitStatus !== RootErrored) {
907 + // Successfully finished rendering on retry
908 + if (errorsFromFirstAttempt !== null) {
909 + // The errors from the failed first attempt have been recovered. Add
910 + // them to the collection of recoverable errors. We'll log them in the
911 + // commit phase.
912 + queueRecoverableErrors(errorsFromFirstAttempt);
913 + }
914 + } else {
915 + // The UI failed to recover.
916 + }
917
918 executionContext = prevExecutionContext;
919
920 return exitStatus;
921 }
922
923 +export function queueRecoverableErrors(errors: Array<mixed>) {
924 + if (workInProgressRootConcurrentErrors === null) {
925 + workInProgressRootRecoverableErrors = errors;
926 + } else {
927 + workInProgressRootConcurrentErrors = workInProgressRootConcurrentErrors.push.apply(
928 + null,
929 + errors,
930 + );
931 + }
932 +}
933 +
934 function finishConcurrentRender(root, exitStatus, lanes) {
935 switch (exitStatus) {
936 case RootIncomplete:
@@ -913,7 +943,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
943 case RootErrored: {
944 // We should have already attempted to retry this tree. If we reached
945 // this point, it errored again. Commit it.
916 - commitRoot(root);
946 + commitRoot(root, workInProgressRootRecoverableErrors);
947 break;
948 }
949 case RootSuspended: {
@@ -953,14 +983,14 @@ function finishConcurrentRender(root, exitStatus, lanes) {
983 // lower priority work to do. Instead of committing the fallback
984 // immediately, wait for more data to arrive.
985 root.timeoutHandle = scheduleTimeout(
956 - commitRoot.bind(null, root),
986 + commitRoot.bind(null, root, workInProgressRootRecoverableErrors),
987 msUntilTimeout,
988 );
989 break;
990 }
991 }
992 // The work expired. Commit immediately.
963 - commitRoot(root);
993 + commitRoot(root, workInProgressRootRecoverableErrors);
994 break;
995 }
996 case RootSuspendedWithDelay: {
@@ -991,7 +1021,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1021 // Instead of committing the fallback immediately, wait for more data
1022 // to arrive.
1023 root.timeoutHandle = scheduleTimeout(
994 - commitRoot.bind(null, root),
1024 + commitRoot.bind(null, root, workInProgressRootRecoverableErrors),
1025 msUntilTimeout,
1026 );
1027 break;
@@ -999,12 +1029,12 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1029 }
1030
1031 // Commit the placeholder.
1002 - commitRoot(root);
1032 + commitRoot(root, workInProgressRootRecoverableErrors);
1033 break;
1034 }
1035 case RootCompleted: {
1036 // The work completed. Ready to commit.
1007 - commitRoot(root);
1037 + commitRoot(root, workInProgressRootRecoverableErrors);
1038 break;
1039 }
1040 default: {
@@ -1124,7 +1154,7 @@ function performSyncWorkOnRoot(root) {
1154 const finishedWork: Fiber = (root.current.alternate: any);
1155 root.finishedWork = finishedWork;
1156 root.finishedLanes = lanes;
1127 - commitRoot(root);
1157 + commitRoot(root, workInProgressRootRecoverableErrors);
1158
1159 // Before exiting, make sure there's a callback scheduled for the next
1160 // pending level.
@@ -1320,6 +1350,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1350 workInProgressRootInterleavedUpdatedLanes = NoLanes;
1351 workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
1352 workInProgressRootPingedLanes = NoLanes;
1353 + workInProgressRootConcurrentErrors = null;
1354 + workInProgressRootRecoverableErrors = null;
1355
1356 enqueueInterleavedUpdates();
1357
@@ -1474,10 +1506,15 @@ export function renderDidSuspendDelayIfPossible(): void {
1506 }
1507 }
1508
1477 -export function renderDidError() {
1509 +export function renderDidError(error: mixed) {
1510 if (workInProgressRootExitStatus !== RootSuspendedWithDelay) {
1511 workInProgressRootExitStatus = RootErrored;
1512 }
1513 + if (workInProgressRootConcurrentErrors === null) {
1514 + workInProgressRootConcurrentErrors = [error];
1515 + } else {
1516 + workInProgressRootConcurrentErrors.push(error);
1517 + }
1518 }
1519
1520 // Called during render to determine if anything has suspended.
@@ -1781,7 +1818,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1818 }
1819 }
1820
1784 -function commitRoot(root) {
1821 +function commitRoot(root: FiberRoot, recoverableErrors: null | Array<mixed>) {
1822 // TODO: This no longer makes any sense. We already wrap the mutation and
1823 // layout phases. Should be able to remove.
1824 const previousUpdateLanePriority = getCurrentUpdatePriority();
@@ -1789,7 +1826,7 @@ function commitRoot(root) {
1826 try {
1827 ReactCurrentBatchConfig.transition = 0;
1828 setCurrentUpdatePriority(DiscreteEventPriority);
1792 - commitRootImpl(root, previousUpdateLanePriority);
1829 + commitRootImpl(root, recoverableErrors, previousUpdateLanePriority);
1830 } finally {
1831 ReactCurrentBatchConfig.transition = prevTransition;
1832 setCurrentUpdatePriority(previousUpdateLanePriority);
@@ -1798,7 +1835,11 @@ function commitRoot(root) {
1835 return null;
1836 }
1837
1801 -function commitRootImpl(root, renderPriorityLevel) {
1838 +function commitRootImpl(
1839 + root: FiberRoot,
1840 + recoverableErrors: null | Array<mixed>,
1841 + renderPriorityLevel: EventPriority,
1842 +) {
1843 do {
1844 // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
1845 // means `flushPassiveEffects` will sometimes result in additional
@@ -2069,6 +2110,22 @@ function commitRootImpl(root, renderPriorityLevel) {
2110 // additional work on this root is scheduled.
2111 ensureRootIsScheduled(root, now());
2112
2113 + if (recoverableErrors !== null) {
2114 + // There were errors during this render, but recovered from them without
2115 + // needing to surface it to the UI. We log them here.
2116 + for (let i = 0; i < recoverableErrors.length; i++) {
2117 + const recoverableError = recoverableErrors[i];
2118 + const onRecoverableError = root.onRecoverableError;
2119 + if (onRecoverableError !== null) {
2120 + onRecoverableError(recoverableError);
2121 + } else {
2122 + // No user-provided onRecoverableError. Use the default behavior
2123 + // provided by the renderer's host config.
2124 + logRecoverableError(recoverableError);
2125 + }
2126 + }
2127 + }
2128 +
2129 if (hasUncaughtError) {
2130 hasUncaughtError = false;
2131 const error = firstUncaughtError;
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+68 -11
@@ -14,6 +14,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
14 import type {StackCursor} from './ReactFiberStack.old';
15 import type {Flags} from './ReactFiberFlags';
16 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old';
17 +import type {EventPriority} from './ReactEventPriorities.old';
18
19 import {
20 warnAboutDeprecatedLifecycles,
@@ -76,6 +77,7 @@ import {
77 supportsMicrotasks,
78 errorHydratingContainer,
79 scheduleMicrotask,
80 + logRecoverableError,
81 } from './ReactFiberHostConfig';
82
83 import {
@@ -296,6 +298,11 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes;
298 let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes;
299 // Lanes that were pinged (in an interleaved event) during this render.
300 let workInProgressRootPingedLanes: Lanes = NoLanes;
301 +// Errors that are thrown during the render phase.
302 +let workInProgressRootConcurrentErrors: Array<mixed> | null = null;
303 +// These are errors that we recovered from without surfacing them to the UI.
304 +// We will log them once the tree commits.
305 +let workInProgressRootRecoverableErrors: Array<mixed> | null = null;
306
307 // The most recent time we committed a fallback. This lets us ensure a train
308 // model where we don't commit new loading states in too quick succession.
@@ -894,13 +901,36 @@ function recoverFromConcurrentError(root, errorRetryLanes) {
901 }
902 }
903
904 + const errorsFromFirstAttempt = workInProgressRootConcurrentErrors;
905 const exitStatus = renderRootSync(root, errorRetryLanes);
906 + if (exitStatus !== RootErrored) {
907 + // Successfully finished rendering on retry
908 + if (errorsFromFirstAttempt !== null) {
909 + // The errors from the failed first attempt have been recovered. Add
910 + // them to the collection of recoverable errors. We'll log them in the
911 + // commit phase.
912 + queueRecoverableErrors(errorsFromFirstAttempt);
913 + }
914 + } else {
915 + // The UI failed to recover.
916 + }
917
918 executionContext = prevExecutionContext;
919
920 return exitStatus;
921 }
922
923 +export function queueRecoverableErrors(errors: Array<mixed>) {
924 + if (workInProgressRootConcurrentErrors === null) {
925 + workInProgressRootRecoverableErrors = errors;
926 + } else {
927 + workInProgressRootConcurrentErrors = workInProgressRootConcurrentErrors.push.apply(
928 + null,
929 + errors,
930 + );
931 + }
932 +}
933 +
934 function finishConcurrentRender(root, exitStatus, lanes) {
935 switch (exitStatus) {
936 case RootIncomplete:
@@ -913,7 +943,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
943 case RootErrored: {
944 // We should have already attempted to retry this tree. If we reached
945 // this point, it errored again. Commit it.
916 - commitRoot(root);
946 + commitRoot(root, workInProgressRootRecoverableErrors);
947 break;
948 }
949 case RootSuspended: {
@@ -953,14 +983,14 @@ function finishConcurrentRender(root, exitStatus, lanes) {
983 // lower priority work to do. Instead of committing the fallback
984 // immediately, wait for more data to arrive.
985 root.timeoutHandle = scheduleTimeout(
956 - commitRoot.bind(null, root),
986 + commitRoot.bind(null, root, workInProgressRootRecoverableErrors),
987 msUntilTimeout,
988 );
989 break;
990 }
991 }
992 // The work expired. Commit immediately.
963 - commitRoot(root);
993 + commitRoot(root, workInProgressRootRecoverableErrors);
994 break;
995 }
996 case RootSuspendedWithDelay: {
@@ -991,7 +1021,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1021 // Instead of committing the fallback immediately, wait for more data
1022 // to arrive.
1023 root.timeoutHandle = scheduleTimeout(
994 - commitRoot.bind(null, root),
1024 + commitRoot.bind(null, root, workInProgressRootRecoverableErrors),
1025 msUntilTimeout,
1026 );
1027 break;
@@ -999,12 +1029,12 @@ function finishConcurrentRender(root, exitStatus, lanes) {
1029 }
1030
1031 // Commit the placeholder.
1002 - commitRoot(root);
1032 + commitRoot(root, workInProgressRootRecoverableErrors);
1033 break;
1034 }
1035 case RootCompleted: {
1036 // The work completed. Ready to commit.
1007 - commitRoot(root);
1037 + commitRoot(root, workInProgressRootRecoverableErrors);
1038 break;
1039 }
1040 default: {
@@ -1124,7 +1154,7 @@ function performSyncWorkOnRoot(root) {
1154 const finishedWork: Fiber = (root.current.alternate: any);
1155 root.finishedWork = finishedWork;
1156 root.finishedLanes = lanes;
1127 - commitRoot(root);
1157 + commitRoot(root, workInProgressRootRecoverableErrors);
1158
1159 // Before exiting, make sure there's a callback scheduled for the next
1160 // pending level.
@@ -1320,6 +1350,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1350 workInProgressRootInterleavedUpdatedLanes = NoLanes;
1351 workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
1352 workInProgressRootPingedLanes = NoLanes;
1353 + workInProgressRootConcurrentErrors = null;
1354 + workInProgressRootRecoverableErrors = null;
1355
1356 enqueueInterleavedUpdates();
1357
@@ -1474,10 +1506,15 @@ export function renderDidSuspendDelayIfPossible(): void {
1506 }
1507 }
1508
1477 -export function renderDidError() {
1509 +export function renderDidError(error: mixed) {
1510 if (workInProgressRootExitStatus !== RootSuspendedWithDelay) {
1511 workInProgressRootExitStatus = RootErrored;
1512 }
1513 + if (workInProgressRootConcurrentErrors === null) {
1514 + workInProgressRootConcurrentErrors = [error];
1515 + } else {
1516 + workInProgressRootConcurrentErrors.push(error);
1517 + }
1518 }
1519
1520 // Called during render to determine if anything has suspended.
@@ -1781,7 +1818,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1818 }
1819 }
1820
1784 -function commitRoot(root) {
1821 +function commitRoot(root: FiberRoot, recoverableErrors: null | Array<mixed>) {
1822 // TODO: This no longer makes any sense. We already wrap the mutation and
1823 // layout phases. Should be able to remove.
1824 const previousUpdateLanePriority = getCurrentUpdatePriority();
@@ -1789,7 +1826,7 @@ function commitRoot(root) {
1826 try {
1827 ReactCurrentBatchConfig.transition = 0;
1828 setCurrentUpdatePriority(DiscreteEventPriority);
1792 - commitRootImpl(root, previousUpdateLanePriority);
1829 + commitRootImpl(root, recoverableErrors, previousUpdateLanePriority);
1830 } finally {
1831 ReactCurrentBatchConfig.transition = prevTransition;
1832 setCurrentUpdatePriority(previousUpdateLanePriority);
@@ -1798,7 +1835,11 @@ function commitRoot(root) {
1835 return null;
1836 }
1837
1801 -function commitRootImpl(root, renderPriorityLevel) {
1838 +function commitRootImpl(
1839 + root: FiberRoot,
1840 + recoverableErrors: null | Array<mixed>,
1841 + renderPriorityLevel: EventPriority,
1842 +) {
1843 do {
1844 // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
1845 // means `flushPassiveEffects` will sometimes result in additional
@@ -2069,6 +2110,22 @@ function commitRootImpl(root, renderPriorityLevel) {
2110 // additional work on this root is scheduled.
2111 ensureRootIsScheduled(root, now());
2112
2113 + if (recoverableErrors !== null) {
2114 + // There were errors during this render, but recovered from them without
2115 + // needing to surface it to the UI. We log them here.
2116 + for (let i = 0; i < recoverableErrors.length; i++) {
2117 + const recoverableError = recoverableErrors[i];
2118 + const onRecoverableError = root.onRecoverableError;
2119 + if (onRecoverableError !== null) {
2120 + onRecoverableError(recoverableError);
2121 + } else {
2122 + // No user-provided onRecoverableError. Use the default behavior
2123 + // provided by the renderer's host config.
2124 + logRecoverableError(recoverableError);
2125 + }
2126 + }
2127 + }
2128 +
2129 if (hasUncaughtError) {
2130 hasUncaughtError = false;
2131 const error = firstUncaughtError;
packages/react-reconciler/src/ReactInternalTypes.js
+2
@@ -246,6 +246,8 @@ type BaseFiberRootProperties = {|
246 // the public createRoot object, which the fiber tree does not currently have
247 // a reference to.
248 identifierPrefix: string,
249 +
250 + onRecoverableError: null | ((error: mixed) => void),
251 |};
252
253 // The following attributes are only used by DevTools and are only present in DEV builds.
packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js
+2
@@ -76,6 +76,7 @@ describe('ReactFiberHostContext', () => {
76 null,
77 false,
78 '',
79 + null,
80 );
81 act(() => {
82 Renderer.updateContainer(
@@ -139,6 +140,7 @@ describe('ReactFiberHostContext', () => {
140 null,
141 false,
142 '',
143 + null,
144 );
145 act(() => {
146 Renderer.updateContainer(
packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
+37 -2
@@ -205,6 +205,9 @@ describe('useMutableSourceHydration', () => {
205 act(() => {
206 ReactDOM.hydrateRoot(container, <TestComponent />, {
207 mutableSources: [mutableSource],
208 + onRecoverableError(error) {
209 + Scheduler.unstable_yieldValue('Log error: ' + error.message);
210 + },
211 });
212
213 source.value = 'two';
@@ -254,11 +257,17 @@ describe('useMutableSourceHydration', () => {
257 React.startTransition(() => {
258 ReactDOM.hydrateRoot(container, <TestComponent />, {
259 mutableSources: [mutableSource],
260 + onRecoverableError(error) {
261 + Scheduler.unstable_yieldValue('Log error: ' + error.message);
262 + },
263 });
264 });
265 } else {
266 ReactDOM.hydrateRoot(container, <TestComponent />, {
267 mutableSources: [mutableSource],
268 + onRecoverableError(error) {
269 + Scheduler.unstable_yieldValue('Log error: ' + error.message);
270 + },
271 });
272 }
273 expect(Scheduler).toFlushAndYieldThrough(['a:one']);
@@ -269,7 +278,17 @@ describe('useMutableSourceHydration', () => {
278 'The server HTML was replaced with client content in <div>.',
279 {withoutStack: true},
280 );
272 - expect(Scheduler).toHaveYielded(['a:two', 'b:two']);
281 + expect(Scheduler).toHaveYielded([
282 + 'a:two',
283 + 'b:two',
284 + // TODO: Before onRecoverableError, this error was never surfaced to the
285 + // user. The request to file an bug report no longer makes sense.
286 + // However, the experimental useMutableSource API is slated for
287 + // removal, anyway.
288 + 'Log error: Cannot read from mutable source during the current ' +
289 + 'render without tearing. This may be a bug in React. Please file ' +
290 + 'an issue.',
291 + ]);
292 expect(source.listenerCount).toBe(2);
293 });
294
@@ -328,11 +347,17 @@ describe('useMutableSourceHydration', () => {
347 React.startTransition(() => {
348 ReactDOM.hydrateRoot(container, fragment, {
349 mutableSources: [mutableSource],
350 + onRecoverableError(error) {
351 + Scheduler.unstable_yieldValue('Log error: ' + error.message);
352 + },
353 });
354 });
355 } else {
356 ReactDOM.hydrateRoot(container, fragment, {
357 mutableSources: [mutableSource],
358 + onRecoverableError(error) {
359 + Scheduler.unstable_yieldValue('Log error: ' + error.message);
360 + },
361 });
362 }
363 expect(Scheduler).toFlushAndYieldThrough(['0:a:one']);
@@ -343,7 +368,17 @@ describe('useMutableSourceHydration', () => {
368 'The server HTML was replaced with client content in <div>.',
369 {withoutStack: true},
370 );
346 - expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']);
371 + expect(Scheduler).toHaveYielded([
372 + '0:a:one',
373 + '1:b:two',
374 + // TODO: Before onRecoverableError, this error was never surfaced to the
375 + // user. The request to file an bug report no longer makes sense.
376 + // However, the experimental useMutableSource API is slated for
377 + // removal, anyway.
378 + 'Log error: Cannot read from mutable source during the current ' +
379 + 'render without tearing. This may be a bug in React. Please file ' +
380 + 'an issue.',
381 + ]);
382 });
383
384 // @gate !enableSyncDefaultUpdates
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+1
@@ -68,6 +68,7 @@ export const prepareScopeUpdate = $$$hostConfig.preparePortalMount;
68 export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope;
69 export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority;
70 export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance;
71 +export const logRecoverableError = $$$hostConfig.logRecoverableError;
72
73 // -------------------
74 // Microtasks
packages/react-server/src/ReactFizzServer.js
+6 -6
@@ -401,7 +401,7 @@ function popComponentStackInDEV(task: Task): void {
401 }
402 }
403
404 -function reportError(request: Request, error: mixed): void {
404 +function logRecoverableError(request: Request, error: mixed): void {
405 // If this callback errors, we intentionally let that error bubble up to become a fatal error
406 // so that someone fixes the error reporting instead of hiding it.
407 const onError = request.onError;
@@ -484,7 +484,7 @@ function renderSuspenseBoundary(
484 }
485 } catch (error) {
486 contentRootSegment.status = ERRORED;
487 - reportError(request, error);
487 + logRecoverableError(request, error);
488 newBoundary.forceClientRender = true;
489 // We don't need to decrement any task numbers because we didn't spawn any new task.
490 // We don't need to schedule any task because we know the parent has written yet.
@@ -1337,7 +1337,7 @@ function erroredTask(
1337 error: mixed,
1338 ) {
1339 // Report the error to a global handler.
1340 - reportError(request, error);
1340 + logRecoverableError(request, error);
1341 if (boundary === null) {
1342 fatalError(request, error);
1343 } else {
@@ -1557,7 +1557,7 @@ export function performWork(request: Request): void {
1557 flushCompletedQueues(request, request.destination);
1558 }
1559 } catch (error) {
1560 - reportError(request, error);
1560 + logRecoverableError(request, error);
1561 fatalError(request, error);
1562 } finally {
1563 setCurrentResponseState(prevResponseState);
@@ -1945,7 +1945,7 @@ export function startFlowing(request: Request, destination: Destination): void {
1945 try {
1946 flushCompletedQueues(request, destination);
1947 } catch (error) {
1948 - reportError(request, error);
1948 + logRecoverableError(request, error);
1949 fatalError(request, error);
1950 }
1951 }
@@ -1960,7 +1960,7 @@ export function abort(request: Request): void {
1960 flushCompletedQueues(request, request.destination);
1961 }
1962 } catch (error) {
1963 - reportError(request, error);
1963 + logRecoverableError(request, error);
1964 fatalError(request, error);
1965 }
1966 }
packages/react-server/src/ReactFlightServer.js
+5 -5
@@ -421,7 +421,7 @@ export function resolveModelToJSON(
421 x.then(ping, ping);
422 return serializeByRefID(newSegment.id);
423 } else {
424 - reportError(request, x);
424 + logRecoverableError(request, x);
425 // Something errored. We'll still send everything we have up until this point.
426 // We'll replace this element with a lazy reference that throws on the client
427 // once it gets rendered.
@@ -604,7 +604,7 @@ export function resolveModelToJSON(
604 );
605 }
606
607 -function reportError(request: Request, error: mixed): void {
607 +function logRecoverableError(request: Request, error: mixed): void {
608 const onError = request.onError;
609 onError(error);
610 }
@@ -687,7 +687,7 @@ function retrySegment(request: Request, segment: Segment): void {
687 x.then(ping, ping);
688 return;
689 } else {
690 - reportError(request, x);
690 + logRecoverableError(request, x);
691 // This errored, we need to serialize this error to the
692 emitErrorChunk(request, segment.id, x);
693 }
@@ -711,7 +711,7 @@ function performWork(request: Request): void {
711 flushCompletedChunks(request, request.destination);
712 }
713 } catch (error) {
714 - reportError(request, error);
714 + logRecoverableError(request, error);
715 fatalError(request, error);
716 } finally {
717 ReactCurrentDispatcher.current = prevDispatcher;
@@ -794,7 +794,7 @@ export function startFlowing(request: Request, destination: Destination): void {
794 try {
795 flushCompletedChunks(request, destination);
796 } catch (error) {
797 - reportError(request, error);
797 + logRecoverableError(request, error);
798 fatalError(request, error);
799 }
800 }
packages/react-test-renderer/src/ReactTestHostConfig.js
+4
@@ -314,3 +314,7 @@ export function getInstanceFromScope(scopeInstance: Object): null | Object {
314 export function detachDeletedInstance(node: Instance): void {
315 // noop
316 }
317 +
318 +export function logRecoverableError(error: mixed): void {
319 + // noop
320 +}
packages/react-test-renderer/src/ReactTestRenderer.js
+1
@@ -472,6 +472,7 @@ function create(element: React$Element<any>, options: TestRendererOptions) {
472 isStrictMode,
473 concurrentUpdatesByDefault,
474 '',
475 + null,
476 );
477
478 if (root == null) {
scripts/flow/environment.js
+1
@@ -19,6 +19,7 @@ declare var __REACT_DEVTOOLS_GLOBAL_HOOK__: any; /*?{
19 };*/
20
21 declare var queueMicrotask: (fn: Function) => void;
22 +declare var reportError: (error: mixed) => void;
23
24 declare module 'create-react-class' {
25 declare var exports: React$CreateClass;
scripts/print-warnings/print-warnings.js
+4 -6
@@ -67,12 +67,10 @@ function transform(file, enc, cb) {
67 const warningMsgLiteral = evalStringConcat(node.arguments[0]);
68 warnings.add(JSON.stringify(warningMsgLiteral));
69 } catch (error) {
70 - console.error(
71 - 'Failed to extract warning message from',
72 - file.path
73 - );
74 - console.error(astPath.node.loc);
75 - throw error;
70 + // Silently skip over this call. We have a lint rule to enforce
71 + // that all calls are extractable, so if this one fails, assume
72 + // it's intentional.
73 + return;
74 }
75 }
76 },
scripts/rollup/validate/eslintrc.cjs.js
+1
@@ -31,6 +31,7 @@ module.exports = {
31 ArrayBuffer: 'readonly',
32
33 TaskController: 'readonly',
34 + reportError: 'readonly',
35
36 // Flight
37 Uint8Array: 'readonly',
scripts/rollup/validate/eslintrc.cjs2015.js
+1
@@ -30,6 +30,7 @@ module.exports = {
30 ArrayBuffer: 'readonly',
31
32 TaskController: 'readonly',
33 + reportError: 'readonly',
34
35 // Flight
36 Uint8Array: 'readonly',
scripts/rollup/validate/eslintrc.esm.js
+1
@@ -30,6 +30,7 @@ module.exports = {
30 ArrayBuffer: 'readonly',
31
32 TaskController: 'readonly',
33 + reportError: 'readonly',
34
35 // Flight
36 Uint8Array: 'readonly',
scripts/rollup/validate/eslintrc.fb.js
+1
@@ -31,6 +31,7 @@ module.exports = {
31 ArrayBuffer: 'readonly',
32
33 TaskController: 'readonly',
34 + reportError: 'readonly',
35
36 // Flight
37 Uint8Array: 'readonly',
scripts/rollup/validate/eslintrc.rn.js
+1
@@ -31,6 +31,7 @@ module.exports = {
31 ArrayBuffer: 'readonly',
32
33 TaskController: 'readonly',
34 + reportError: 'readonly',
35
36 // jest
37 jest: 'readonly',
scripts/rollup/validate/eslintrc.umd.js
+1
@@ -36,6 +36,7 @@ module.exports = {
36 ArrayBuffer: 'readonly',
37
38 TaskController: 'readonly',
39 + reportError: 'readonly',
40
41 // Flight
42 Uint8Array: 'readonly',