Skip to content

Commit 0853376

Browse files
committed
Add tests for errors in root error boundary
- Server and client throws - Only client throws - Only hydration pass of client throws but recovers
1 parent 9bff373 commit 0853376

File tree

1 file changed

+225
-0
lines changed

1 file changed

+225
-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
});

0 commit comments

Comments
 (0)