406
);
407
}
408
409
+ // @gate enableBrowserAPI
410
+ it('can opt a component into browser-only rendering', async () => {
411
+ let resolveBrowserText;
412
+ const browserText = new Promise(resolve => {
413
+ resolveBrowserText = resolve;
414
+ });
415
+ const browserOnly = ReactDOM.browser();
416
+
417
+ function BrowserOnly() {
418
+ use(browserOnly);
419
+ const text = use(browserText);
420
+ Scheduler.log(text);
421
+ return <span>{text}</span>;
422
+ }
423
+
424
+ function App() {
425
+ return (
426
+ <div>
427
+ <Suspense fallback={<span>Fallback</span>}>
428
+ <BrowserOnly />
429
+ </Suspense>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ const serverErrors = [];
435
+ await act(() => {
436
+ const {pipe} = renderToPipeableStream(<App />, {
437
+ onError(error) {
438
+ serverErrors.push(error);
439
+ },
440
+ });
441
+ pipe(writable);
442
+ });
443
+
444
+ expect(serverErrors).toEqual([]);
445
+ expect(getVisibleChildren(container)).toEqual(
446
+ <div>
447
+ <span>Fallback</span>
448
+ </div>,
449
+ );
450
+ const recoverableErrors = [];
451
+ ReactDOMClient.hydrateRoot(container, <App />, {
452
+ onRecoverableError(error) {
453
+ recoverableErrors.push(error);
454
+ },
455
+ });
456
+ await waitForAll([]);
457
+
458
+ expect(getVisibleChildren(container)).toEqual(
459
+ <div>
460
+ <span>Fallback</span>
461
+ </div>,
462
+ );
463
+
464
+ await clientAct(() => {
465
+ resolveBrowserText('Browser');
466
+ });
467
+ assertLog(['Browser']);
468
+
469
+ expect(recoverableErrors).toEqual([]);
470
+ expect(getVisibleChildren(container)).toEqual(
471
+ <div>
472
+ <span>Browser</span>
473
+ </div>,
474
+ );
475
+ });
476
+
477
+ // @gate enableBrowserAPI
478
+ it('can opt a component into browser-only rendering after streaming the fallback', async () => {
479
+ let resolveServerReady;
480
+ const serverReady = new Promise(resolve => {
481
+ resolveServerReady = resolve;
482
+ });
483
+
484
+ function BrowserOnly() {
485
+ use(serverReady);
486
+ use(ReactDOM.browser());
487
+ return <span>Browser</span>;
488
+ }
489
+
490
+ function App() {
491
+ return (
492
+ <div>
493
+ <Suspense fallback={<span>Fallback</span>}>
494
+ <BrowserOnly />
495
+ </Suspense>
496
+ </div>
497
+ );
498
+ }
499
+
500
+ const serverErrors = [];
501
+ await act(() => {
502
+ const {pipe} = renderToPipeableStream(<App />, {
503
+ onError(error) {
504
+ serverErrors.push(error);
505
+ },
506
+ });
507
+ pipe(writable);
508
+ });
509
+
510
+ expect(getVisibleChildren(container)).toEqual(
511
+ <div>
512
+ <span>Fallback</span>
513
+ </div>,
514
+ );
515
+
516
+ await act(() => {
517
+ resolveServerReady();
518
+ });
519
+
520
+ expect(serverErrors).toEqual([]);
521
+
522
+ const recoverableErrors = [];
523
+ ReactDOMClient.hydrateRoot(container, <App />, {
524
+ onRecoverableError(error) {
525
+ recoverableErrors.push(error);
526
+ },
527
+ });
528
+ await waitForAll([]);
529
+
530
+ expect(recoverableErrors).toEqual([]);
531
+ expect(getVisibleChildren(container)).toEqual(
532
+ <div>
533
+ <span>Browser</span>
534
+ </div>,
535
+ );
536
+ });
537
+
538
+ // @gate enableBrowserAPI
539
+ it('errors if browser-only content is rendered outside Suspense', async () => {
540
+ function createBrowserValue() {
541
+ return ReactDOM.browser();
542
+ }
543
+ const browserValue = createBrowserValue();
544
+
545
+ function BrowserOnly() {
546
+ use(browserValue);
547
+ return <span>Browser</span>;
548
+ }
549
+
550
+ const reportedErrors = [];
551
+ let shellReady = false;
552
+ let shellError;
553
+ await act(() => {
554
+ renderToPipeableStream(<BrowserOnly />, {
555
+ onError(error) {
556
+ reportedErrors.push(error);
557
+ },
558
+ onShellReady() {
559
+ shellReady = true;
560
+ },
561
+ onShellError(error) {
562
+ shellError = error;
563
+ },
564
+ });
565
+ });
566
+
567
+ expect(shellError).toBeInstanceOf(Error);
568
+ expect(shellError.message).toBe(
569
+ 'The server render could not complete because client rendering was ' +
570
+ "requested outside a Suspense boundary. See this error's cause for " +
571
+ 'additional details.',
572
+ );
573
+ expect(shellError.stack).toContain('BrowserOnly');
574
+ expect(shellError.cause).toBe(browserValue);
575
+ expect(shellError.cause.stack).toContain('createBrowserValue');
576
+ expect(shellError.cause.message).toContain(
577
+ '`use(browser())` can only be used inside a `<Suspense>` boundary',
578
+ );
579
+ expect(shellReady).toBe(false);
580
+ expect(reportedErrors).toEqual([shellError]);
581
+ });
582
+
583
+ // @gate enableBrowserAPI
584
+ it('can abort all pending boundaries into browser-only rendering', async () => {
585
+ const never = new Promise(() => {});
586
+ let isClient = false;
587
+
588
+ function Pending({children}) {
589
+ if (!isClient) {
590
+ use(never);
591
+ }
592
+ return <span>{children}</span>;
593
+ }
594
+
595
+ function App() {
596
+ return (
597
+ <div>
598
+ <span>Shell</span>
599
+ <Suspense fallback={<span>Loading A</span>}>
600
+ <Pending>A</Pending>
601
+ </Suspense>
602
+ <Suspense fallback={<span>Loading B</span>}>
603
+ <Pending>B</Pending>
604
+ </Suspense>
605
+ </div>
606
+ );
607
+ }
608
+
609
+ const serverErrors = [];
610
+ let abort;
611
+ await act(() => {
612
+ const controls = renderToPipeableStream(<App />, {
613
+ onError(error) {
614
+ serverErrors.push(error);
615
+ },
616
+ });
617
+ abort = controls.abort;
618
+ controls.pipe(writable);
619
+ });
620
+
621
+ expect(getVisibleChildren(container)).toEqual(
622
+ <div>
623
+ <span>Shell</span>
624
+ <span>Loading A</span>
625
+ <span>Loading B</span>
626
+ </div>,
627
+ );
628
+
629
+ await act(() => {
630
+ abort(ReactDOM.browser());
631
+ });
632
+
633
+ expect(serverErrors).toEqual([]);
634
+
635
+ isClient = true;
636
+ const recoverableErrors = [];
637
+ ReactDOMClient.hydrateRoot(container, <App />, {
638
+ onRecoverableError(error) {
639
+ recoverableErrors.push(error);
640
+ },
641
+ });
642
+ await waitForAll([]);
643
+
644
+ expect(recoverableErrors).toEqual([]);
645
+ expect(getVisibleChildren(container)).toEqual(
646
+ <div>
647
+ <span>Shell</span>
648
+ <span>A</span>
649
+ <span>B</span>
650
+ </div>,
651
+ );
652
+ });
653
+
654
+ // @gate enableBrowserAPI
655
+ it('errors if aborted with browser() before the shell completes', async () => {
656
+ const never = new Promise(() => {});
657
+ const browserValue = ReactDOM.browser();
658
+
659
+ function PendingRoot() {
660
+ use(never);
661
+ return <span>Root</span>;
662
+ }
663
+
664
+ const reportedErrors = [];
665
+ let shellReady = false;
666
+ let shellError;
667
+ let abort;
668
+ await act(() => {
669
+ const controls = renderToPipeableStream(<PendingRoot />, {
670
+ onError(error) {
671
+ reportedErrors.push(error);
672
+ },
673
+ onShellReady() {
674
+ shellReady = true;
675
+ },
676
+ onShellError(error) {
677
+ shellError = error;
678
+ },
679
+ });
680
+ abort = controls.abort;
681
+ });
682
+
683
+ await act(() => {
684
+ abort(browserValue);
685
+ });
686
+
687
+ expect(shellError).toBeInstanceOf(Error);
688
+ expect(shellError.message).toBe(
689
+ 'The server render could not complete because client rendering was ' +
690
+ "requested outside a Suspense boundary. See this error's cause for " +
691
+ 'additional details.',
692
+ );
693
+ expect(shellError.cause).toBe(browserValue);
694
+ expect(shellReady).toBe(false);
695
+ expect(reportedErrors).toEqual([shellError]);
696
+ });
697
+
698
+ // @gate enableBrowserAPI
699
+ it('reports the browser value if it is thrown instead of passed to use', async () => {
700
+ const browserValue = ReactDOM.browser();
701
+
702
+ function BrowserOnly() {
703
+ throw browserValue;
704
+ }
705
+
706
+ const reportedErrors = [];
707
+ await act(() => {
708
+ const {pipe} = renderToPipeableStream(
709
+ <Suspense fallback={<span>Fallback</span>}>
710
+ <BrowserOnly />
711
+ </Suspense>,
712
+ {
713
+ onError(error) {
714
+ reportedErrors.push(error);
715
+ },
716
+ },
717
+ );
718
+ pipe(writable);
719
+ });
720
+
721
+ expect(reportedErrors).toEqual([browserValue]);
722
+ expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
723
+ });
724
+
725
+ ['', 'BROWSER'].forEach(userDigest => {
726
+ it(`does not reserve the ${JSON.stringify(
727
+ userDigest,
728
+ )} user error digest for browser rendering`, async () => {
729
+ let isClient = false;
730
+ const serverError = new Error('Server error');
731
+
732
+ function ServerError() {
733
+ if (!isClient) {
734
+ throw serverError;
735
+ }
736
+ return <span>Client</span>;
737
+ }
738
+
739
+ function App() {
740
+ return (
741
+ <Suspense fallback={<span>Fallback</span>}>
742
+ <ServerError />
743
+ </Suspense>
744
+ );
745
+ }
746
+
747
+ const serverErrors = [];
748
+ await act(() => {
749
+ const {pipe} = renderToPipeableStream(<App />, {
750
+ onError(error) {
751
+ serverErrors.push(error);
752
+ return userDigest;
753
+ },
754
+ });
755
+ pipe(writable);
756
+ });
757
+
758
+ expect(serverErrors).toEqual([serverError]);
759
+ expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
760
+
761
+ isClient = true;
762
+ const recoverableErrors = [];
763
+ ReactDOMClient.hydrateRoot(container, <App />, {
764
+ onRecoverableError(error) {
765
+ recoverableErrors.push(error);
766
+ },
767
+ });
768
+ await waitForAll([]);
769
+
770
+ expect(recoverableErrors).toHaveLength(1);
771
+ expect(recoverableErrors[0].digest).toBe(userDigest || undefined);
772
+ expect(getVisibleChildren(container)).toEqual(<span>Client</span>);
773
+ });
774
+ });
775
+
776
it('should asynchronously load a lazy component', async () => {
777
let resolveA;
778
const LazyA = React.lazy(() => {