diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index d0dcb2f714743..da250d55558fe 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -7498,4 +7498,451 @@ describe('ReactDOMFizzServer', () => { , ); }); + + // @gate enablePostpone + it('does not call onError when you abort with a postpone instance during prerender', async () => { + const promise = new Promise(r => {}); + + function Wait() { + return React.use(promise); + } + + function App() { + return ( +
+ +

+ + + + + +

+

+ + + + + +

+
+
+ ); + } + + let postponeInstance; + try { + React.unstable_postpone('manufactured'); + } catch (p) { + postponeInstance = p; + } + + const controller = new AbortController(); + const signal = controller.signal; + + const errors = []; + function onError(error) { + errors.push(error); + } + const postpones = []; + function onPostpone(reason) { + postpones.push(reason); + } + let pendingPrerender; + await act(() => { + pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(, { + signal, + onError, + onPostpone, + }); + }); + controller.abort(postponeInstance); + + const prerendered = await pendingPrerender; + + expect(prerendered.postponed).toBe(null); + expect(errors).toEqual([]); + expect(postpones).toEqual(['manufactured', 'manufactured']); + + await act(() => { + prerendered.prelude.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again too... +

+
, + ); + }); + + // @gate enablePostpone + it('does not call onError when you abort with a postpone instance during resume', async () => { + let prerendering = true; + const promise = new Promise(r => {}); + + function Wait() { + return React.use(promise); + } + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return ( + + + + + + ); + } + + function App() { + return ( +
+ +

+ +

+

+ +

+
+
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + let postponeInstance; + try { + React.unstable_postpone('manufactured'); + } catch (p) { + postponeInstance = p; + } + + const errors = []; + function onError(error) { + errors.push(error); + } + const postpones = []; + function onPostpone(reason) { + postpones.push(reason); + } + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resumeToPipeableStream( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError, + onPostpone, + }, + ); + + await act(() => { + resumed.pipe(writable); + }); + await act(() => { + resumed.abort(postponeInstance); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again... +

+
, + ); + + expect(errors).toEqual([]); + expect(postpones).toEqual(['manufactured', 'manufactured']); + }); + + // @gate enablePostpone + it('does not call onError when you abort with a postpone instance during a render', async () => { + const promise = new Promise(r => {}); + + function Wait() { + return React.use(promise); + } + + function App() { + return ( +
+ +

+ + + + + +

+

+ + + + + +

+
+
+ ); + } + + const errors = []; + function onError(error) { + errors.push(error); + } + const postpones = []; + function onPostpone(reason) { + postpones.push(reason); + } + const result = await renderToPipeableStream(, {onError, onPostpone}); + await act(() => { + result.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again... +

+
, + ); + + let postponeInstance; + try { + React.unstable_postpone('manufactured'); + } catch (p) { + postponeInstance = p; + } + await act(() => { + result.abort(postponeInstance); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again... +

+
, + ); + + expect(errors).toEqual([]); + expect(postpones).toEqual(['manufactured', 'manufactured']); + }); + + // @gate enablePostpone + it('fatally errors if you abort with a postpone in the shell during resume', async () => { + let prerendering = true; + const promise = new Promise(r => {}); + + function Wait() { + return React.use(promise); + } + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return ( + + + + + + ); + } + + function PostponeInShell() { + if (prerendering) { + React.unstable_postpone(); + } + return in shell; + } + + function App() { + return ( +
+ + +

+ +

+

+ +

+
+
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + // Create a separate stream so it doesn't close the writable. I.e. simple concat. + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual(undefined); + + let postponeInstance; + try { + React.unstable_postpone('manufactured'); + } catch (p) { + postponeInstance = p; + } + + const errors = []; + function onError(error) { + errors.push(error); + } + const shellErrors = []; + function onShellError(error) { + shellErrors.push(error); + } + const postpones = []; + function onPostpone(reason) { + postpones.push(reason); + } + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resumeToPipeableStream( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError, + onShellError, + onPostpone, + }, + ); + await act(() => { + resumed.abort(postponeInstance); + }); + expect(errors).toEqual([ + new Error( + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', + ), + ]); + expect(shellErrors).toEqual([ + new Error( + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', + ), + ]); + expect(postpones).toEqual([]); + }); + + // @gate enablePostpone + it('fatally errors if you abort with a postpone in the shell during render', async () => { + const promise = new Promise(r => {}); + + function Wait() { + return React.use(promise); + } + + function App() { + return ( +
+ +

+ + + + + +

+

+ + + + + +

+
+
+ ); + } + + const errors = []; + function onError(error) { + errors.push(error); + } + const shellErrors = []; + function onShellError(error) { + shellErrors.push(error); + } + const postpones = []; + function onPostpone(reason) { + postpones.push(reason); + } + const result = await renderToPipeableStream(, { + onError, + onShellError, + onPostpone, + }); + + let postponeInstance; + try { + React.unstable_postpone('manufactured'); + } catch (p) { + postponeInstance = p; + } + await act(() => { + result.abort(postponeInstance); + }); + + expect(getVisibleChildren(container)).toEqual(undefined); + + expect(errors).toEqual([ + new Error( + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', + ), + ]); + expect(shellErrors).toEqual([ + new Error( + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured', + ), + ]); + expect(postpones).toEqual([]); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 6b82bfe3a45b7..152b3acc24593 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -3129,8 +3129,23 @@ function abortTask(task: Task, request: Request, error: mixed): void { if (replay === null) { // We didn't complete the root so we have nothing to show. We can close // the request; - logRecoverableError(request, error, errorInfo); - fatalError(request, error); + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + const fatal = new Error( + 'The render was aborted with postpone when the shell is incomplete. Reason: ' + + postponeInstance.message, + ); + logRecoverableError(request, fatal, errorInfo); + fatalError(request, fatal); + } else { + logRecoverableError(request, error, errorInfo); + fatalError(request, error); + } return; } else { // If the shell aborts during a replay, that's not a fatal error. Instead @@ -3138,7 +3153,20 @@ function abortTask(task: Task, request: Request, error: mixed): void { // the ReplaySet. replay.pendingTasks--; if (replay.pendingTasks === 0 && replay.nodes.length > 0) { - const errorDigest = logRecoverableError(request, error, errorInfo); + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message, errorInfo); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError(request, error, errorInfo); + } abortRemainingReplayNodes( request, null, @@ -3162,7 +3190,20 @@ function abortTask(task: Task, request: Request, error: mixed): void { // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which // boundary the message is referring to const errorInfo = getThrownInfo(request, task.componentStack); - const errorDigest = logRecoverableError(request, error, errorInfo); + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message, errorInfo); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError(request, error, errorInfo); + } let errorMessage = error; if (__DEV__) { const errorPrefix = diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 74ae5ea927dc6..ad5d3ebaf3f5e 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -485,5 +485,6 @@ "497": "Only objects or functions can be passed to taintObjectReference.", "498": "Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.", "499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.", - "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React." + "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React.", + "501": "The render was aborted with postpone when the shell is incomplete. Reason: %s" }