412
const browserText = new Promise(resolve => {
413
resolveBrowserText = resolve;
414
});
415
- const browserOnly = ReactDOM.browser();
415
+ let browserReason;
416
+ const initializeReason = jest.fn(() => {
417
+ browserReason = Object.freeze(
418
+ new Error('Only render this content in a browser'),
419
+ );
420
+ return browserReason;
421
+ });
422
+ const browserOnly = ReactDOM.browser(initializeReason);
423
424
function BrowserOnly() {
425
use(browserOnly);
453
});
454
455
expect(serverErrors).toEqual([]);
456
+ expect(initializeReason).toHaveBeenCalledTimes(1);
457
expect(browserBailouts).toHaveLength(1);
450
- expect(browserBailouts[0].error).toBe(browserOnly);
458
+ expect(browserBailouts[0].error).toBeInstanceOf(Error);
459
+ expect(browserBailouts[0].error.message).toBe(
460
+ 'Browser-only rendering was requested by `browser()`.',
461
+ );
462
+ expect(browserBailouts[0].error.stack).toContain('BrowserOnly');
463
+ expect(browserBailouts[0].error.cause).toBe(browserReason);
464
expect(
465
normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack),
466
).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App']));
489
assertLog(['Browser']);
490
491
expect(recoverableErrors).toEqual([]);
492
+ expect(initializeReason).toHaveBeenCalledTimes(1);
493
expect(getVisibleChildren(container)).toEqual(
494
<div>
495
<span>Browser</span>
503
const serverReady = new Promise(resolve => {
504
resolveServerReady = resolve;
505
});
506
+ const initializeReason = jest.fn(
507
+ () => 'Only render this content in a browser',
508
+ );
509
510
function BrowserOnly() {
511
use(serverReady);
495
- use(ReactDOM.browser());
512
+ use(ReactDOM.browser(initializeReason));
513
return <span>Browser</span>;
514
}
515
524
}
525
526
const serverErrors = [];
527
+ const browserBailouts = [];
528
await act(() => {
529
const {pipe} = renderToPipeableStream(<App />, {
530
onError(error) {
531
serverErrors.push(error);
532
},
533
+ onBrowserBailout(error) {
534
+ browserBailouts.push(error);
535
+ },
536
});
537
pipe(writable);
538
});
548
});
549
550
expect(serverErrors).toEqual([]);
551
+ expect(initializeReason).toHaveBeenCalledTimes(1);
552
+ expect(browserBailouts).toHaveLength(1);
553
+ expect(browserBailouts[0].message).toBe(
554
+ 'Browser-only rendering was requested by `browser()`.',
555
+ );
556
+ expect(browserBailouts[0].stack).toContain('BrowserOnly');
557
+ expect(browserBailouts[0].cause).toBe(
558
+ 'Only render this content in a browser',
559
+ );
560
561
const recoverableErrors = [];
562
ReactDOMClient.hydrateRoot(container, <App />, {
567
await waitForAll([]);
568
569
expect(recoverableErrors).toEqual([]);
570
+ expect(initializeReason).toHaveBeenCalledTimes(1);
571
expect(getVisibleChildren(container)).toEqual(
572
<div>
573
<span>Browser</span>
576
});
577
578
// @gate enableBrowserAPI
548
- it('errors if browser-only content is rendered outside Suspense', async () => {
549
- function createBrowserValue() {
550
- return ReactDOM.browser();
579
+ it('supports omitted and direct string browser reasons', async () => {
580
+ const directReason = 'Only render this content in a browser';
581
+ const withoutReason = ReactDOM.browser();
582
+ const withDirectReason = ReactDOM.browser(directReason);
583
+
584
+ function WithoutReason() {
585
+ use(withoutReason);
586
+ return <span>Browser</span>;
587
}
552
- const browserValue = createBrowserValue();
588
+
589
+ function WithDirectReason() {
590
+ use(withDirectReason);
591
+ return <span>Browser</span>;
592
+ }
593
+
594
+ const serverErrors = [];
595
+ const browserBailouts = [];
596
+ await act(() => {
597
+ const {pipe} = renderToPipeableStream(
598
+ <>
599
+ <Suspense fallback={<span>Fallback A</span>}>
600
+ <WithoutReason />
601
+ </Suspense>
602
+ <Suspense fallback={<span>Fallback B</span>}>
603
+ <WithDirectReason />
604
+ </Suspense>
605
+ </>,
606
+ {
607
+ onError(error) {
608
+ serverErrors.push(error);
609
+ },
610
+ onBrowserBailout(error) {
611
+ browserBailouts.push(error);
612
+ },
613
+ },
614
+ );
615
+ pipe(writable);
616
+ });
617
+
618
+ expect(serverErrors).toEqual([]);
619
+ expect(browserBailouts).toHaveLength(2);
620
+ expect(browserBailouts[0].message).toBe(
621
+ 'Browser-only rendering was requested by `browser()`.',
622
+ );
623
+ expect(browserBailouts[0].stack).toContain('WithoutReason');
624
+ expect(
625
+ Object.prototype.hasOwnProperty.call(browserBailouts[0], 'cause'),
626
+ ).toBe(false);
627
+ expect(browserBailouts[1].message).toBe(
628
+ 'Browser-only rendering was requested by `browser()`.',
629
+ );
630
+ expect(browserBailouts[1].stack).toContain('WithDirectReason');
631
+ expect(browserBailouts[1].cause).toBe(directReason);
632
+ });
633
+
634
+ // @gate enableBrowserAPI
635
+ it('supports any value returned by a browser reason initializer', async () => {
636
+ const reasonValues = [undefined, null, 42, Symbol('browser reason')];
637
+ const initializeReasons = reasonValues.map(reason => jest.fn(() => reason));
638
+ const browserValues = initializeReasons.map(initializeReason =>
639
+ ReactDOM.browser(initializeReason),
640
+ );
641
+
642
+ function BrowserOnly({browserValue}) {
643
+ use(browserValue);
644
+ return <span>Browser</span>;
645
+ }
646
+
647
+ const serverErrors = [];
648
+ const browserBailouts = [];
649
+ await act(() => {
650
+ const {pipe} = renderToPipeableStream(
651
+ <>
652
+ {browserValues.map((browserValue, index) => (
653
+ <Suspense key={index} fallback={<span>Fallback</span>}>
654
+ <BrowserOnly browserValue={browserValue} />
655
+ </Suspense>
656
+ ))}
657
+ </>,
658
+ {
659
+ onError(error) {
660
+ serverErrors.push(error);
661
+ },
662
+ onBrowserBailout(error) {
663
+ browserBailouts.push(error);
664
+ },
665
+ },
666
+ );
667
+ pipe(writable);
668
+ });
669
+
670
+ expect(serverErrors).toEqual([]);
671
+ expect(browserBailouts).toHaveLength(reasonValues.length);
672
+ initializeReasons.forEach(initializeReason => {
673
+ expect(initializeReason).toHaveBeenCalledTimes(1);
674
+ });
675
+ browserBailouts.forEach((error, index) => {
676
+ expect(error).toBeInstanceOf(Error);
677
+ expect(error.message).toBe(
678
+ 'Browser-only rendering was requested by `browser()`.',
679
+ );
680
+ expect(Object.prototype.hasOwnProperty.call(error, 'cause')).toBe(true);
681
+ expect(error.cause).toBe(reasonValues[index]);
682
+ });
683
+ });
684
+
685
+ // @gate enableBrowserAPI
686
+ it('initializes a shared browser reason at each use site', async () => {
687
+ const browserReasons = [];
688
+ const initializeReason = jest.fn(() => {
689
+ const browserReason = {index: browserReasons.length};
690
+ browserReasons.push(browserReason);
691
+ return browserReason;
692
+ });
693
+ const browserValue = ReactDOM.browser(initializeReason);
694
+
695
+ function BrowserOnlyA() {
696
+ use(browserValue);
697
+ return <span>Browser A</span>;
698
+ }
699
+
700
+ function BrowserOnlyB() {
701
+ use(browserValue);
702
+ return <span>Browser B</span>;
703
+ }
704
+
705
+ const browserBailouts = [];
706
+ await act(() => {
707
+ const {pipe} = renderToPipeableStream(
708
+ <>
709
+ <Suspense fallback={<span>Fallback A</span>}>
710
+ <BrowserOnlyA />
711
+ </Suspense>
712
+ <Suspense fallback={<span>Fallback B</span>}>
713
+ <BrowserOnlyB />
714
+ </Suspense>
715
+ </>,
716
+ {
717
+ onBrowserBailout(error) {
718
+ browserBailouts.push(error);
719
+ },
720
+ },
721
+ );
722
+ pipe(writable);
723
+ });
724
+
725
+ expect(initializeReason).toHaveBeenCalledTimes(2);
726
+ expect(browserBailouts).toHaveLength(2);
727
+ expect(browserBailouts[0]).not.toBe(browserBailouts[1]);
728
+ expect(browserBailouts[0].cause).toBe(browserReasons[0]);
729
+ expect(browserBailouts[0].stack).toContain('BrowserOnlyA');
730
+ expect(browserBailouts[1].cause).toBe(browserReasons[1]);
731
+ expect(browserBailouts[1].stack).toContain('BrowserOnlyB');
732
+ });
733
+
734
+ // @gate enableBrowserAPI
735
+ it('uses a fallback if a browser reason initializer throws', async () => {
736
+ const reasonError = new Error('Failed to initialize browser reason');
737
+ const initializeReason = jest.fn(() => {
738
+ throw reasonError;
739
+ });
740
+ const browserValue = ReactDOM.browser(initializeReason);
741
+
742
+ function BrowserOnly() {
743
+ use(browserValue);
744
+ return <span>Browser</span>;
745
+ }
746
+
747
+ const serverErrors = [];
748
+ const browserBailouts = [];
749
+ await act(() => {
750
+ const {pipe} = renderToPipeableStream(
751
+ <Suspense fallback={<span>Fallback</span>}>
752
+ <BrowserOnly />
753
+ </Suspense>,
754
+ {
755
+ onError(error) {
756
+ serverErrors.push(error);
757
+ },
758
+ onBrowserBailout(error) {
759
+ browserBailouts.push(error);
760
+ },
761
+ },
762
+ );
763
+ pipe(writable);
764
+ });
765
+
766
+ expect(initializeReason).toHaveBeenCalledTimes(1);
767
+ expect(serverErrors).toEqual([]);
768
+ expect(browserBailouts).toHaveLength(1);
769
+ expect(browserBailouts[0].cause).toBe(
770
+ 'The reason for browser-only rendering could not be determined because ' +
771
+ 'its initializer threw.',
772
+ );
773
+ expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
774
+ });
775
+
776
+ // @gate enableBrowserAPI
777
+ it('errors if browser-only content is rendered outside Suspense', async () => {
778
+ const browserReason = 'Only render this content in a browser';
779
+ const browserValue = ReactDOM.browser(browserReason);
780
781
function BrowserOnly() {
782
use(browserValue);
810
"requested outside a Suspense boundary. See this error's cause for " +
811
'additional details.',
812
);
813
+ expect(shellError.cause).toBe(browserReason);
814
expect(shellError.stack).toContain('BrowserOnly');
587
- expect(shellError.cause).toBe(browserValue);
588
- expect(shellError.cause.stack).toContain('createBrowserValue');
815
+ expect(shellError.stack.split('\n')[0]).toBe(
816
+ 'Error: ' + shellError.message,
817
+ );
818
expect(shellReady).toBe(false);
819
expect(reportedErrors).toEqual([shellError]);
820
expect(browserBailouts).toEqual([]);
848
849
const serverErrors = [];
850
const browserBailouts = [];
622
- const browserValue = ReactDOM.browser();
851
+ const browserReason = {code: 'render-pending-content-in-browser'};
852
+ const initializeReason = jest.fn(() => browserReason);
853
+ const browserValue = ReactDOM.browser(initializeReason);
854
let abort;
855
await act(() => {
856
const controls = renderToPipeableStream(<App />, {
874
);
875
876
await act(() => {
646
- abort(browserValue);
877
+ function abortToBrowser() {
878
+ abort(browserValue);
879
+ }
880
+ abortToBrowser();
881
});
882
883
expect(serverErrors).toEqual([]);
650
- expect(browserBailouts).toEqual([browserValue, browserValue]);
884
+ expect(initializeReason).toHaveBeenCalledTimes(1);
885
+ expect(browserBailouts).toHaveLength(2);
886
+ expect(browserBailouts[0]).toBeInstanceOf(Error);
887
+ expect(browserBailouts[0].message).toBe(
888
+ 'Browser-only rendering was requested by `browser()`.',
889
+ );
890
+ expect(browserBailouts[0].stack).toContain('abortToBrowser');
891
+ expect(browserBailouts[0].cause).toBe(browserReason);
892
+ expect(browserBailouts[1]).toBe(browserBailouts[0]);
893
894
isClient = true;
895
const recoverableErrors = [];
913
// @gate enableBrowserAPI
914
it('errors if aborted with browser() before the shell completes', async () => {
915
const never = new Promise(() => {});
674
- const browserValue = ReactDOM.browser();
916
+ let browserReason;
917
+ const initializeReason = jest.fn(() => {
918
+ browserReason = new Error('Only abort this render on the server');
919
+ return browserReason;
920
+ });
921
+ const browserValue = ReactDOM.browser(initializeReason);
922
923
function PendingRoot() {
924
use(never);
949
});
950
951
await act(() => {
705
- abort(browserValue);
952
+ function abortToBrowser() {
953
+ abort(browserValue);
954
+ }
955
+ abortToBrowser();
956
});
957
958
expect(shellError).toBeInstanceOf(Error);
959
+ expect(initializeReason).toHaveBeenCalledTimes(1);
960
expect(shellError.message).toBe(
961
'The server render could not complete because client rendering was ' +
962
"requested outside a Suspense boundary. See this error's cause for " +
963
'additional details.',
964
);
714
- expect(shellError.cause).toBe(browserValue);
965
+ expect(shellError.cause).toBe(browserReason);
966
+ expect(shellError.stack).toContain('abortToBrowser');
967
expect(shellReady).toBe(false);
968
expect(reportedErrors).toEqual([shellError]);
969
expect(browserBailouts).toEqual([]);
970
});
971
972
+ // @gate enableBrowserAPI
973
+ it('reports nested browser bailouts if aborting fatals the shell', async () => {
974
+ const never = new Promise(() => {});
975
+ const browserReason = 'Abort pending work into browser rendering';
976
+ const browserValue = ReactDOM.browser(browserReason);
977
+
978
+ function Pending() {
979
+ use(never);
980
+ return <span>Pending</span>;
981
+ }
982
+
983
+ const reportedErrors = [];
984
+ const browserBailouts = [];
985
+ let shellError;
986
+ let abort;
987
+ await act(() => {
988
+ const controls = renderToPipeableStream(
989
+ <>
990
+ <Suspense fallback={<span>Fallback</span>}>
991
+ <Pending />
992
+ </Suspense>
993
+ <Pending />
994
+ <Suspense fallback={<span>Fallback</span>}>
995
+ <Pending />
996
+ </Suspense>
997
+ <Pending />
998
+ </>,
999
+ {
1000
+ onError(error) {
1001
+ reportedErrors.push(error);
1002
+ },
1003
+ onBrowserBailout(error) {
1004
+ browserBailouts.push(error);
1005
+ },
1006
+ onShellError(error) {
1007
+ shellError = error;
1008
+ },
1009
+ },
1010
+ );
1011
+ abort = controls.abort;
1012
+ });
1013
+
1014
+ await act(() => {
1015
+ abort(browserValue);
1016
+ });
1017
+
1018
+ expect(shellError).toBeInstanceOf(Error);
1019
+ expect(shellError.message).toBe(
1020
+ 'The server render could not complete because client rendering was ' +
1021
+ "requested outside a Suspense boundary. See this error's cause for " +
1022
+ 'additional details.',
1023
+ );
1024
+ expect(shellError.cause).toBe(browserReason);
1025
+ expect(reportedErrors).toHaveLength(2);
1026
+ expect(reportedErrors[0]).toBe(shellError);
1027
+ expect(reportedErrors[1].message).toBe(shellError.message);
1028
+ expect(reportedErrors[1].cause).toBe(browserReason);
1029
+ expect(browserBailouts).toHaveLength(2);
1030
+ expect(browserBailouts[0]).toBe(browserBailouts[1]);
1031
+ expect(browserBailouts[0]).not.toBe(shellError);
1032
+ expect(browserBailouts[0].message).toBe(
1033
+ 'Browser-only rendering was requested by `browser()`.',
1034
+ );
1035
+ expect(browserBailouts[0].cause).toBe(browserReason);
1036
+ });
1037
+
1038
+ // @gate enableBrowserAPI
1039
+ it('uses a fallback if a browser reason initializer throws during abort', async () => {
1040
+ const never = new Promise(() => {});
1041
+ const reasonError = new Error('Failed to initialize browser reason');
1042
+ const initializeReason = jest.fn(() => {
1043
+ throw reasonError;
1044
+ });
1045
+ const browserValue = ReactDOM.browser(initializeReason);
1046
+
1047
+ function PendingRoot() {
1048
+ use(never);
1049
+ return <span>Root</span>;
1050
+ }
1051
+
1052
+ const reportedErrors = [];
1053
+ const browserBailouts = [];
1054
+ let shellError;
1055
+ let abort;
1056
+ await act(() => {
1057
+ const controls = renderToPipeableStream(<PendingRoot />, {
1058
+ onError(error) {
1059
+ reportedErrors.push(error);
1060
+ },
1061
+ onBrowserBailout(error) {
1062
+ browserBailouts.push(error);
1063
+ },
1064
+ onShellError(error) {
1065
+ shellError = error;
1066
+ },
1067
+ });
1068
+ abort = controls.abort;
1069
+ });
1070
+
1071
+ await act(() => {
1072
+ abort(browserValue);
1073
+ });
1074
+
1075
+ expect(initializeReason).toHaveBeenCalledTimes(1);
1076
+ expect(shellError).toBeInstanceOf(Error);
1077
+ expect(shellError.cause).toBe(
1078
+ 'The reason for browser-only rendering could not be determined because ' +
1079
+ 'its initializer threw.',
1080
+ );
1081
+ expect(reportedErrors).toEqual([shellError]);
1082
+ expect(browserBailouts).toEqual([]);
1083
+ });
1084
+
1085
// @gate enableBrowserAPI
1086
it('reports the browser value if it is thrown instead of passed to use', async () => {
722
- const browserValue = ReactDOM.browser();
1087
+ const initializeReason = jest.fn(
1088
+ () => new Error('Only render this content in a browser'),
1089
+ );
1090
+ const browserValue = ReactDOM.browser(initializeReason);
1091
1092
function BrowserOnly() {
1093
throw browserValue;
1114
1115
expect(reportedErrors).toEqual([browserValue]);
1116
expect(browserBailouts).toEqual([]);
1117
+ expect(initializeReason).not.toHaveBeenCalled();
1118
expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
1119
});
1120
7974
expect(errors).toEqual(['abort reason', 'abort reason']);
7975
});
7976
7977
+ // @gate enableBrowserAPI
7978
+ it('reports an in-flight browser bailout after another root task fatals while aborting', async () => {
7979
+ const promise = new Promise(() => {});
7980
+ function SuspendedRoot() {
7981
+ use(promise);
7982
+ return null;
7983
+ }
7984
+
7985
+ function Child() {
7986
+ return 'child';
7987
+ }
7988
+
7989
+ const browserValue = ReactDOM.browser('abort reason');
7990
+ const abortRef = {current: null};
7991
+ function ComponentThatAborts() {
7992
+ abortRef.current(browserValue);
7993
+ return <Child />;
7994
+ }
7995
+
7996
+ const errors = [];
7997
+ const browserBailouts = [];
7998
+ let shellError;
7999
+ await act(() => {
8000
+ const {abort} = renderToPipeableStream(
8001
+ <>
8002
+ <SuspendedRoot />
8003
+ <Suspense fallback="loading...">
8004
+ <ComponentThatAborts />
8005
+ </Suspense>
8006
+ </>,
8007
+ {
8008
+ onError(error) {
8009
+ errors.push(error);
8010
+ },
8011
+ onBrowserBailout(error) {
8012
+ browserBailouts.push(error);
8013
+ },
8014
+ onShellError(error) {
8015
+ shellError = error;
8016
+ },
8017
+ },
8018
+ );
8019
+ abortRef.current = abort;
8020
+ });
8021
+
8022
+ expect(errors).toEqual([shellError]);
8023
+ expect(browserBailouts).toHaveLength(1);
8024
+ expect(browserBailouts[0]).not.toBe(shellError);
8025
+ expect(browserBailouts[0].message).toBe(
8026
+ 'Browser-only rendering was requested by `browser()`.',
8027
+ );
8028
+ expect(browserBailouts[0].cause).toBe('abort reason');
8029
+ });
8030
+
8031
it('reports a root task before rendering a suspended child returned after aborting', async () => {
8032
const promise = new Promise(() => {});
8033
function SuspendedRoot() {