89
});
90
});
91
92
+ function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
93
+ const mappedErrows = errorsArr.map(error => {
94
+ if (error.componentStack) {
95
+ return [
96
+ error.message,
97
+ error.hash,
98
+ normalizeCodeLocInfo(error.componentStack),
99
+ ];
100
+ } else if (error.hash) {
101
+ return [error.message, error.hash];
102
+ }
103
+ return error.message;
104
+ });
105
+ if (__DEV__) {
106
+ expect(mappedErrows).toEqual(
107
+ toBeDevArr,
108
+ // .map(([errorMessage, errorHash, errorComponentStack]) => {
109
+ // if (typeof error === 'string' || error instanceof String) {
110
+ // return error;
111
+ // }
112
+ // let str = JSON.stringify(error).replace(/\\n/g, '\n');
113
+ // // this gets stripped away by normalizeCodeLocInfo...
114
+ // // Kind of hacky but lets strip it away here too just so they match...
115
+ // // easier than fixing the regex to account for this edge case
116
+ // if (str.endsWith('at **)"}')) {
117
+ // str = str.replace(/at \*\*\)\"}$/, 'at **)');
118
+ // }
119
+ // return str;
120
+ // }),
121
+ );
122
+ } else {
123
+ expect(mappedErrows).toEqual(toBeProdArr);
124
+ }
125
+ }
126
+
127
+ // @TODO we will use this in a followup change once we start exposing componentStacks from server errors
128
+ // function componentStack(components) {
129
+ // return components
130
+ // .map(component => `\n in ${component} (at **)`)
131
+ // .join('');
132
+ // }
133
+
134
async function act(callback) {
135
await callback();
136
// Await one turn around the event loop.
455
});
456
});
457
416
- const loggedErrors = [];
417
-
458
function App({isClient}) {
459
return (
460
<div>
466
}
467
468
let bootstrapped = false;
469
+ const errors = [];
470
window.__INIT__ = function() {
471
bootstrapped = true;
472
// Attempt to hydrate the content.
473
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
474
onRecoverableError(error) {
434
- Scheduler.unstable_yieldValue(error.message);
475
+ errors.push(error);
476
},
477
});
478
};
479
480
+ const theError = new Error('Test');
481
+ const loggedErrors = [];
482
+ function onError(x) {
483
+ loggedErrors.push(x);
484
+ return 'Hash of (' + x.message + ')';
485
+ }
486
+ // const expectedHash = onError(theError);
487
+ // loggedErrors.length = 0;
488
+
489
await act(async () => {
490
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
491
<App isClient={false} />,
492
{
493
bootstrapScriptContent: '__INIT__();',
444
- onError(x) {
445
- loggedErrors.push(x);
446
- },
494
+ onError,
495
},
496
);
497
pipe(writable);
506
507
expect(loggedErrors).toEqual([]);
508
461
- const theError = new Error('Test');
509
await act(async () => {
510
rejectComponent(theError);
511
});
516
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
517
518
// Now we can client render it instead.
472
- expect(Scheduler).toFlushAndYield([
473
- 'The server could not finish this Suspense boundary, likely due to ' +
474
- 'an error during server rendering. Switched to client rendering.',
475
- ]);
519
+ expect(Scheduler).toFlushAndYield([]);
520
+ expectErrors(
521
+ errors,
522
+ [theError.message],
523
+ [
524
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
525
+ ],
526
+ );
527
528
// The client rendered HTML is now in place.
529
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
571
});
572
});
573
574
+ const theError = new Error('Test');
575
const loggedErrors = [];
576
+ function onError(x) {
577
+ loggedErrors.push(x);
578
+ return 'hash of (' + x.message + ')';
579
+ }
580
+ // const expectedHash = onError(theError);
581
+ // loggedErrors.length = 0;
582
583
function App({isClient}) {
584
return (
595
<App isClient={false} />,
596
597
{
540
- onError(x) {
541
- loggedErrors.push(x);
542
- },
598
+ onError,
599
},
600
);
601
pipe(writable);
602
});
603
expect(loggedErrors).toEqual([]);
604
605
+ const errors = [];
606
// Attempt to hydrate the content.
607
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
608
onRecoverableError(error) {
552
- Scheduler.unstable_yieldValue(error.message);
609
+ errors.push(error);
610
},
611
});
612
Scheduler.unstable_flushAll();
616
617
expect(loggedErrors).toEqual([]);
618
562
- const theError = new Error('Test');
619
await act(async () => {
620
rejectElement(theError);
621
});
626
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
627
628
// Now we can client render it instead.
573
- expect(Scheduler).toFlushAndYield([
574
- 'The server could not finish this Suspense boundary, likely due to ' +
575
- 'an error during server rendering. Switched to client rendering.',
576
- ]);
629
+ expect(Scheduler).toFlushAndYield([]);
630
+
631
+ expectErrors(
632
+ errors,
633
+ [theError.message],
634
+ [
635
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
636
+ ],
637
+ );
638
639
// The client rendered HTML is now in place.
579
- expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
640
+ // expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
641
+
642
+ expect(loggedErrors).toEqual([theError]);
643
+ });
644
+
645
+ // @gate experimental
646
+ it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
647
+ function Indirection({level, children}) {
648
+ if (level > 0) {
649
+ return <Indirection level={level - 1}>{children}</Indirection>;
650
+ }
651
+ return children;
652
+ }
653
+
654
+ const theError = new Error('uh oh');
655
+
656
+ function Erroring({isClient}) {
657
+ if (isClient) {
658
+ return 'Hello World';
659
+ }
660
+ throw theError;
661
+ }
662
+
663
+ function App({isClient}) {
664
+ return (
665
+ <div>
666
+ <Suspense fallback={<span>loading...</span>}>
667
+ <Erroring isClient={isClient} />
668
+ </Suspense>
669
+ </div>
670
+ );
671
+ }
672
+
673
+ const loggedErrors = [];
674
+ function onError(x) {
675
+ loggedErrors.push(x);
676
+ return 'hash(' + x.message + ')';
677
+ }
678
+ // const expectedHash = onError(theError);
679
+ // loggedErrors.length = 0;
680
+
681
+ await act(async () => {
682
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
683
+ <App />,
684
685
+ {
686
+ onError,
687
+ },
688
+ );
689
+ pipe(writable);
690
+ });
691
+ expect(loggedErrors).toEqual([theError]);
692
+
693
+ const errors = [];
694
+ // Attempt to hydrate the content.
695
+ ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
696
+ onRecoverableError(error) {
697
+ errors.push(error);
698
+ },
699
+ });
700
+ Scheduler.unstable_flushAll();
701
+
702
+ expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
703
+
704
+ expectErrors(
705
+ errors,
706
+ [theError.message],
707
+ [
708
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
709
+ ],
710
+ );
711
+ });
712
+
713
+ // @gate experimental
714
+ it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
715
+ let rejectComponent;
716
+ const LazyComponent = React.lazy(() => {
717
+ return new Promise((resolve, reject) => {
718
+ rejectComponent = reject;
719
+ });
720
+ });
721
+
722
+ function App({isClient}) {
723
+ return (
724
+ <div>
725
+ <Suspense fallback={<Text text="Loading..." />}>
726
+ {isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
727
+ </Suspense>
728
+ </div>
729
+ );
730
+ }
731
+
732
+ const loggedErrors = [];
733
+ const theError = new Error('uh oh');
734
+ function onError(x) {
735
+ loggedErrors.push(x);
736
+ return 'hash(' + x.message + ')';
737
+ }
738
+ // const expectedHash = onError(theError);
739
+ // loggedErrors.length = 0;
740
+
741
+ await act(async () => {
742
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
743
+ <App />,
744
+
745
+ {
746
+ onError,
747
+ },
748
+ );
749
+ pipe(writable);
750
+ });
751
+ expect(loggedErrors).toEqual([]);
752
+
753
+ const errors = [];
754
+ // Attempt to hydrate the content.
755
+ ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
756
+ onRecoverableError(error) {
757
+ errors.push(error);
758
+ },
759
+ });
760
+ Scheduler.unstable_flushAll();
761
+
762
+ expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
763
+
764
+ await act(async () => {
765
+ rejectComponent(theError);
766
+ });
767
+
768
+ expect(loggedErrors).toEqual([theError]);
769
+ expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
770
+
771
+ // Now we can client render it instead.
772
+ expect(Scheduler).toFlushAndYield([]);
773
+
774
+ expectErrors(
775
+ errors,
776
+ [theError.message],
777
+ [
778
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
779
+ ],
780
+ );
781
+
782
+ // The client rendered HTML is now in place.
783
+ expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
784
expect(loggedErrors).toEqual([theError]);
785
});
786
1052
);
1053
}
1054
1055
+ const loggedErrors = [];
1056
+ function onError(error) {
1057
+ loggedErrors.push(error);
1058
+ return `Hash of (${error.message})`;
1059
+ }
1060
+
1061
let controls;
1062
await act(async () => {
854
- controls = ReactDOMFizzServer.renderToPipeableStream(<App />);
1063
+ controls = ReactDOMFizzServer.renderToPipeableStream(<App />, {onError});
1064
controls.pipe(writable);
1065
});
1066
1067
// We're still showing a fallback.
1068
1069
+ const errors = [];
1070
// Attempt to hydrate the content.
1071
ReactDOMClient.hydrateRoot(container, <App />, {
1072
onRecoverableError(error) {
863
- Scheduler.unstable_yieldValue(error.message);
1073
+ errors.push(error);
1074
},
1075
});
1076
Scheduler.unstable_flushAll();
1084
});
1085
1086
// We still can't render it on the client.
877
- expect(Scheduler).toFlushAndYield([
878
- 'The server could not finish this Suspense boundary, likely due to an ' +
879
- 'error during server rendering. Switched to client rendering.',
880
- ]);
1087
+ expect(Scheduler).toFlushAndYield([]);
1088
+ expectErrors(
1089
+ errors,
1090
+ ['This Suspense boundary was aborted by the server'],
1091
+ [
1092
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1093
+ ],
1094
+ );
1095
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1096
1097
// We now resolve it on the client.
1749
);
1750
}
1751
1752
+ const theError = new Error('Test');
1753
const loggedErrors = [];
1754
+ function onError(x) {
1755
+ loggedErrors.push(x);
1756
+ return `hash of (${x.message})`;
1757
+ }
1758
+ // const expectedHash = onError(theError);
1759
+ // loggedErrors.length = 0;
1760
+
1761
let controls;
1762
await act(async () => {
1763
controls = ReactDOMFizzServer.renderToPipeableStream(
1764
<App isClient={false} />,
1765
1766
{
1545
- onError(x) {
1546
- loggedErrors.push(x);
1547
- },
1767
+ onError,
1768
},
1769
);
1770
controls.pipe(writable);
1772
1773
// We're still showing a fallback.
1774
1775
+ const errors = [];
1776
// Attempt to hydrate the content.
1777
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
1778
onRecoverableError(error) {
1558
- Scheduler.unstable_yieldValue(error.message);
1779
+ errors.push(error);
1780
},
1781
});
1782
Scheduler.unstable_flushAll();
1786
1787
expect(loggedErrors).toEqual([]);
1788
1568
- const theError = new Error('Test');
1789
// Error the content, but we don't have a fallback yet.
1790
await act(async () => {
1791
rejectText('Hello', theError);
1806
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
1807
1808
// That will let us client render it instead.
1589
- expect(Scheduler).toFlushAndYield([
1590
- 'The server could not finish this Suspense boundary, likely due to ' +
1591
- 'an error during server rendering. Switched to client rendering.',
1592
- ]);
1809
+ expect(Scheduler).toFlushAndYield([]);
1810
+ expectErrors(
1811
+ errors,
1812
+ [theError.message],
1813
+ [
1814
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1815
+ ],
1816
+ );
1817
1818
// The client rendered HTML is now in place.
1819
expect(getVisibleChildren(container)).toEqual(
2402
2403
// Hydrate the tree. Child will throw during render.
2404
isClient = true;
2405
+ const errors = [];
2406
ReactDOMClient.hydrateRoot(container, <App />, {
2407
onRecoverableError(error) {
2183
- Scheduler.unstable_yieldValue(
2184
- 'Log recoverable error: ' + error.message,
2185
- );
2408
+ errors.push(error.message);
2409
},
2410
});
2411
2413
// shouldn't be called.
2414
expect(Scheduler).toFlushAndYield([]);
2415
expect(getVisibleChildren(container)).toEqual('Oops!');
2416
+
2417
+ expectErrors(errors, [], []);
2418
},
2419
);
2420
3019
);
3020
});
3021
3022
+ describe('error escaping', () => {
3023
+ //@gate experimental
3024
+ it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
3025
+ window.__outlet = {};
3026
+
3027
+ const dangerousErrorString =
3028
+ '"></template></div><script>window.__outlet.message="from error"</script><div><template data-foo="';
3029
+
3030
+ function Erroring() {
3031
+ throw new Error(dangerousErrorString);
3032
+ }
3033
+
3034
+ // We can't test newline in component stacks because the stack always takes just one line and we end up
3035
+ // dropping the first part including the \n character
3036
+ Erroring.displayName =
3037
+ 'DangerousName' +
3038
+ dangerousErrorString.replace(
3039
+ 'message="from error"',
3040
+ 'stack="from_stack"',
3041
+ );
3042
+
3043
+ function App() {
3044
+ return (
3045
+ <div>
3046
+ <Suspense fallback={<div>Loading...</div>}>
3047
+ <Erroring />
3048
+ </Suspense>
3049
+ </div>
3050
+ );
3051
+ }
3052
+
3053
+ function onError(x) {
3054
+ return `dangerous hash ${x.message.replace(
3055
+ 'message="from error"',
3056
+ 'hash="from hash"',
3057
+ )}`;
3058
+ }
3059
+
3060
+ await act(async () => {
3061
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3062
+ onError,
3063
+ });
3064
+ pipe(writable);
3065
+ });
3066
+ expect(window.__outlet).toEqual({});
3067
+ });
3068
+ //@gate experimental
3069
+ it('escapes error hash, message, and component stack values in clientRenderInstruction (javascript escaping)', async () => {
3070
+ window.__outlet = {};
3071
+
3072
+ const dangerousErrorString =
3073
+ '");window.__outlet.message="from error";</script><script>(() => {})("';
3074
+
3075
+ let rejectComponent;
3076
+ const SuspensyErroring = React.lazy(() => {
3077
+ return new Promise((resolve, reject) => {
3078
+ rejectComponent = reject;
3079
+ });
3080
+ });
3081
+
3082
+ // We can't test newline in component stacks because the stack always takes just one line and we end up
3083
+ // dropping the first part including the \n character
3084
+ SuspensyErroring.displayName =
3085
+ 'DangerousName' +
3086
+ dangerousErrorString.replace(
3087
+ 'message="from error"',
3088
+ 'stack="from_stack"',
3089
+ );
3090
+
3091
+ function App() {
3092
+ return (
3093
+ <div>
3094
+ <Suspense fallback={<div>Loading...</div>}>
3095
+ <SuspensyErroring />
3096
+ </Suspense>
3097
+ </div>
3098
+ );
3099
+ }
3100
+
3101
+ function onError(x) {
3102
+ return `dangerous hash ${x.message.replace(
3103
+ 'message="from error"',
3104
+ 'hash="from hash"',
3105
+ )}`;
3106
+ }
3107
+
3108
+ await act(async () => {
3109
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3110
+ onError,
3111
+ });
3112
+ pipe(writable);
3113
+ });
3114
+
3115
+ await act(() => {
3116
+ rejectComponent(new Error(dangerousErrorString));
3117
+ });
3118
+ expect(window.__outlet).toEqual({});
3119
+ });
3120
+ //@gate experimental
3121
+ it('escapes such that attributes cannot be masked', async () => {
3122
+ const dangerousErrorString = '" data-msg="bad message" data-foo="';
3123
+ const theError = new Error(dangerousErrorString);
3124
+
3125
+ function Erroring({isClient}) {
3126
+ if (isClient) return 'Hello';
3127
+ throw theError;
3128
+ }
3129
+
3130
+ function App({isClient}) {
3131
+ return (
3132
+ <div>
3133
+ <Suspense fallback={<div>Loading...</div>}>
3134
+ <Erroring isClient={isClient} />
3135
+ </Suspense>
3136
+ </div>
3137
+ );
3138
+ }
3139
+
3140
+ const loggedErrors = [];
3141
+ function onError(x) {
3142
+ loggedErrors.push(x);
3143
+ return x.message.replace('bad message', 'bad hash');
3144
+ }
3145
+ // const expectedHash = onError(theError);
3146
+ // loggedErrors.length = 0;
3147
+
3148
+ await act(async () => {
3149
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3150
+ onError,
3151
+ });
3152
+ pipe(writable);
3153
+ });
3154
+
3155
+ expect(loggedErrors).toEqual([theError]);
3156
+
3157
+ const errors = [];
3158
+ ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
3159
+ onRecoverableError(error) {
3160
+ errors.push(error);
3161
+ },
3162
+ });
3163
+ expect(Scheduler).toFlushAndYield([]);
3164
+
3165
+ // If escaping were not done we would get a message that says "bad hash"
3166
+ expectErrors(
3167
+ errors,
3168
+ [theError.message],
3169
+ [
3170
+ 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3171
+ ],
3172
+ );
3173
+ });
3174
+ });
3175
+
3176
describe('bootstrapScriptContent escaping', () => {
3177
// @gate experimental
3178
it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {