Skip to content

Commit

Permalink
[Fizz] Support abort reasons (#24680)
Browse files Browse the repository at this point in the history
* [Fizz] Support abort reasons

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 absence 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 Suspense is not supported in renderToString-like renderers
  • Loading branch information
gnoff committed Jun 8, 2022
1 parent 79f54c1 commit b345523
Show file tree
Hide file tree
Showing 17 changed files with 950 additions and 422 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: The render was aborted by the server without a 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 b345523

Please sign in to comment.