Skip to content

Commit

Permalink
[Fizz] Support abort reasons
Browse files Browse the repository at this point in the history
Fizz supports aborting the render but does not currently accept a reason. The various render functions that use Fizz have some automatic and some user-controlled abort semantics that can be useful to communicate with the running program and users about why an Abort happened.

This change implements abort reasons for renderToReadableStream and renderToPipeable stream as well as legacy renderers such as renderToString and related implementations.

For AbortController implementations the reason passed to the abort method is forwarded to Fizz and sent to the onError handler. If no reason is provided the AbortController should construct an AbortError DOMException and as a fallback Fizz will generate a similar error in the absense of a reason

For pipeable  streams, an abort function is returned alongside pipe which already accepted a reason. That reason is now forwarded to Fizz and the implementation described above.

For legacy renderers there is no exposed abort functionality but it is used internally and the reasons provided give useful context to, for instance to the fact that Suspsense is not supported in renderToString-like renderers

Some notable special case reasons are included below
If no reason is provided then a manufactured reason "signal is aborted without reason"
If a writable stream errors then a reason "The destination stream errored while writing data."
If a writable stream closes early then a reason "The destination stream closed early."
  • Loading branch information
gnoff committed Jun 7, 2022
1 parent 7e8a020 commit 20331f1
Show file tree
Hide file tree
Showing 15 changed files with 818 additions and 342 deletions.
180 changes: 179 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Expand Up @@ -1106,7 +1106,13 @@ describe('ReactDOMFizzServer', () => {
expect(Scheduler).toFlushAndYield([]);
expectErrors(
errors,
[['This Suspense boundary was aborted by the server.', expectedDigest]],
[
[
'The server did not finish this Suspense boundary: signal is aborted without reason',
expectedDigest,
componentStack(['h1', 'Suspense', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
Expand Down Expand Up @@ -3057,6 +3063,178 @@ describe('ReactDOMFizzServer', () => {
);
});

// @gate experimental
it('Supports custom abort reasons with a string', async () => {
function App() {
return (
<div>
<p>
<Suspense fallback={'p'}>
<AsyncText text={'hello'} />
</Suspense>
</p>
<span>
<Suspense fallback={'span'}>
<AsyncText text={'world'} />
</Suspense>
</span>
</div>
);
}

let abort;
const loggedErrors = [];
await act(async () => {
const {
pipe,
abort: abortImpl,
} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError(error) {
// In this test we contrive erroring with strings so we push the error whereas in most
// other tests we contrive erroring with Errors and push the message.
loggedErrors.push(error);
return 'a digest';
},
});
abort = abortImpl;
pipe(writable);
});

expect(loggedErrors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>p</p>
<span>span</span>
</div>,
);

await act(() => {
abort('foobar');
});

expect(loggedErrors).toEqual(['foobar', 'foobar']);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});

expect(Scheduler).toFlushAndYield([]);

expectErrors(
errors,
[
[
'The server did not finish this Suspense boundary: foobar',
'a digest',
componentStack(['Suspense', 'p', 'div', 'App']),
],
[
'The server did not finish this Suspense boundary: foobar',
'a digest',
componentStack(['Suspense', 'span', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
],
);
});

// @gate experimental
it('Supports custom abort reasons with an Error', async () => {
function App() {
return (
<div>
<p>
<Suspense fallback={'p'}>
<AsyncText text={'hello'} />
</Suspense>
</p>
<span>
<Suspense fallback={'span'}>
<AsyncText text={'world'} />
</Suspense>
</span>
</div>
);
}

let abort;
const loggedErrors = [];
await act(async () => {
const {
pipe,
abort: abortImpl,
} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError(error) {
loggedErrors.push(error.message);
return 'a digest';
},
});
abort = abortImpl;
pipe(writable);
});

expect(loggedErrors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>p</p>
<span>span</span>
</div>,
);

await act(() => {
abort(new Error('uh oh'));
});

expect(loggedErrors).toEqual(['uh oh', 'uh oh']);

const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error, errorInfo) {
errors.push({error, errorInfo});
},
});

expect(Scheduler).toFlushAndYield([]);

expectErrors(
errors,
[
[
'The server did not finish this Suspense boundary: uh oh',
'a digest',
componentStack(['Suspense', 'p', 'div', 'App']),
],
[
'The server did not finish this Suspense boundary: uh oh',
'a digest',
componentStack(['Suspense', 'span', 'div', 'App']),
],
],
[
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
[
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
'a digest',
],
],
);
});

describe('error escaping', () => {
//@gate experimental
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
Expand Down

0 comments on commit 20331f1

Please sign in to comment.