Skip to content

Commit bd66c7b

Browse files
committed
Refactor to use abort reason
Both renderToReadableStream and renderToPipeableStream expose an abort functionality. This commit adds a reason argument to both abort pathways. This can be used by implementors to provide custome reasons for why an abort happened. Additionally the legacy rending pathways such as renderToString are now using this abort reason to convey the instructional message about why using Suspense with these render methods is not correct.
1 parent f22a8d8 commit bd66c7b

7 files changed

+348
-51
lines changed

Diff for: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+169
Original file line numberDiff line numberDiff line change
@@ -3057,6 +3057,175 @@ describe('ReactDOMFizzServer', () => {
30573057
);
30583058
});
30593059

3060+
// @gate experimental
3061+
it('Supports custom abort reasons with a string', async () => {
3062+
function App() {
3063+
return (
3064+
<div>
3065+
<p>
3066+
<Suspense fallback={'p'}>
3067+
<AsyncText text={'hello'} />
3068+
</Suspense>
3069+
</p>
3070+
<span>
3071+
<Suspense fallback={'span'}>
3072+
<AsyncText text={'world'} />
3073+
</Suspense>
3074+
</span>
3075+
</div>
3076+
);
3077+
}
3078+
3079+
let abort;
3080+
const loggedErrors = [];
3081+
await act(async () => {
3082+
const {
3083+
pipe,
3084+
abort: abortImpl,
3085+
} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3086+
onError(error, errorInfo) {
3087+
loggedErrors.push(error.message);
3088+
return 'a digest';
3089+
},
3090+
});
3091+
abort = abortImpl;
3092+
pipe(writable);
3093+
});
3094+
3095+
expect(loggedErrors).toEqual([]);
3096+
expect(getVisibleChildren(container)).toEqual(
3097+
<div>
3098+
<p>p</p>
3099+
<span>span</span>
3100+
</div>,
3101+
);
3102+
3103+
await act(() => {
3104+
abort('foobar');
3105+
});
3106+
3107+
expect(loggedErrors).toEqual([
3108+
'The server did not finish this Suspense boundary. foobar',
3109+
'The server did not finish this Suspense boundary. foobar',
3110+
]);
3111+
3112+
let errors = [];
3113+
ReactDOMClient.hydrateRoot(container, <App />, {
3114+
onRecoverableError(error, errorInfo) {
3115+
errors.push({error, errorInfo});
3116+
},
3117+
});
3118+
3119+
expect(Scheduler).toFlushAndYield([]);
3120+
3121+
expectErrors(
3122+
errors,
3123+
[
3124+
[
3125+
'The server did not finish this Suspense boundary. foobar',
3126+
'a digest',
3127+
componentStack(['Suspense', 'p', 'div', 'App']),
3128+
],
3129+
[
3130+
'The server did not finish this Suspense boundary. foobar',
3131+
'a digest',
3132+
componentStack(['Suspense', 'span', 'div', 'App']),
3133+
],
3134+
],
3135+
[
3136+
[
3137+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3138+
'a digest',
3139+
],
3140+
[
3141+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3142+
'a digest',
3143+
],
3144+
],
3145+
);
3146+
});
3147+
3148+
// @gate experimental
3149+
it('Supports custom abort reasons with an Error', async () => {
3150+
function App() {
3151+
return (
3152+
<div>
3153+
<p>
3154+
<Suspense fallback={'p'}>
3155+
<AsyncText text={'hello'} />
3156+
</Suspense>
3157+
</p>
3158+
<span>
3159+
<Suspense fallback={'span'}>
3160+
<AsyncText text={'world'} />
3161+
</Suspense>
3162+
</span>
3163+
</div>
3164+
);
3165+
}
3166+
3167+
let abort;
3168+
const loggedErrors = [];
3169+
await act(async () => {
3170+
const {
3171+
pipe,
3172+
abort: abortImpl,
3173+
} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3174+
onError(error, errorInfo) {
3175+
loggedErrors.push(error.message);
3176+
return 'a digest';
3177+
},
3178+
});
3179+
abort = abortImpl;
3180+
pipe(writable);
3181+
});
3182+
3183+
expect(loggedErrors).toEqual([]);
3184+
expect(getVisibleChildren(container)).toEqual(
3185+
<div>
3186+
<p>p</p>
3187+
<span>span</span>
3188+
</div>,
3189+
);
3190+
3191+
await act(() => {
3192+
abort(new Error('uh oh'));
3193+
});
3194+
3195+
expect(loggedErrors).toEqual(['uh oh', 'uh oh']);
3196+
3197+
let errors = [];
3198+
ReactDOMClient.hydrateRoot(container, <App />, {
3199+
onRecoverableError(error, errorInfo) {
3200+
errors.push({error, errorInfo});
3201+
},
3202+
});
3203+
3204+
expect(Scheduler).toFlushAndYield([]);
3205+
3206+
expectErrors(
3207+
errors,
3208+
[
3209+
['uh oh', 'a digest', componentStack(['Suspense', 'p', 'div', 'App'])],
3210+
[
3211+
'uh oh',
3212+
'a digest',
3213+
componentStack(['Suspense', 'span', 'div', 'App']),
3214+
],
3215+
],
3216+
[
3217+
[
3218+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3219+
'a digest',
3220+
],
3221+
[
3222+
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3223+
'a digest',
3224+
],
3225+
],
3226+
);
3227+
});
3228+
30603229
describe('error escaping', () => {
30613230
//@gate experimental
30623231
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {

Diff for: packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

+103
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,109 @@ describe('ReactDOMFizzServer', () => {
316316
result = await readResult(stream);
317317
expect(result).toMatchInlineSnapshot(`"<div>${str2049}</div>"`);
318318
});
319+
320+
// @gate experimental
321+
it('Supports custom abort reasons with a string', async () => {
322+
let hasLoaded = false;
323+
let resolve;
324+
let isComplete = false;
325+
let rendered = false;
326+
const promise = new Promise(r => (resolve = r));
327+
function Wait() {
328+
if (!hasLoaded) {
329+
throw promise;
330+
}
331+
rendered = true;
332+
return 'Done';
333+
}
334+
function App() {
335+
return (
336+
<div>
337+
<p>
338+
<Suspense fallback={'p'}>
339+
<Wait />
340+
</Suspense>
341+
</p>
342+
<span>
343+
<Suspense fallback={'span'}>
344+
<Wait />
345+
</Suspense>
346+
</span>
347+
</div>
348+
);
349+
}
350+
351+
const errors = [];
352+
const controller = new AbortController();
353+
const stream = await ReactDOMFizzServer.renderToReadableStream(<App />, {
354+
signal: controller.signal,
355+
onError(x) {
356+
errors.push(x.message);
357+
return 'a digest';
358+
},
359+
});
360+
361+
// @TODO this is a hack to work around lack of support for abortSignal.reason in node
362+
// The abort call itself should set this property but since we are testing in node we
363+
// set it here manually
364+
controller.signal.reason = 'foobar';
365+
controller.abort('foobar');
366+
367+
expect(errors).toEqual([
368+
'The server did not finish this Suspense boundary. foobar',
369+
'The server did not finish this Suspense boundary. foobar',
370+
]);
371+
});
372+
373+
// @gate experimental
374+
it('Supports custom abort reasons with an Error', async () => {
375+
let hasLoaded = false;
376+
let resolve;
377+
let isComplete = false;
378+
let rendered = false;
379+
const promise = new Promise(r => (resolve = r));
380+
function Wait() {
381+
if (!hasLoaded) {
382+
throw promise;
383+
}
384+
rendered = true;
385+
return 'Done';
386+
}
387+
function App() {
388+
return (
389+
<div>
390+
<p>
391+
<Suspense fallback={'p'}>
392+
<Wait />
393+
</Suspense>
394+
</p>
395+
<span>
396+
<Suspense fallback={'span'}>
397+
<Wait />
398+
</Suspense>
399+
</span>
400+
</div>
401+
);
402+
}
403+
404+
const errors = [];
405+
const controller = new AbortController();
406+
const stream = await ReactDOMFizzServer.renderToReadableStream(<App />, {
407+
signal: controller.signal,
408+
onError(x) {
409+
errors.push(x.message);
410+
return 'a digest';
411+
},
412+
});
413+
414+
// @TODO this is a hack to work around lack of support for abortSignal.reason in node
415+
// The abort call itself should set this property but since we are testing in node we
416+
// set it here manually
417+
controller.signal.reason = new Error('uh oh');
418+
controller.abort(new Error('uh oh'));
419+
420+
expect(errors).toEqual(['uh oh', 'uh oh']);
421+
});
319422
});
320423

321424
// @gate experimental

Diff for: packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,16 @@ function renderToReadableStream(
9797
if (options && options.signal) {
9898
const signal = options.signal;
9999
const listener = () => {
100-
abort(request);
100+
const reason = signal.reason;
101+
if (
102+
reason &&
103+
(typeof reason === 'string' ||
104+
(typeof reason === 'object' && typeof reason.message === 'string'))
105+
) {
106+
abort(request, reason);
107+
} else {
108+
abort(request, null);
109+
}
101110
signal.removeEventListener('abort', listener);
102111
};
103112
signal.addEventListener('abort', listener);

Diff for: packages/react-dom/src/server/ReactDOMFizzServerNode.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,16 @@ function renderToPipeableStream(
9393
destination.on('close', createAbortHandler(request));
9494
return destination;
9595
},
96-
abort() {
97-
abort(request);
96+
abort(reason) {
97+
if (
98+
reason &&
99+
(typeof reason === 'string' ||
100+
(typeof reason === 'object' && typeof reason.message === 'string'))
101+
) {
102+
abort(request, reason);
103+
} else {
104+
abort(request, null);
105+
}
98106
},
99107
};
100108
}

Diff for: packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js

+13-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
startWork,
1717
startFlowing,
1818
abort,
19-
runWithLegacyRuntimeContext,
2019
} from 'react-server/src/ReactFizzServer';
2120

2221
import {
@@ -36,6 +35,7 @@ export function renderToStringImpl(
3635
children: ReactNodeList,
3736
options: void | ServerOptions,
3837
generateStaticMarkup: boolean,
38+
suspenseAbortMessage: string,
3939
): string {
4040
let didFatal = false;
4141
let fatalError = null;
@@ -74,7 +74,7 @@ export function renderToStringImpl(
7474
startWork(request);
7575
// If anything suspended and is still pending, we'll abort it before writing.
7676
// That way we write only client-rendered boundaries from the start.
77-
abort(request);
77+
abort(request, suspenseAbortMessage);
7878
startFlowing(request, destination);
7979
if (didFatal) {
8080
throw fatalError;
@@ -98,16 +98,24 @@ function renderToString(
9898
children: ReactNodeList,
9999
options?: ServerOptions,
100100
): string {
101-
return runWithLegacyRuntimeContext('renderToString', 'browser', () =>
102-
renderToStringImpl(children, options, false),
101+
return renderToStringImpl(
102+
children,
103+
options,
104+
false,
105+
'The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server',
103106
);
104107
}
105108

106109
function renderToStaticMarkup(
107110
children: ReactNodeList,
108111
options?: ServerOptions,
109112
): string {
110-
return renderToStringImpl(children, options, true);
113+
return renderToStringImpl(
114+
children,
115+
options,
116+
true,
117+
'The server used "renderToStaticMarkup" which does not support Suspense. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server',
118+
);
111119
}
112120

113121
function renderToNodeStream() {

0 commit comments

Comments
 (0)