diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 7104350fde00e..65dfdbb15dd15 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -3057,6 +3057,175 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate experimental + it('Supports custom abort reasons with a string', async () => { + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + let abort; + const loggedErrors = []; + await act(async () => { + const { + pipe, + abort: abortImpl, + } = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error, errorInfo) { + loggedErrors.push(error.message); + return 'a digest'; + }, + }); + abort = abortImpl; + pipe(writable); + }); + + expect(loggedErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+

p

+ span +
, + ); + + await act(() => { + abort('foobar'); + }); + + expect(loggedErrors).toEqual([ + 'The server did not finish this Suspense boundary. foobar', + 'The server did not finish this Suspense boundary. foobar', + ]); + + let errors = []; + ReactDOMClient.hydrateRoot(container, , { + 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 ( +
+

+ + + +

+ + + + + +
+ ); + } + + let abort; + const loggedErrors = []; + await act(async () => { + const { + pipe, + abort: abortImpl, + } = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error, errorInfo) { + loggedErrors.push(error.message); + return 'a digest'; + }, + }); + abort = abortImpl; + pipe(writable); + }); + + expect(loggedErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+

p

+ span +
, + ); + + await act(() => { + abort(new Error('uh oh')); + }); + + expect(loggedErrors).toEqual(['uh oh', 'uh oh']); + + let errors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); + }, + }); + + expect(Scheduler).toFlushAndYield([]); + + expectErrors( + errors, + [ + ['uh oh', 'a digest', componentStack(['Suspense', 'p', 'div', 'App'])], + [ + '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 () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index f9eb651821446..4df9da9daee13 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -316,6 +316,109 @@ describe('ReactDOMFizzServer', () => { result = await readResult(stream); expect(result).toMatchInlineSnapshot(`"
${str2049}
"`); }); + + // @gate experimental + it('Supports custom abort reasons with a string', async () => { + let hasLoaded = false; + let resolve; + let isComplete = false; + let rendered = false; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + rendered = true; + return 'Done'; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const stream = await ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = 'foobar'; + controller.abort('foobar'); + + expect(errors).toEqual([ + 'The server did not finish this Suspense boundary. foobar', + 'The server did not finish this Suspense boundary. foobar', + ]); + }); + + // @gate experimental + it('Supports custom abort reasons with an Error', async () => { + let hasLoaded = false; + let resolve; + let isComplete = false; + let rendered = false; + const promise = new Promise(r => (resolve = r)); + function Wait() { + if (!hasLoaded) { + throw promise; + } + rendered = true; + return 'Done'; + } + function App() { + return ( +
+

+ + + +

+ + + + + +
+ ); + } + + const errors = []; + const controller = new AbortController(); + const stream = await ReactDOMFizzServer.renderToReadableStream(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + return 'a digest'; + }, + }); + + // @TODO this is a hack to work around lack of support for abortSignal.reason in node + // The abort call itself should set this property but since we are testing in node we + // set it here manually + controller.signal.reason = new Error('uh oh'); + controller.abort(new Error('uh oh')); + + expect(errors).toEqual(['uh oh', 'uh oh']); + }); }); // @gate experimental diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 35fbf0e6023c0..2e530dba950d5 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -97,7 +97,16 @@ function renderToReadableStream( if (options && options.signal) { const signal = options.signal; const listener = () => { - abort(request); + const reason = signal.reason; + if ( + reason && + (typeof reason === 'string' || + (typeof reason === 'object' && typeof reason.message === 'string')) + ) { + abort(request, reason); + } else { + abort(request, null); + } signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index c5318c2024aa2..fed6b74bf5d7a 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -93,8 +93,16 @@ function renderToPipeableStream( destination.on('close', createAbortHandler(request)); return destination; }, - abort() { - abort(request); + abort(reason) { + if ( + reason && + (typeof reason === 'string' || + (typeof reason === 'object' && typeof reason.message === 'string')) + ) { + abort(request, reason); + } else { + abort(request, null); + } }, }; } diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js b/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js index af5bd385d31c3..01930a6fca793 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js @@ -16,7 +16,6 @@ import { startWork, startFlowing, abort, - runWithLegacyRuntimeContext, } from 'react-server/src/ReactFizzServer'; import { @@ -36,6 +35,7 @@ export function renderToStringImpl( children: ReactNodeList, options: void | ServerOptions, generateStaticMarkup: boolean, + suspenseAbortMessage: string, ): string { let didFatal = false; let fatalError = null; @@ -74,7 +74,7 @@ export function renderToStringImpl( startWork(request); // If anything suspended and is still pending, we'll abort it before writing. // That way we write only client-rendered boundaries from the start. - abort(request); + abort(request, suspenseAbortMessage); startFlowing(request, destination); if (didFatal) { throw fatalError; @@ -98,8 +98,11 @@ function renderToString( children: ReactNodeList, options?: ServerOptions, ): string { - return runWithLegacyRuntimeContext('renderToString', 'browser', () => - renderToStringImpl(children, options, false), + return renderToStringImpl( + children, + options, + false, + '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', ); } @@ -107,7 +110,12 @@ function renderToStaticMarkup( children: ReactNodeList, options?: ServerOptions, ): string { - return renderToStringImpl(children, options, true); + return renderToStringImpl( + children, + options, + true, + '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', + ); } function renderToNodeStream() { diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerNode.js b/packages/react-dom/src/server/ReactDOMLegacyServerNode.js index 5ec7ff8c1009e..47ef78cee6db9 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerNode.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerNode.js @@ -16,7 +16,6 @@ import { startWork, startFlowing, abort, - runWithLegacyRuntimeContext, } from 'react-server/src/ReactFizzServer'; import { @@ -24,11 +23,7 @@ import { createRootFormatContext, } from './ReactDOMServerLegacyFormatConfig'; -import { - version, - renderToStringImpl, - renderToStaticMarkup, -} from './ReactDOMLegacyServerBrowser'; +import {version, renderToStringImpl} from './ReactDOMLegacyServerBrowser'; import {Readable} from 'stream'; @@ -114,8 +109,23 @@ function renderToString( children: ReactNodeList, options?: ServerOptions, ): string { - return runWithLegacyRuntimeContext('renderToString', 'node', () => - renderToStringImpl(children, options, false), + return renderToStringImpl( + children, + options, + false, + '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 "renderToPipeableStream" which supports Suspense on the server', + ); +} + +function renderToStaticMarkup( + children: ReactNodeList, + options?: ServerOptions, +): string { + return renderToStringImpl( + children, + options, + true, + 'The server used "renderToStaticMarkup" which does not support Suspense. If you intended to have the server wait for the suspended component please switch to "renderToPipeableStream" which supports Suspense on the server', ); } diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7da5f9fe26be6..6e937e550c1be 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -193,6 +193,7 @@ export opaque type Request = { pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. completedRootSegment: null | Segment, // Completed but not yet flushed root segments. abortableTasks: Set, + abortReason: null | AbortReason, pingedTasks: Array, // Queues to flush in order of priority clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. @@ -1552,20 +1553,29 @@ function abortTask(task: Task): void { boundary.pendingTasks--; if (!boundary.forceClientRender) { + const abortReason = request.abortReason; boundary.forceClientRender = true; - const error = - runtimeUnsupportedSuspense && runtimeMethodName - ? new Error( - `The server did not finish this Suspense boundary. The server used "${runtimeMethodName}" 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 "${ - runtimeHostEnvironment === 'browser' - ? 'renderToReadableStream' - : 'renderToPipeableStream' - }" which supports Suspense on the server`, - ) - : new Error('This Suspense boundary was aborted by the server.'); + let error; + if (abortReason) { + if (typeof abortReason === 'string') { + error = new Error( + `The server did not finish this Suspense boundary. ${abortReason}`, + ); + } else { + error = abortReason; + } + } else { + error = new Error('This Suspense boundary was aborted by the server.'); + } boundary.errorDigest = request.onError(error); if (__DEV__) { - captureBoundaryErrorDetailsDev(boundary, error); + const previousTaskInDev = currentTaskInDEV; + currentTaskInDEV = task; + try { + captureBoundaryErrorDetailsDev(boundary, error); + } finally { + currentTaskInDEV = previousTaskInDev; + } } if (boundary.parentFlushed) { request.clientRenderedBoundaries.push(boundary); @@ -2165,8 +2175,11 @@ export function startFlowing(request: Request, destination: Destination): void { } } +type AbortReason = string | {message: string, ...}; + // This is called to early terminate a request. It puts all pending boundaries in client rendered state. -export function abort(request: Request): void { +export function abort(request: Request, reason: ?AbortReason): void { + request.abortReason = reason; try { const abortableTasks = request.abortableTasks; abortableTasks.forEach(abortTask, request); @@ -2179,26 +2192,3 @@ export function abort(request: Request): void { fatalError(request, error); } } - -type RuntimeHostEnvironment = 'browser' | 'node' | ''; - -let runtimeUnsupportedSuspense: boolean = false; -let runtimeMethodName: string = ''; -let runtimeHostEnvironment: RuntimeHostEnvironment = ''; - -export function runWithLegacyRuntimeContext( - methodName: string, - hostEnvironment: RuntimeHostEnvironment, - callback: () => T, -): T { - runtimeUnsupportedSuspense = true; - runtimeMethodName = methodName; - runtimeHostEnvironment = hostEnvironment; - try { - return callback(); - } finally { - runtimeUnsupportedSuspense = false; - runtimeMethodName = ''; - runtimeHostEnvironment = ''; - } -}