@@ -314,4 +314,229 @@ describe('ReactDOMFizzShellHydration', () => {
314
314
'RangeError: Maximum call stack size exceeded' ,
315
315
) ;
316
316
} ) ;
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
+ } ) ;
317
542
} ) ;
0 commit comments