Skip to content

Commit aec5759

Browse files
gnoffsalazarm
andauthored
[Fizz] Send errors down to client (#24551)
* use return from onError * export getSuspenseInstanceFallbackError * stringToChunk * return string from onError in downstream type signatures * 1 more type * support encoding errors in html stream and escape user input This commit adds another way to get errors to the suspense instance by encoding them as dataset properties of a template element at the head of the boundary. Previously if there was an error before the boundary flushed there was no way to stream the error to the client because there would never be a client render instruction. Additionally the error is sent in 3 parts 1) error hash - this is always sent (dev or prod) if one is provided 2) error message - Dev only 3) error component stack - Dev only, this now captures the stack at the point of error Another item addressed in this commit is the escaping of potentially unsafe data. all error components are escaped as test for browers when written into the html and as javascript strings when written into a client render instruction. * nits Co-authored-by: Marco Salazar <[email protected]>
1 parent a276638 commit aec5759

17 files changed

+768
-77
lines changed

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

+418-39
Large diffs are not rendered by default.

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

+21-1
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,30 @@ describe('ReactDOMFizzServer', () => {
193193

194194
// @gate experimental
195195
it('should be able to complete by aborting even if the promise never resolves', async () => {
196+
const errors = [];
196197
const controller = new AbortController();
197198
const stream = await ReactDOMFizzServer.renderToReadableStream(
198199
<div>
199200
<Suspense fallback={<div>Loading</div>}>
200201
<InfiniteSuspend />
201202
</Suspense>
202203
</div>,
203-
{signal: controller.signal},
204+
{
205+
signal: controller.signal,
206+
onError(x) {
207+
errors.push(x.message);
208+
},
209+
},
204210
);
205211

206212
controller.abort();
207213

208214
const result = await readResult(stream);
209215
expect(result).toContain('Loading');
216+
217+
expect(errors).toEqual([
218+
'This Suspense boundary was aborted by the server',
219+
]);
210220
});
211221

212222
// @gate experimental
@@ -223,12 +233,18 @@ describe('ReactDOMFizzServer', () => {
223233
rendered = true;
224234
return 'Done';
225235
}
236+
const errors = [];
226237
const stream = await ReactDOMFizzServer.renderToReadableStream(
227238
<div>
228239
<Suspense fallback={<div>Loading</div>}>
229240
<Wait /> />
230241
</Suspense>
231242
</div>,
243+
{
244+
onError(x) {
245+
errors.push(x.message);
246+
},
247+
},
232248
);
233249

234250
stream.allReady.then(() => (isComplete = true));
@@ -239,6 +255,10 @@ describe('ReactDOMFizzServer', () => {
239255
const reader = stream.getReader();
240256
reader.cancel();
241257

258+
expect(errors).toEqual([
259+
'This Suspense boundary was aborted by the server',
260+
]);
261+
242262
hasLoaded = true;
243263
resolve();
244264

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

+28-2
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('ReactDOMFizzServer', () => {
211211

212212
{
213213
onError(x) {
214-
reportedErrors.push(x);
214+
reportedErrors.push(x.message);
215215
},
216216
onShellError(x) {
217217
reportedShellErrors.push(x);
@@ -224,7 +224,10 @@ describe('ReactDOMFizzServer', () => {
224224

225225
expect(output.error).toBe(theError);
226226
expect(output.result).toBe('');
227-
expect(reportedErrors).toEqual([theError]);
227+
expect(reportedErrors).toEqual([
228+
theError.message,
229+
'This Suspense boundary was aborted by the server',
230+
]);
228231
expect(reportedShellErrors).toEqual([theError]);
229232
});
230233

@@ -289,6 +292,7 @@ describe('ReactDOMFizzServer', () => {
289292
// @gate experimental
290293
it('should be able to complete by aborting even if the promise never resolves', async () => {
291294
let isCompleteCalls = 0;
295+
const errors = [];
292296
const {writable, output, completed} = getTestWritable();
293297
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
294298
<div>
@@ -298,6 +302,9 @@ describe('ReactDOMFizzServer', () => {
298302
</div>,
299303

300304
{
305+
onError(x) {
306+
errors.push(x.message);
307+
},
301308
onAllReady() {
302309
isCompleteCalls++;
303310
},
@@ -314,6 +321,9 @@ describe('ReactDOMFizzServer', () => {
314321

315322
await completed;
316323

324+
expect(errors).toEqual([
325+
'This Suspense boundary was aborted by the server',
326+
]);
317327
expect(output.error).toBe(undefined);
318328
expect(output.result).toContain('Loading');
319329
expect(isCompleteCalls).toBe(1);
@@ -322,6 +332,7 @@ describe('ReactDOMFizzServer', () => {
322332
// @gate experimental
323333
it('should be able to complete by abort when the fallback is also suspended', async () => {
324334
let isCompleteCalls = 0;
335+
const errors = [];
325336
const {writable, output, completed} = getTestWritable();
326337
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
327338
<div>
@@ -333,6 +344,9 @@ describe('ReactDOMFizzServer', () => {
333344
</div>,
334345

335346
{
347+
onError(x) {
348+
errors.push(x.message);
349+
},
336350
onAllReady() {
337351
isCompleteCalls++;
338352
},
@@ -349,6 +363,11 @@ describe('ReactDOMFizzServer', () => {
349363

350364
await completed;
351365

366+
expect(errors).toEqual([
367+
// There are two boundaries that abort
368+
'This Suspense boundary was aborted by the server',
369+
'This Suspense boundary was aborted by the server',
370+
]);
352371
expect(output.error).toBe(undefined);
353372
expect(output.result).toContain('Loading');
354373
expect(isCompleteCalls).toBe(1);
@@ -552,6 +571,7 @@ describe('ReactDOMFizzServer', () => {
552571
rendered = true;
553572
return 'Done';
554573
}
574+
const errors = [];
555575
const {writable, completed} = getTestWritable();
556576
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
557577
<div>
@@ -560,6 +580,9 @@ describe('ReactDOMFizzServer', () => {
560580
</Suspense>
561581
</div>,
562582
{
583+
onError(x) {
584+
errors.push(x.message);
585+
},
563586
onAllReady() {
564587
isComplete = true;
565588
},
@@ -579,6 +602,9 @@ describe('ReactDOMFizzServer', () => {
579602

580603
await completed;
581604

605+
expect(errors).toEqual([
606+
'This Suspense boundary was aborted by the server',
607+
]);
582608
expect(rendered).toBe(false);
583609
expect(isComplete).toBe(true);
584610
});

packages/react-dom/src/client/ReactDOMHostConfig.js

+24
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,30 @@ export function isSuspenseInstancePending(instance: SuspenseInstance) {
729729
export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
730730
return instance.data === SUSPENSE_FALLBACK_START_DATA;
731731
}
732+
export function getSuspenseInstanceFallbackErrorDetails(
733+
instance: SuspenseInstance,
734+
) {
735+
const nextSibling = instance.nextSibling;
736+
let errorMessage /*, errorComponentStack, errorHash*/;
737+
if (
738+
nextSibling &&
739+
nextSibling.nodeType === ELEMENT_NODE &&
740+
nextSibling.nodeName.toLowerCase() === 'template'
741+
) {
742+
const msg = ((nextSibling: any): HTMLTemplateElement).dataset.msg;
743+
if (msg !== null) errorMessage = msg;
744+
745+
// @TODO read and return hash and componentStack once we know how we are goign to
746+
// expose this extra errorInfo to onRecoverableError
747+
748+
// const hash = ((nextSibling: any): HTMLTemplateElement).dataset.hash;
749+
// if (hash !== null) errorHash = hash;
750+
751+
// const stack = ((nextSibling: any): HTMLTemplateElement).dataset.stack;
752+
// if (stack !== null) errorComponentStack = stack;
753+
}
754+
return {errorMessage /*, errorComponentStack, errorHash*/};
755+
}
732756

733757
export function registerSuspenseInstanceRetry(
734758
instance: SuspenseInstance,

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type Options = {|
3232
bootstrapModules?: Array<string>,
3333
progressiveChunkSize?: number,
3434
signal?: AbortSignal,
35-
onError?: (error: mixed) => void,
35+
onError?: (error: mixed) => ?string,
3636
|};
3737

3838
// TODO: Move to sub-classing ReadableStream.

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ type Options = {|
4343
onShellReady?: () => void,
4444
onShellError?: (error: mixed) => void,
4545
onAllReady?: () => void,
46-
onError?: (error: mixed) => void,
46+
onError?: (error: mixed) => ?string,
4747
|};
4848

4949
type PipeableStream = {|

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

+106-4
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,19 @@ const startClientRenderedSuspenseBoundary = stringToPrecomputedChunk(
15261526
);
15271527
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');
15281528

1529+
const clientRenderedSuspenseBoundaryError1 = stringToPrecomputedChunk(
1530+
'<template data-hash="',
1531+
);
1532+
const clientRenderedSuspenseBoundaryError1A = stringToPrecomputedChunk(
1533+
'" data-msg="',
1534+
);
1535+
const clientRenderedSuspenseBoundaryError1B = stringToPrecomputedChunk(
1536+
'" data-stack="',
1537+
);
1538+
const clientRenderedSuspenseBoundaryError2 = stringToPrecomputedChunk(
1539+
'"></template>',
1540+
);
1541+
15291542
export function pushStartCompletedSuspenseBoundary(
15301543
target: Array<Chunk | PrecomputedChunk>,
15311544
) {
@@ -1563,8 +1576,43 @@ export function writeStartPendingSuspenseBoundary(
15631576
export function writeStartClientRenderedSuspenseBoundary(
15641577
destination: Destination,
15651578
responseState: ResponseState,
1579+
errorHash: ?string,
1580+
errorMesssage: ?string,
1581+
errorComponentStack: ?string,
15661582
): boolean {
1567-
return writeChunkAndReturn(destination, startClientRenderedSuspenseBoundary);
1583+
let result;
1584+
result = writeChunkAndReturn(
1585+
destination,
1586+
startClientRenderedSuspenseBoundary,
1587+
);
1588+
if (errorHash) {
1589+
writeChunk(destination, clientRenderedSuspenseBoundaryError1);
1590+
writeChunk(destination, stringToChunk(escapeTextForBrowser(errorHash)));
1591+
// In prod errorMessage will usually be nullish but there is one case where
1592+
// it is used (currently when the server aborts the task) so we leave it ungated.
1593+
if (errorMesssage) {
1594+
writeChunk(destination, clientRenderedSuspenseBoundaryError1A);
1595+
writeChunk(
1596+
destination,
1597+
stringToChunk(escapeTextForBrowser(errorMesssage)),
1598+
);
1599+
}
1600+
if (__DEV__) {
1601+
// Component stacks are currently only captured in dev
1602+
if (errorComponentStack) {
1603+
writeChunk(destination, clientRenderedSuspenseBoundaryError1B);
1604+
writeChunk(
1605+
destination,
1606+
stringToChunk(escapeTextForBrowser(errorComponentStack)),
1607+
);
1608+
}
1609+
}
1610+
result = writeChunkAndReturn(
1611+
destination,
1612+
clientRenderedSuspenseBoundaryError2,
1613+
);
1614+
}
1615+
return result;
15681616
}
15691617
export function writeEndCompletedSuspenseBoundary(
15701618
destination: Destination,
@@ -1724,7 +1772,7 @@ export function writeEndSegment(
17241772
// const SUSPENSE_PENDING_START_DATA = '$?';
17251773
// const SUSPENSE_FALLBACK_START_DATA = '$!';
17261774
//
1727-
// function clientRenderBoundary(suspenseBoundaryID) {
1775+
// function clientRenderBoundary(suspenseBoundaryID, errorHash, errorMsg, errorComponentStack) {
17281776
// // Find the fallback's first element.
17291777
// const suspenseIdNode = document.getElementById(suspenseBoundaryID);
17301778
// if (!suspenseIdNode) {
@@ -1736,6 +1784,11 @@ export function writeEndSegment(
17361784
// const suspenseNode = suspenseIdNode.previousSibling;
17371785
// // Tag it to be client rendered.
17381786
// suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
1787+
// // assign error metadata to first sibling
1788+
// let dataset = suspenseIdNode.dataset;
1789+
// if (errorHash) dataset.hash = errorHash;
1790+
// if (errorMsg) dataset.msg = errorMsg;
1791+
// if (errorComponentStack) dataset.stack = errorComponentStack;
17391792
// // Tell React to retry it if the parent already hydrated.
17401793
// if (suspenseNode._reactRetry) {
17411794
// suspenseNode._reactRetry();
@@ -1823,7 +1876,7 @@ const completeSegmentFunction =
18231876
const completeBoundaryFunction =
18241877
'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}';
18251878
const clientRenderFunction =
1826-
'function $RX(a){if(a=document.getElementById(a))a=a.previousSibling,a.data="$!",a._reactRetry&&a._reactRetry()}';
1879+
'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.hash=c),d&&(a.msg=d),e&&(a.stack=e),b._reactRetry&&b._reactRetry())}';
18271880

18281881
const completeSegmentScript1Full = stringToPrecomputedChunk(
18291882
completeSegmentFunction + ';$RS("',
@@ -1896,12 +1949,17 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
18961949
clientRenderFunction + ';$RX("',
18971950
);
18981951
const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
1899-
const clientRenderScript2 = stringToPrecomputedChunk('")</script>');
1952+
const clientRenderScript1A = stringToPrecomputedChunk('"');
1953+
const clientRenderScript2 = stringToPrecomputedChunk(')</script>');
1954+
const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
19001955

19011956
export function writeClientRenderBoundaryInstruction(
19021957
destination: Destination,
19031958
responseState: ResponseState,
19041959
boundaryID: SuspenseBoundaryID,
1960+
errorHash: ?string,
1961+
errorMessage?: string,
1962+
errorComponentStack?: string,
19051963
): boolean {
19061964
writeChunk(destination, responseState.startInlineScript);
19071965
if (!responseState.sentClientRenderFunction) {
@@ -1920,5 +1978,49 @@ export function writeClientRenderBoundaryInstruction(
19201978
}
19211979

19221980
writeChunk(destination, boundaryID);
1981+
writeChunk(destination, clientRenderScript1A);
1982+
if (errorHash || errorMessage || errorComponentStack) {
1983+
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1984+
writeChunk(
1985+
destination,
1986+
stringToChunk(escapeJSStringsForInstructionScripts(errorHash || '')),
1987+
);
1988+
}
1989+
if (errorMessage || errorComponentStack) {
1990+
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1991+
writeChunk(
1992+
destination,
1993+
stringToChunk(escapeJSStringsForInstructionScripts(errorMessage || '')),
1994+
);
1995+
}
1996+
if (errorComponentStack) {
1997+
writeChunk(destination, clientRenderErrorScriptArgInterstitial);
1998+
writeChunk(
1999+
destination,
2000+
stringToChunk(escapeJSStringsForInstructionScripts(errorComponentStack)),
2001+
);
2002+
}
19232003
return writeChunkAndReturn(destination, clientRenderScript2);
19242004
}
2005+
2006+
const regexForJSStringsInScripts = /[<\u2028\u2029]/g;
2007+
function escapeJSStringsForInstructionScripts(input: string): string {
2008+
const escaped = JSON.stringify(input);
2009+
return escaped.replace(regexForJSStringsInScripts, match => {
2010+
switch (match) {
2011+
// santizing breaking out of strings and script tags
2012+
case '<':
2013+
return '\\u003c';
2014+
case '\u2028':
2015+
return '\\u2028';
2016+
case '\u2029':
2017+
return '\\u2029';
2018+
default: {
2019+
// eslint-disable-next-line react-internal/prod-error-codes
2020+
throw new Error(
2021+
'escapeJSStringsForInstructionScripts encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React',
2022+
);
2023+
}
2024+
}
2025+
});
2026+
}

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

+4
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ export function writeStartCompletedSuspenseBoundary(
148148
export function writeStartClientRenderedSuspenseBoundary(
149149
destination: Destination,
150150
responseState: ResponseState,
151+
// flushing these error arguments are not currently supported in this legacy streaming format.
152+
errorHash: ?string,
153+
errorMessage?: string,
154+
errorComponentStack?: string,
151155
): boolean {
152156
if (responseState.generateStaticMarkup) {
153157
// A client rendered boundary is done and doesn't need a representation in the HTML

0 commit comments

Comments
 (0)