Skip to content

Commit f88fff1

Browse files
committed
Prod can now send error messages along with a hash, however this is currently only used when the server aborts
On the client, the error messages received is used or a generic fallback error message is used. In all environments the hash is attached to the error if found. In Dev, the component stack will be included as well. This gives us more options to inspect the error in onRecoverableError on the client while unifying the code paths in dev/prod
1 parent 036afda commit f88fff1

File tree

6 files changed

+173
-162
lines changed

6 files changed

+173
-162
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -90,24 +90,37 @@ describe('ReactDOMFizzServer', () => {
9090
});
9191

9292
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+
});
93105
if (__DEV__) {
94-
expect(errorsArr.map(error => normalizeCodeLocInfo(error))).toEqual(
95-
toBeDevArr.map(error => {
96-
if (typeof error === 'string' || error instanceof String) {
97-
return error;
98-
}
99-
let str = JSON.stringify(error).replace(/\\n/g, '\n');
100-
// this gets stripped away by normalizeCodeLocInfo...
101-
// Kind of hacky but lets strip it away here too just so they match...
102-
// easier than fixing the regex to account for this edge case
103-
if (str.endsWith('at **)"}')) {
104-
str = str.replace(/at \*\*\)\"}$/, 'at **)');
105-
}
106-
return str;
107-
}),
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+
// }),
108121
);
109122
} else {
110-
expect(errorsArr).toEqual(toBeProdArr);
123+
expect(mappedErrows).toEqual(toBeProdArr);
111124
}
112125
}
113126

@@ -453,7 +466,7 @@ describe('ReactDOMFizzServer', () => {
453466
// Attempt to hydrate the content.
454467
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
455468
onRecoverableError(error) {
456-
errors.push(error.message);
469+
errors.push(error);
457470
},
458471
});
459472
};
@@ -501,12 +514,18 @@ describe('ReactDOMFizzServer', () => {
501514
expectErrors(
502515
errors,
503516
[
504-
theError.message +
505-
'\nServer Error Hash: ' +
506-
expectedHash +
517+
[
518+
theError.message,
519+
expectedHash,
507520
componentStack(['Lazy', 'Suspense', 'div', 'App']),
521+
],
522+
],
523+
[
524+
[
525+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
526+
expectedHash,
527+
],
508528
],
509-
[expectedHash],
510529
);
511530

512531
// The client rendered HTML is now in place.
@@ -590,7 +609,7 @@ describe('ReactDOMFizzServer', () => {
590609
// Attempt to hydrate the content.
591610
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
592611
onRecoverableError(error) {
593-
errors.push(error.message);
612+
errors.push(error);
594613
},
595614
});
596615
Scheduler.unstable_flushAll();
@@ -615,12 +634,18 @@ describe('ReactDOMFizzServer', () => {
615634
expectErrors(
616635
errors,
617636
[
618-
theError.message +
619-
'\nServer Error Hash: ' +
620-
expectedHash +
637+
[
638+
theError.message,
639+
expectedHash,
621640
componentStack(['~lazy-element~', 'Suspense', 'div', 'App']),
641+
],
642+
],
643+
[
644+
[
645+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
646+
expectedHash,
647+
],
622648
],
623-
[expectedHash],
624649
);
625650

626651
// The client rendered HTML is now in place.
@@ -681,7 +706,7 @@ describe('ReactDOMFizzServer', () => {
681706
// Attempt to hydrate the content.
682707
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
683708
onRecoverableError(error) {
684-
errors.push(error.message);
709+
errors.push(error);
685710
},
686711
});
687712
Scheduler.unstable_flushAll();
@@ -691,12 +716,18 @@ describe('ReactDOMFizzServer', () => {
691716
expectErrors(
692717
errors,
693718
[
694-
theError.message +
695-
'\nServer Error Hash: ' +
696-
expectedHash +
719+
[
720+
theError.message,
721+
expectedHash,
697722
componentStack(['Erroring', 'Suspense', 'div', 'App']),
723+
],
724+
],
725+
[
726+
[
727+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
728+
expectedHash,
729+
],
698730
],
699-
[expectedHash],
700731
);
701732
});
702733

@@ -744,7 +775,7 @@ describe('ReactDOMFizzServer', () => {
744775
// Attempt to hydrate the content.
745776
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
746777
onRecoverableError(error) {
747-
errors.push(error.message);
778+
errors.push(error);
748779
},
749780
});
750781
Scheduler.unstable_flushAll();
@@ -764,12 +795,18 @@ describe('ReactDOMFizzServer', () => {
764795
expectErrors(
765796
errors,
766797
[
767-
theError.message +
768-
'\nServer Error Hash: ' +
769-
expectedHash +
798+
[
799+
theError.message,
800+
expectedHash,
770801
componentStack(['Lazy', 'Suspense', 'div', 'App']),
802+
],
803+
],
804+
[
805+
[
806+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
807+
expectedHash,
808+
],
771809
],
772-
[expectedHash],
773810
);
774811

775812
// The client rendered HTML is now in place.
@@ -1057,7 +1094,7 @@ describe('ReactDOMFizzServer', () => {
10571094
// Attempt to hydrate the content.
10581095
ReactDOMClient.hydrateRoot(container, <App />, {
10591096
onRecoverableError(error) {
1060-
errors.push(error.message);
1097+
errors.push(error);
10611098
},
10621099
});
10631100
Scheduler.unstable_flushAll();
@@ -1761,7 +1798,7 @@ describe('ReactDOMFizzServer', () => {
17611798
// Attempt to hydrate the content.
17621799
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
17631800
onRecoverableError(error) {
1764-
errors.push(error.message);
1801+
errors.push(error);
17651802
},
17661803
});
17671804
Scheduler.unstable_flushAll();
@@ -1795,9 +1832,9 @@ describe('ReactDOMFizzServer', () => {
17951832
expectErrors(
17961833
errors,
17971834
[
1798-
theError.message +
1799-
'\nServer Error Hash: ' +
1800-
expectedHash +
1835+
[
1836+
theError.message,
1837+
expectedHash,
18011838
componentStack([
18021839
'AsyncText',
18031840
'h1',
@@ -1806,8 +1843,14 @@ describe('ReactDOMFizzServer', () => {
18061843
'Suspense',
18071844
'App',
18081845
]),
1846+
],
1847+
],
1848+
[
1849+
[
1850+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1851+
expectedHash,
1852+
],
18091853
],
1810-
[expectedHash],
18111854
);
18121855

18131856
// The client rendered HTML is now in place.
@@ -3114,8 +3157,6 @@ describe('ReactDOMFizzServer', () => {
31143157
});
31153158
//@gate experimental
31163159
it('escapes such that attributes cannot be masked', async () => {
3117-
window.__outlet = {};
3118-
31193160
const dangerousErrorString = '" data-msg="bad message" data-foo="';
31203161
const theError = new Error(dangerousErrorString);
31213162

@@ -3154,7 +3195,7 @@ describe('ReactDOMFizzServer', () => {
31543195
const errors = [];
31553196
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
31563197
onRecoverableError(error) {
3157-
errors.push(error.message);
3198+
errors.push(error);
31583199
},
31593200
});
31603201
expect(Scheduler).toFlushAndYield([]);
@@ -3163,12 +3204,18 @@ describe('ReactDOMFizzServer', () => {
31633204
expectErrors(
31643205
errors,
31653206
[
3166-
theError.message +
3167-
'\nServer Error Hash: ' +
3168-
expectedHash +
3207+
[
3208+
theError.message,
3209+
expectedHash,
31693210
componentStack(['Erroring', 'Suspense', 'div', 'App']),
3211+
],
3212+
],
3213+
[
3214+
[
3215+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3216+
expectedHash,
3217+
],
31703218
],
3171-
[expectedHash],
31723219
);
31733220
});
31743221
});

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

Lines changed: 28 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,10 +1506,10 @@ const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');
15061506
const clientRenderedSuspenseBoundaryError1 = stringToPrecomputedChunk(
15071507
'<template data-hash="',
15081508
);
1509-
const clientRenderedSuspenseBoundaryErrorDevA = stringToPrecomputedChunk(
1509+
const clientRenderedSuspenseBoundaryError1A = stringToPrecomputedChunk(
15101510
'" data-msg="',
15111511
);
1512-
const clientRenderedSuspenseBoundaryErrorDevB = stringToPrecomputedChunk(
1512+
const clientRenderedSuspenseBoundaryError1B = stringToPrecomputedChunk(
15131513
'" data-stack="',
15141514
);
15151515
const clientRenderedSuspenseBoundaryError2 = stringToPrecomputedChunk(
@@ -1565,17 +1565,19 @@ export function writeStartClientRenderedSuspenseBoundary(
15651565
if (errorHash) {
15661566
writeChunk(destination, clientRenderedSuspenseBoundaryError1);
15671567
writeChunk(destination, stringToChunk(escapeTextForBrowser(errorHash)));
1568+
// In prod errorMessage will usually be nullish but there is one case where
1569+
// it is used (currently when the server aborts the task) so we leave it ungated.
1570+
if (errorMesssage) {
1571+
writeChunk(destination, clientRenderedSuspenseBoundaryError1A);
1572+
writeChunk(
1573+
destination,
1574+
stringToChunk(escapeTextForBrowser(errorMesssage)),
1575+
);
1576+
}
15681577
if (__DEV__) {
1569-
// in dev send message and component stack if they exist
1570-
if (errorMesssage) {
1571-
writeChunk(destination, clientRenderedSuspenseBoundaryErrorDevA);
1572-
writeChunk(
1573-
destination,
1574-
stringToChunk(escapeTextForBrowser(errorMesssage)),
1575-
);
1576-
}
1578+
// Component stacks are currently only captured in dev
15771579
if (errorComponentStack) {
1578-
writeChunk(destination, clientRenderedSuspenseBoundaryErrorDevB);
1580+
writeChunk(destination, clientRenderedSuspenseBoundaryError1B);
15791581
writeChunk(
15801582
destination,
15811583
stringToChunk(escapeTextForBrowser(errorComponentStack)),
@@ -1924,8 +1926,9 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
19241926
clientRenderFunction + ';$RX("',
19251927
);
19261928
const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
1927-
const clientRenderScript2 = stringToPrecomputedChunk('")</script>');
1928-
const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk('","');
1929+
const clientRenderScript1A = stringToPrecomputedChunk('"');
1930+
const clientRenderScript2 = stringToPrecomputedChunk(')</script>');
1931+
const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
19291932

19301933
export function writeClientRenderBoundaryInstruction(
19311934
destination: Destination,
@@ -1952,21 +1955,20 @@ export function writeClientRenderBoundaryInstruction(
19521955
}
19531956

19541957
writeChunk(destination, boundaryID);
1958+
writeChunk(destination, clientRenderScript1A);
19551959
if (errorHash || errorMessage || errorComponentStack) {
19561960
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1957-
if (errorHash)
1958-
writeChunk(
1959-
destination,
1960-
stringToChunk(escapeJSStringsForInstructionScripts(errorHash)),
1961-
);
1961+
writeChunk(
1962+
destination,
1963+
stringToChunk(escapeJSStringsForInstructionScripts(errorHash || '')),
1964+
);
19621965
}
19631966
if (errorMessage || errorComponentStack) {
19641967
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1965-
if (errorMessage)
1966-
writeChunk(
1967-
destination,
1968-
stringToChunk(escapeJSStringsForInstructionScripts(errorMessage)),
1969-
);
1968+
writeChunk(
1969+
destination,
1970+
stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
1971+
);
19701972
}
19711973
if (errorComponentStack) {
19721974
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
@@ -1978,28 +1980,14 @@ export function writeClientRenderBoundaryInstruction(
19781980
return writeChunkAndReturn(destination, clientRenderScript2);
19791981
}
19801982

1981-
const regexForJSStringsInScripts = /[<\'\`\u2028\u2029]/g;
1983+
const regexForJSStringsInScripts = /[<\u2028\u2029]/g;
19821984
function escapeJSStringsForInstructionScripts(input: string): string {
1983-
if (typeof input !== 'string') {
1984-
// eslint-disable-next-line react-internal/prod-error-codes
1985-
throw new Error(
1986-
`regexForJSStringsInScripts must be passed a string but was passed something of type "${
1987-
input === null ? 'null' : typeof input
1988-
}" instead`,
1989-
);
1990-
}
1991-
let escaped = JSON.stringify(input);
1992-
escaped = escaped.replace(regexForJSStringsInScripts, match => {
1985+
const escaped = JSON.stringify(input);
1986+
return escaped.replace(regexForJSStringsInScripts, match => {
19931987
switch (match) {
19941988
// santizing breaking out of strings and script tags
19951989
case '<':
19961990
return '\\u003c';
1997-
case "'":
1998-
return "\\'";
1999-
// case '"': // JSON.stringify already escaped these
2000-
// return '\\"';
2001-
case '`':
2002-
return '\\`';
20031991
case '\u2028':
20041992
return '\\u2028';
20051993
case '\u2029':
@@ -2012,10 +2000,4 @@ function escapeJSStringsForInstructionScripts(input: string): string {
20122000
}
20132001
}
20142002
});
2015-
if (escaped[0] === '"' && escaped[escaped.length - 1] === '"') {
2016-
// This should always be true since JSON.stringify of a string produces a value wrapped in double quotes.
2017-
return escaped.slice(1, -1);
2018-
} else {
2019-
return escaped;
2020-
}
20212003
}

0 commit comments

Comments
 (0)