From 2042572329425f9ebf35ae6287ea5bab72b2c497 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 7 Aug 2026 22:31:46 -0400 Subject: [PATCH] Add `onBrowserBailout` Fizz option (#37193) Adds a new Fizz option, `onBrowserBailout`, for observing intentional server-render bailouts caused by `ReactDOM.browser()` and future APIs that use the same recoverable error mechanism. The callback receives the original recoverable error and `ErrorInfo`, defaults to a noop, and runs only when Fizz successfully recovers by deferring work to the browser. Recoverables consumed within Suspense or used to abort recoverable boundaries are reported through `onBrowserBailout` without also invoking `onError`. A bailout outside Suspense remains fatal and reports only through `onError`, with the original recoverable preserved as its cause, while directly throwing the value continues to behave like a normal render error. Plumbs the option through the streaming, resume, and prerender entry points for Node, browser, Edge, Bun, FB, markup, and noop renderers while preserving the positional Fizz request API for callers that do not expose the option. Uses an environment-neutral browser-only rendering message for the isomorphic `browser()` value and updates the production error mapping. Tests cover successful browser bailouts, recoverable abort reasons, root-fatal behavior, component stack information, the default noop behavior, and direct throws in development and production. --- .../src/__tests__/ReactDOMFizzServer-test.js | 35 ++++++++-- .../src/server/ReactDOMFizzServerBrowser.js | 4 ++ .../src/server/ReactDOMFizzServerBun.js | 2 + .../src/server/ReactDOMFizzServerEdge.js | 4 ++ .../src/server/ReactDOMFizzServerNode.js | 6 ++ .../src/server/ReactDOMFizzStaticBrowser.js | 4 ++ .../src/server/ReactDOMFizzStaticEdge.js | 4 ++ .../src/server/ReactDOMFizzStaticNode.js | 6 ++ .../src/server/ReactDOMLegacyServerImpl.js | 1 + .../react-dom/src/shared/ReactDOMBrowser.js | 7 +- .../react-markup/src/ReactMarkupClient.js | 1 + .../react-markup/src/ReactMarkupServer.js | 1 + .../src/ReactNoopServer.js | 2 + .../src/ReactDOMServerFB.js | 4 +- packages/react-server/src/ReactFizzHooks.js | 7 +- packages/react-server/src/ReactFizzServer.js | 64 ++++++++++++++++--- scripts/error-codes/codes.json | 2 +- 17 files changed, 130 insertions(+), 24 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index e1f3e7adc461..b0e47939a2c9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -432,16 +432,25 @@ describe('ReactDOMFizzServer', () => { } const serverErrors = []; + const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream(, { onError(error) { serverErrors.push(error); }, + onBrowserBailout(error, errorInfo) { + browserBailouts.push({error, errorInfo}); + }, }); pipe(writable); }); expect(serverErrors).toEqual([]); + expect(browserBailouts).toHaveLength(1); + expect(browserBailouts[0].error).toBe(browserOnly); + expect( + normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack), + ).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App'])); expect(getVisibleChildren(container)).toEqual(
Fallback @@ -548,6 +557,7 @@ describe('ReactDOMFizzServer', () => { } const reportedErrors = []; + const browserBailouts = []; let shellReady = false; let shellError; await act(() => { @@ -555,6 +565,9 @@ describe('ReactDOMFizzServer', () => { onError(error) { reportedErrors.push(error); }, + onBrowserBailout(error) { + browserBailouts.push(error); + }, onShellReady() { shellReady = true; }, @@ -573,11 +586,9 @@ describe('ReactDOMFizzServer', () => { expect(shellError.stack).toContain('BrowserOnly'); expect(shellError.cause).toBe(browserValue); expect(shellError.cause.stack).toContain('createBrowserValue'); - expect(shellError.cause.message).toContain( - '`use(browser())` can only be used inside a `` boundary', - ); expect(shellReady).toBe(false); expect(reportedErrors).toEqual([shellError]); + expect(browserBailouts).toEqual([]); }); // @gate enableBrowserAPI @@ -607,12 +618,17 @@ describe('ReactDOMFizzServer', () => { } const serverErrors = []; + const browserBailouts = []; + const browserValue = ReactDOM.browser(); let abort; await act(() => { const controls = renderToPipeableStream(, { onError(error) { serverErrors.push(error); }, + onBrowserBailout(error) { + browserBailouts.push(error); + }, }); abort = controls.abort; controls.pipe(writable); @@ -627,10 +643,11 @@ describe('ReactDOMFizzServer', () => { ); await act(() => { - abort(ReactDOM.browser()); + abort(browserValue); }); expect(serverErrors).toEqual([]); + expect(browserBailouts).toEqual([browserValue, browserValue]); isClient = true; const recoverableErrors = []; @@ -662,6 +679,7 @@ describe('ReactDOMFizzServer', () => { } const reportedErrors = []; + const browserBailouts = []; let shellReady = false; let shellError; let abort; @@ -670,6 +688,9 @@ describe('ReactDOMFizzServer', () => { onError(error) { reportedErrors.push(error); }, + onBrowserBailout(error) { + browserBailouts.push(error); + }, onShellReady() { shellReady = true; }, @@ -693,6 +714,7 @@ describe('ReactDOMFizzServer', () => { expect(shellError.cause).toBe(browserValue); expect(shellReady).toBe(false); expect(reportedErrors).toEqual([shellError]); + expect(browserBailouts).toEqual([]); }); // @gate enableBrowserAPI @@ -704,6 +726,7 @@ describe('ReactDOMFizzServer', () => { } const reportedErrors = []; + const browserBailouts = []; await act(() => { const {pipe} = renderToPipeableStream( Fallback}> @@ -713,12 +736,16 @@ describe('ReactDOMFizzServer', () => { onError(error) { reportedErrors.push(error); }, + onBrowserBailout(error) { + browserBailouts.push(error); + }, }, ); pipe(writable); }); expect(reportedErrors).toEqual([browserValue]); + expect(browserBailouts).toEqual([]); expect(getVisibleChildren(container)).toEqual(Fallback); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 51c25c0f765f..162e937f96db 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -53,6 +53,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, formState?: ReactFormState | null, @@ -64,6 +65,7 @@ type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, onError?: (error: mixed) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, }; @@ -141,6 +143,7 @@ function renderToReadableStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, @@ -211,6 +214,7 @@ function resume( options ? options.nonce : undefined, ), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index 2d9c6d1edbcc..8e3ce7a411c7 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -49,6 +49,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, formState?: ReactFormState | null, @@ -131,6 +132,7 @@ function renderToReadableStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js index 51c25c0f765f..162e937f96db 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -53,6 +53,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, formState?: ReactFormState | null, @@ -64,6 +65,7 @@ type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, onError?: (error: mixed) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, }; @@ -141,6 +143,7 @@ function renderToReadableStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, @@ -211,6 +214,7 @@ function resume( options ? options.nonce : undefined, ), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index d9261dd0f7ee..d99dcfef405c 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -76,6 +76,7 @@ type Options = { onShellError?: (error: mixed) => void, onAllReady?: () => void, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, formState?: ReactFormState | null, @@ -89,6 +90,7 @@ type ResumeOptions = { onShellError?: (error: mixed) => void, onAllReady?: () => void, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, }; type PipeableStream = { @@ -120,6 +122,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) { createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, options ? options.onAllReady : undefined, options ? options.onShellReady : undefined, options ? options.onShellError : undefined, @@ -278,6 +281,7 @@ function renderToReadableStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, @@ -313,6 +317,7 @@ function resumeRequestImpl( options ? options.nonce : undefined, ), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, options ? options.onAllReady : undefined, options ? options.onShellReady : undefined, options ? options.onShellError : undefined, @@ -415,6 +420,7 @@ function resume( options ? options.nonce : undefined, ), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, onShellReady, onShellError, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index 313b7f9c49f3..91c5e59384cc 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -53,6 +53,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, onHeaders?: (headers: Headers) => void, @@ -124,6 +125,7 @@ function prerender( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, @@ -149,6 +151,7 @@ type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, onError?: (error: mixed) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, }; @@ -189,6 +192,7 @@ function resumeAndPrerender( postponedState, resumeRenderState(postponedState.resumableState, undefined), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index 3196b3f7ce23..0117123523af 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -53,6 +53,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, onHeaders?: (headers: Headers) => void, @@ -123,6 +124,7 @@ function prerender( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, @@ -148,6 +150,7 @@ type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, }; function resumeAndPrerender( @@ -187,6 +190,7 @@ function resumeAndPrerender( postponedState, resumeRenderState(postponedState.resumableState, undefined), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index 3f777ae53d9e..7aabc76608b4 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -57,6 +57,7 @@ type Options = { progressiveChunkSize?: number, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, importMap?: ImportMap, onHeaders?: (headers: HeadersDescriptor) => void, @@ -155,6 +156,7 @@ function prerenderToNodeStream( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, @@ -245,6 +247,7 @@ function prerender( createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, @@ -270,6 +273,7 @@ type ResumeOptions = { nonce?: NonceOption, signal?: AbortSignal, onError?: (error: mixed, errorInfo: ErrorInfo) => ?string, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, }; function resumeAndPrerenderToNodeStream( @@ -299,6 +303,7 @@ function resumeAndPrerenderToNodeStream( postponedState, resumeRenderState(postponedState.resumableState, undefined), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, @@ -365,6 +370,7 @@ function resumeAndPrerender( postponedState, resumeRenderState(postponedState.resumableState, undefined), options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, onAllReady, undefined, undefined, diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js index 0dfc0f34d606..ae66f14e1b05 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js @@ -72,6 +72,7 @@ function renderToStringImpl( Infinity, onError, undefined, + undefined, onShellReady, undefined, undefined, diff --git a/packages/react-dom/src/shared/ReactDOMBrowser.js b/packages/react-dom/src/shared/ReactDOMBrowser.js index caabe635494a..eaf1c05d83d3 100644 --- a/packages/react-dom/src/shared/ReactDOMBrowser.js +++ b/packages/react-dom/src/shared/ReactDOMBrowser.js @@ -16,12 +16,7 @@ const browserImpl = function browser(): ReactRecoverable { // Recoverables are Errors so that a renderer can preserve the browser() call // site as the cause if no downstream renderer can recover the subtree. const recoverable = new Error( - "Recoverable Exception: This is not a real error! It's an " + - 'implementation detail of `use(browser())` to defer rendering to the ' + - 'browser. `use(browser())` can only be used inside a `` ' + - 'boundary. If a server render errors with this as its cause, the ' + - 'component that called `use(browser())` does not have a `` ' + - 'boundary above it.', + 'Browser-only rendering was requested by `browser()`.', ); Object.defineProperty(recoverable as any, '$$typeof', { value: REACT_RECOVERABLE_TYPE, diff --git a/packages/react-markup/src/ReactMarkupClient.js b/packages/react-markup/src/ReactMarkupClient.js index e71c41d44157..3c70e21e0034 100644 --- a/packages/react-markup/src/ReactMarkupClient.js +++ b/packages/react-markup/src/ReactMarkupClient.js @@ -85,6 +85,7 @@ export function experimental_renderToHTML( undefined, undefined, undefined, + undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-markup/src/ReactMarkupServer.js b/packages/react-markup/src/ReactMarkupServer.js index a885bd38f645..15a2e7827c18 100644 --- a/packages/react-markup/src/ReactMarkupServer.js +++ b/packages/react-markup/src/ReactMarkupServer.js @@ -215,6 +215,7 @@ export function experimental_renderToHTML( undefined, undefined, undefined, + undefined, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index d8397eab1ace..04f067a92baa 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -358,6 +358,7 @@ type Options = { onShellReady?: () => void, onAllReady?: () => void, onError?: (error: mixed) => ?string, + onBrowserBailout?: (error: mixed) => void, }; function render(children: React$Element, options?: Options): Destination { @@ -383,6 +384,7 @@ function render(children: React$Element, options?: Options): Destination { null, options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, + options ? options.onBrowserBailout : undefined, options ? options.onAllReady : undefined, options ? options.onShellReady : undefined, ); diff --git a/packages/react-server-dom-fb/src/ReactDOMServerFB.js b/packages/react-server-dom-fb/src/ReactDOMServerFB.js index 1b770988d4be..4bac90d9f2d7 100644 --- a/packages/react-server-dom-fb/src/ReactDOMServerFB.js +++ b/packages/react-server-dom-fb/src/ReactDOMServerFB.js @@ -9,7 +9,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; -import type {Request} from 'react-server/src/ReactFizzServer'; +import type {Request, ErrorInfo} from 'react-server/src/ReactFizzServer'; import type {Destination} from 'react-server/src/ReactServerStreamConfig'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; @@ -35,6 +35,7 @@ type Options = { bootstrapModules: Array, progressiveChunkSize?: number, onError: (error: mixed) => void, + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, }; @@ -68,6 +69,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream { createRootFormatContext(undefined), options ? options.progressiveChunkSize : undefined, options.onError, + options.onBrowserBailout, undefined, undefined, ); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 3a58b5688f99..e42ebcbee619 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -107,6 +107,9 @@ let suspendedRecoverableError: Error | null = null; export function createFatalRecoverableError( recoverable: ReactRecoverable, ): Error { + // This is created eagerly when use() encounters the recoverable so its stack + // points to the component call site. It only becomes fatal if no Suspense + // boundary can recover the render. return new Error( 'The server render could not complete because client rendering was ' + "requested outside a Suspense boundary. See this error's cause for " + @@ -314,10 +317,6 @@ export function getSuspendedRecoverableError(): Error { return error; } -export function clearSuspendedRecoverableError(): void { - suspendedRecoverableError = null; -} - export function checkDidRenderIdHook(): boolean { // This should be called immediately after every finishHooks call. // Conceptually, it's part of the return value of finishHooks; it's only a diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 1af0265ea116..6641ced68988 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -138,7 +138,6 @@ import { RecoverableException, createFatalRecoverableError, getSuspendedRecoverableError, - clearSuspendedRecoverableError, } from './ReactFizzHooks'; import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher'; import { @@ -415,6 +414,9 @@ export opaque type Request = { // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. // Returning null/undefined will cause a default error message in production onError: (error: mixed, errorInfo: ThrownInfo) => ?string, + // onBrowserBailout is called when Fizz recovers by intentionally deferring + // rendering to the browser. + onBrowserBailout: (error: mixed, errorInfo: ThrownInfo) => void, // onAllReady is called when all pending task is done but it may not have flushed yet. // This is a good time to start writing if you want only HTML and no intermediate steps. onAllReady: () => void, @@ -536,6 +538,7 @@ function RequestInstance( rootFormatContext: FormatContext, progressiveChunkSize: void | number, onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void), onAllReady: void | (() => void), onShellReady: void | (() => void), onShellError: void | ((error: mixed) => void), @@ -572,6 +575,8 @@ function RequestInstance( this.trackedPostpones = null; this.postponedState = null; this.onError = onError === undefined ? defaultErrorHandler : onError; + this.onBrowserBailout = + onBrowserBailout === undefined ? noop : onBrowserBailout; this.onAllReady = onAllReady === undefined ? noop : onAllReady; this.onShellReady = onShellReady === undefined ? noop : onShellReady; this.onShellError = onShellError === undefined ? noop : onShellError; @@ -589,6 +594,7 @@ export function createRequest( rootFormatContext: FormatContext, progressiveChunkSize: void | number, onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void), onAllReady: void | (() => void), onShellReady: void | (() => void), onShellError: void | ((error: mixed) => void), @@ -606,6 +612,7 @@ export function createRequest( rootFormatContext, progressiveChunkSize, onError, + onBrowserBailout, onAllReady, onShellReady, onShellError, @@ -656,6 +663,7 @@ export function createPrerenderRequest( rootFormatContext: FormatContext, progressiveChunkSize: void | number, onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void), onAllReady: void | (() => void), onShellReady: void | (() => void), onShellError: void | ((error: mixed) => void), @@ -668,6 +676,7 @@ export function createPrerenderRequest( rootFormatContext, progressiveChunkSize, onError, + onBrowserBailout, onAllReady, onShellReady, onShellError, @@ -688,6 +697,7 @@ export function resumeRequest( postponedState: PostponedState, renderState: RenderState, onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void), onAllReady: void | (() => void), onShellReady: void | (() => void), onShellError: void | ((error: mixed) => void), @@ -704,6 +714,7 @@ export function resumeRequest( postponedState.rootFormatContext, postponedState.progressiveChunkSize, onError, + onBrowserBailout, onAllReady, onShellReady, onShellError, @@ -782,6 +793,7 @@ export function resumeAndPrerenderRequest( postponedState: PostponedState, renderState: RenderState, onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string), + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void), onAllReady: void | (() => void), onShellReady: void | (() => void), onShellError: void | ((error: mixed) => void), @@ -792,6 +804,7 @@ export function resumeAndPrerenderRequest( postponedState, renderState, onError, + onBrowserBailout, onAllReady, onShellReady, onShellError, @@ -1363,7 +1376,16 @@ function logRecoverableError( debugTask: null | ConsoleTask, ): ?string { if (error === RecoverableException) { - clearSuspendedRecoverableError(); + // The fatal wrapper was created eagerly to capture the use() call site, but + // this path recovered at a Suspense boundary. Report its original cause and + // discard the wrapper. + const fatalRecoverableError = getSuspendedRecoverableError(); + logBrowserBailout( + request, + fatalRecoverableError.cause, + errorInfo, + debugTask, + ); return REACT_RECOVERABLE_DIGEST; } @@ -1391,6 +1413,22 @@ function logRecoverableError( return errorDigest === '' ? undefined : errorDigest; } +function logBrowserBailout( + request: Request, + error: mixed, + errorInfo: ThrownInfo, + debugTask: null | ConsoleTask, +): void { + // If this callback errors, we intentionally let that error bubble up to + // become a fatal error, matching the behavior of onError. + const onBrowserBailout = request.onBrowserBailout; + if (__DEV__ && debugTask) { + debugTask.run(onBrowserBailout.bind(null, error, errorInfo)); + } else { + onBrowserBailout(error, errorInfo); + } +} + function fatalError( request: Request, error: mixed, @@ -4861,6 +4899,7 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { let errorDigest; let errorForBoundary; if (isRecoverableAbort) { + logBrowserBailout(request, error, errorInfo, null); errorDigest = REACT_RECOVERABLE_DIGEST; errorForBoundary = RecoverableException; } else { @@ -4908,12 +4947,21 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { boundary.status = CLIENT_RENDERED; // We are aborting a render or resume which should put boundaries // into an explicitly client rendered state - const errorDigest = isRecoverableAbort - ? REACT_RECOVERABLE_DIGEST - : logRecoverableError(request, error, errorInfo, task.debugTask); - const errorForBoundary = isRecoverableAbort - ? RecoverableException - : error; + let errorDigest; + let errorForBoundary; + if (isRecoverableAbort) { + logBrowserBailout(request, error, errorInfo, task.debugTask); + errorDigest = REACT_RECOVERABLE_DIGEST; + errorForBoundary = RecoverableException; + } else { + errorDigest = logRecoverableError( + request, + error, + errorInfo, + task.debugTask, + ); + errorForBoundary = error; + } encodeErrorForBoundary( boundary, errorDigest, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index f2eb43edb17e..a1ebcc3106f5 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -588,7 +588,7 @@ "600": "A rejected Promise was passed to React without a `reason` property. React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", "601": "A chunk pair is incomplete. This is a bug in React.", "602": "Cannot handle action key. This is a bug in React.", - "603": "Recoverable Exception: This is not a real error! It's an implementation detail of `use(browser())` to defer rendering to the browser. `use(browser())` can only be used inside a `` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `` boundary above it.", + "603": "Browser-only rendering was requested by `browser()`.", "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.",