diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
index 3c2260d83bd6f..756d5d455c9d2 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
@@ -314,4 +314,229 @@ describe('ReactDOMFizzShellHydration', () => {
'RangeError: Maximum call stack size exceeded',
);
});
+
+ it('client renders when an error is thrown in an error boundary', async () => {
+ function Throws() {
+ throw new Error('plain error');
+ }
+
+ class ErrorBoundary extends React.Component {
+ state = {error: null};
+ static getDerivedStateFromError(error) {
+ return {error};
+ }
+ render() {
+ if (this.state.error) {
+ return
Caught an error: {this.state.error.message}
;
+ }
+ return this.props.children;
+ }
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ // Server render
+ let shellError;
+ try {
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, {
+ onError(error) {
+ Scheduler.log('onError: ' + error.message);
+ },
+ });
+ pipe(writable);
+ });
+ } catch (x) {
+ shellError = x;
+ }
+ expect(shellError).toEqual(
+ expect.objectContaining({message: 'plain error'}),
+ );
+ assertLog(['onError: plain error']);
+
+ function ErroredApp() {
+ return loading;
+ }
+
+ // Reset test environment
+ buffer = '';
+ hasErrored = false;
+ writable = new Stream.PassThrough();
+ writable.setEncoding('utf8');
+ writable.on('data', chunk => {
+ buffer += chunk;
+ });
+ writable.on('error', error => {
+ hasErrored = true;
+ fatalError = error;
+ });
+
+ // The Server errored at the shell. The recommended approach is to render a
+ // fallback loading state, which can then be hydrated with a mismatch.
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream();
+ pipe(writable);
+ });
+
+ expect(container.innerHTML).toBe('loading');
+
+ // Hydration suspends because the data for the shell hasn't loaded yet
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, , {
+ onCaughtError(error) {
+ Scheduler.log('onCaughtError: ' + error.message);
+ },
+ onUncaughtError(error) {
+ Scheduler.log('onUncaughtError: ' + error.message);
+ },
+ onRecoverableError(error) {
+ Scheduler.log('onRecoverableError: ' + error.message);
+ },
+ });
+ });
+
+ assertLog(['onCaughtError: plain error']);
+ expect(container.textContent).toBe('Caught an error: plain error');
+ });
+
+ it('client renders when a client error is thrown in an error boundary', async () => {
+ let isClient = false;
+
+ function Throws() {
+ if (isClient) {
+ throw new Error('plain error');
+ }
+ return Hello world
;
+ }
+
+ class ErrorBoundary extends React.Component {
+ state = {error: null};
+ static getDerivedStateFromError(error) {
+ return {error};
+ }
+ render() {
+ if (this.state.error) {
+ return Caught an error: {this.state.error.message}
;
+ }
+ return this.props.children;
+ }
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ // Server render
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, {
+ onError(error) {
+ Scheduler.log('onError: ' + error.message);
+ },
+ });
+ pipe(writable);
+ });
+ assertLog([]);
+
+ expect(container.innerHTML).toBe('Hello world
');
+
+ isClient = true;
+
+ // Hydration suspends because the data for the shell hasn't loaded yet
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, , {
+ onCaughtError(error) {
+ Scheduler.log('onCaughtError: ' + error.message);
+ },
+ onUncaughtError(error) {
+ Scheduler.log('onUncaughtError: ' + error.message);
+ },
+ onRecoverableError(error) {
+ Scheduler.log('onRecoverableError: ' + error.message);
+ },
+ });
+ });
+
+ assertLog(['onCaughtError: plain error']);
+ expect(container.textContent).toBe('Caught an error: plain error');
+ });
+
+ it('client renders when a hydration pass error is thrown in an error boundary', async () => {
+ let isClient = false;
+ let isFirst = true;
+
+ function Throws() {
+ if (isClient && isFirst) {
+ isFirst = false; // simulate a hydration or concurrent error
+ throw new Error('plain error');
+ }
+ return Hello world
;
+ }
+
+ class ErrorBoundary extends React.Component {
+ state = {error: null};
+ static getDerivedStateFromError(error) {
+ return {error};
+ }
+ render() {
+ if (this.state.error) {
+ return Caught an error: {this.state.error.message}
;
+ }
+ return this.props.children;
+ }
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ // Server render
+ await serverAct(async () => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, {
+ onError(error) {
+ Scheduler.log('onError: ' + error.message);
+ },
+ });
+ pipe(writable);
+ });
+ assertLog([]);
+
+ expect(container.innerHTML).toBe('Hello world
');
+
+ isClient = true;
+
+ // Hydration suspends because the data for the shell hasn't loaded yet
+ await clientAct(async () => {
+ ReactDOMClient.hydrateRoot(container, , {
+ onCaughtError(error) {
+ Scheduler.log('onCaughtError: ' + error.message);
+ },
+ onUncaughtError(error) {
+ Scheduler.log('onUncaughtError: ' + error.message);
+ },
+ onRecoverableError(error) {
+ Scheduler.log('onRecoverableError: ' + error.message);
+ },
+ });
+ });
+
+ assertLog([
+ 'onRecoverableError: plain error',
+ 'onRecoverableError: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
+ ]);
+ expect(container.textContent).toBe('Hello world');
+ });
});
diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js
index ce18234fd37ca..3bb54f73e80d6 100644
--- a/packages/react-reconciler/src/ReactFiberThrow.js
+++ b/packages/react-reconciler/src/ReactFiberThrow.js
@@ -574,6 +574,13 @@ function throwException(
return false;
}
case ClassComponent:
+ if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
+ // If we're hydrating and got here, it means that we didn't find a suspense
+ // boundary above so it's a root error. In this case we shouldn't let the
+ // error boundary capture it because it'll just try to hydrate the error state.
+ // Instead we let it bubble to the root and let the recover pass handle it.
+ break;
+ }
// Capture and retry
const errorInfo = value;
const ctor = workInProgress.type;