Skip to content

Commit 37f53ee

Browse files
sebmarkbageAndyPengc12
authored andcommitted
Don't let error boundaries catch errors during hydration (facebook#28675)
When an error boundary catches an error during hydration it'll try to render the error state which will then try to hydrate that state, causing hydration warnings. When an error happens inside a Suspense boundary during hydration, we instead let the boundary catch it and restart a client render from there. However, when it's in the root we instead let it fail the root and do the sync recovery pass. This didn't consider that we might hit an error boundary first so this just skips the error boundary in that case. We should probably instead let the root do a concurrent client render in this same pass instead to unify with Suspense boundaries.
1 parent f8558e0 commit 37f53ee

File tree

2 files changed

+232
-0
lines changed

2 files changed

+232
-0
lines changed

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

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,229 @@ describe('ReactDOMFizzShellHydration', () => {
314314
'RangeError: Maximum call stack size exceeded',
315315
);
316316
});
317+
318+
it('client renders when an error is thrown in an error boundary', async () => {
319+
function Throws() {
320+
throw new Error('plain error');
321+
}
322+
323+
class ErrorBoundary extends React.Component {
324+
state = {error: null};
325+
static getDerivedStateFromError(error) {
326+
return {error};
327+
}
328+
render() {
329+
if (this.state.error) {
330+
return <div>Caught an error: {this.state.error.message}</div>;
331+
}
332+
return this.props.children;
333+
}
334+
}
335+
336+
function App() {
337+
return (
338+
<ErrorBoundary>
339+
<Throws />
340+
</ErrorBoundary>
341+
);
342+
}
343+
344+
// Server render
345+
let shellError;
346+
try {
347+
await serverAct(async () => {
348+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
349+
onError(error) {
350+
Scheduler.log('onError: ' + error.message);
351+
},
352+
});
353+
pipe(writable);
354+
});
355+
} catch (x) {
356+
shellError = x;
357+
}
358+
expect(shellError).toEqual(
359+
expect.objectContaining({message: 'plain error'}),
360+
);
361+
assertLog(['onError: plain error']);
362+
363+
function ErroredApp() {
364+
return <span>loading</span>;
365+
}
366+
367+
// Reset test environment
368+
buffer = '';
369+
hasErrored = false;
370+
writable = new Stream.PassThrough();
371+
writable.setEncoding('utf8');
372+
writable.on('data', chunk => {
373+
buffer += chunk;
374+
});
375+
writable.on('error', error => {
376+
hasErrored = true;
377+
fatalError = error;
378+
});
379+
380+
// The Server errored at the shell. The recommended approach is to render a
381+
// fallback loading state, which can then be hydrated with a mismatch.
382+
await serverAct(async () => {
383+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ErroredApp />);
384+
pipe(writable);
385+
});
386+
387+
expect(container.innerHTML).toBe('<span>loading</span>');
388+
389+
// Hydration suspends because the data for the shell hasn't loaded yet
390+
await clientAct(async () => {
391+
ReactDOMClient.hydrateRoot(container, <App />, {
392+
onCaughtError(error) {
393+
Scheduler.log('onCaughtError: ' + error.message);
394+
},
395+
onUncaughtError(error) {
396+
Scheduler.log('onUncaughtError: ' + error.message);
397+
},
398+
onRecoverableError(error) {
399+
Scheduler.log('onRecoverableError: ' + error.message);
400+
},
401+
});
402+
});
403+
404+
assertLog(['onCaughtError: plain error']);
405+
expect(container.textContent).toBe('Caught an error: plain error');
406+
});
407+
408+
it('client renders when a client error is thrown in an error boundary', async () => {
409+
let isClient = false;
410+
411+
function Throws() {
412+
if (isClient) {
413+
throw new Error('plain error');
414+
}
415+
return <div>Hello world</div>;
416+
}
417+
418+
class ErrorBoundary extends React.Component {
419+
state = {error: null};
420+
static getDerivedStateFromError(error) {
421+
return {error};
422+
}
423+
render() {
424+
if (this.state.error) {
425+
return <div>Caught an error: {this.state.error.message}</div>;
426+
}
427+
return this.props.children;
428+
}
429+
}
430+
431+
function App() {
432+
return (
433+
<ErrorBoundary>
434+
<Throws />
435+
</ErrorBoundary>
436+
);
437+
}
438+
439+
// Server render
440+
await serverAct(async () => {
441+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
442+
onError(error) {
443+
Scheduler.log('onError: ' + error.message);
444+
},
445+
});
446+
pipe(writable);
447+
});
448+
assertLog([]);
449+
450+
expect(container.innerHTML).toBe('<div>Hello world</div>');
451+
452+
isClient = true;
453+
454+
// Hydration suspends because the data for the shell hasn't loaded yet
455+
await clientAct(async () => {
456+
ReactDOMClient.hydrateRoot(container, <App />, {
457+
onCaughtError(error) {
458+
Scheduler.log('onCaughtError: ' + error.message);
459+
},
460+
onUncaughtError(error) {
461+
Scheduler.log('onUncaughtError: ' + error.message);
462+
},
463+
onRecoverableError(error) {
464+
Scheduler.log('onRecoverableError: ' + error.message);
465+
},
466+
});
467+
});
468+
469+
assertLog(['onCaughtError: plain error']);
470+
expect(container.textContent).toBe('Caught an error: plain error');
471+
});
472+
473+
it('client renders when a hydration pass error is thrown in an error boundary', async () => {
474+
let isClient = false;
475+
let isFirst = true;
476+
477+
function Throws() {
478+
if (isClient && isFirst) {
479+
isFirst = false; // simulate a hydration or concurrent error
480+
throw new Error('plain error');
481+
}
482+
return <div>Hello world</div>;
483+
}
484+
485+
class ErrorBoundary extends React.Component {
486+
state = {error: null};
487+
static getDerivedStateFromError(error) {
488+
return {error};
489+
}
490+
render() {
491+
if (this.state.error) {
492+
return <div>Caught an error: {this.state.error.message}</div>;
493+
}
494+
return this.props.children;
495+
}
496+
}
497+
498+
function App() {
499+
return (
500+
<ErrorBoundary>
501+
<Throws />
502+
</ErrorBoundary>
503+
);
504+
}
505+
506+
// Server render
507+
await serverAct(async () => {
508+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
509+
onError(error) {
510+
Scheduler.log('onError: ' + error.message);
511+
},
512+
});
513+
pipe(writable);
514+
});
515+
assertLog([]);
516+
517+
expect(container.innerHTML).toBe('<div>Hello world</div>');
518+
519+
isClient = true;
520+
521+
// Hydration suspends because the data for the shell hasn't loaded yet
522+
await clientAct(async () => {
523+
ReactDOMClient.hydrateRoot(container, <App />, {
524+
onCaughtError(error) {
525+
Scheduler.log('onCaughtError: ' + error.message);
526+
},
527+
onUncaughtError(error) {
528+
Scheduler.log('onUncaughtError: ' + error.message);
529+
},
530+
onRecoverableError(error) {
531+
Scheduler.log('onRecoverableError: ' + error.message);
532+
},
533+
});
534+
});
535+
536+
assertLog([
537+
'onRecoverableError: plain error',
538+
'onRecoverableError: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
539+
]);
540+
expect(container.textContent).toBe('Hello world');
541+
});
317542
});

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,13 @@ function throwException(
574574
return false;
575575
}
576576
case ClassComponent:
577+
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
578+
// If we're hydrating and got here, it means that we didn't find a suspense
579+
// boundary above so it's a root error. In this case we shouldn't let the
580+
// error boundary capture it because it'll just try to hydrate the error state.
581+
// Instead we let it bubble to the root and let the recover pass handle it.
582+
break;
583+
}
577584
// Capture and retry
578585
const errorInfo = value;
579586
const ctor = workInProgress.type;

0 commit comments

Comments
 (0)