@samitouri / QOS-React / commits / 2042572329

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.

Josh Story committed Aug 7, 2026 at 22:31 UTC 2042572329425f9ebf35ae6287ea5bab72b2c497
17 files changed +130 -24
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+31 -4
@@ -432,16 +432,25 @@ describe('ReactDOMFizzServer', () => {
432 }
433
434 const serverErrors = [];
435 + const browserBailouts = [];
436 await act(() => {
437 const {pipe} = renderToPipeableStream(<App />, {
438 onError(error) {
439 serverErrors.push(error);
440 },
441 + onBrowserBailout(error, errorInfo) {
442 + browserBailouts.push({error, errorInfo});
443 + },
444 });
445 pipe(writable);
446 });
447
448 expect(serverErrors).toEqual([]);
449 + expect(browserBailouts).toHaveLength(1);
450 + expect(browserBailouts[0].error).toBe(browserOnly);
451 + expect(
452 + normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack),
453 + ).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App']));
454 expect(getVisibleChildren(container)).toEqual(
455 <div>
456 <span>Fallback</span>
@@ -548,6 +557,7 @@ describe('ReactDOMFizzServer', () => {
557 }
558
559 const reportedErrors = [];
560 + const browserBailouts = [];
561 let shellReady = false;
562 let shellError;
563 await act(() => {
@@ -555,6 +565,9 @@ describe('ReactDOMFizzServer', () => {
565 onError(error) {
566 reportedErrors.push(error);
567 },
568 + onBrowserBailout(error) {
569 + browserBailouts.push(error);
570 + },
571 onShellReady() {
572 shellReady = true;
573 },
@@ -573,11 +586,9 @@ describe('ReactDOMFizzServer', () => {
586 expect(shellError.stack).toContain('BrowserOnly');
587 expect(shellError.cause).toBe(browserValue);
588 expect(shellError.cause.stack).toContain('createBrowserValue');
576 - expect(shellError.cause.message).toContain(
577 - '`use(browser())` can only be used inside a `<Suspense>` boundary',
578 - );
589 expect(shellReady).toBe(false);
590 expect(reportedErrors).toEqual([shellError]);
591 + expect(browserBailouts).toEqual([]);
592 });
593
594 // @gate enableBrowserAPI
@@ -607,12 +618,17 @@ describe('ReactDOMFizzServer', () => {
618 }
619
620 const serverErrors = [];
621 + const browserBailouts = [];
622 + const browserValue = ReactDOM.browser();
623 let abort;
624 await act(() => {
625 const controls = renderToPipeableStream(<App />, {
626 onError(error) {
627 serverErrors.push(error);
628 },
629 + onBrowserBailout(error) {
630 + browserBailouts.push(error);
631 + },
632 });
633 abort = controls.abort;
634 controls.pipe(writable);
@@ -627,10 +643,11 @@ describe('ReactDOMFizzServer', () => {
643 );
644
645 await act(() => {
630 - abort(ReactDOM.browser());
646 + abort(browserValue);
647 });
648
649 expect(serverErrors).toEqual([]);
650 + expect(browserBailouts).toEqual([browserValue, browserValue]);
651
652 isClient = true;
653 const recoverableErrors = [];
@@ -662,6 +679,7 @@ describe('ReactDOMFizzServer', () => {
679 }
680
681 const reportedErrors = [];
682 + const browserBailouts = [];
683 let shellReady = false;
684 let shellError;
685 let abort;
@@ -670,6 +688,9 @@ describe('ReactDOMFizzServer', () => {
688 onError(error) {
689 reportedErrors.push(error);
690 },
691 + onBrowserBailout(error) {
692 + browserBailouts.push(error);
693 + },
694 onShellReady() {
695 shellReady = true;
696 },
@@ -693,6 +714,7 @@ describe('ReactDOMFizzServer', () => {
714 expect(shellError.cause).toBe(browserValue);
715 expect(shellReady).toBe(false);
716 expect(reportedErrors).toEqual([shellError]);
717 + expect(browserBailouts).toEqual([]);
718 });
719
720 // @gate enableBrowserAPI
@@ -704,6 +726,7 @@ describe('ReactDOMFizzServer', () => {
726 }
727
728 const reportedErrors = [];
729 + const browserBailouts = [];
730 await act(() => {
731 const {pipe} = renderToPipeableStream(
732 <Suspense fallback={<span>Fallback</span>}>
@@ -713,12 +736,16 @@ describe('ReactDOMFizzServer', () => {
736 onError(error) {
737 reportedErrors.push(error);
738 },
739 + onBrowserBailout(error) {
740 + browserBailouts.push(error);
741 + },
742 },
743 );
744 pipe(writable);
745 });
746
747 expect(reportedErrors).toEqual([browserValue]);
748 + expect(browserBailouts).toEqual([]);
749 expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
750 });
751
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+4
@@ -53,6 +53,7 @@ type Options = {
53 progressiveChunkSize?: number,
54 signal?: AbortSignal,
55 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
56 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
57 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
58 importMap?: ImportMap,
59 formState?: ReactFormState<any, any> | null,
@@ -64,6 +65,7 @@ type ResumeOptions = {
65 nonce?: NonceOption,
66 signal?: AbortSignal,
67 onError?: (error: mixed) => ?string,
68 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
69 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
70 };
71
@@ -141,6 +143,7 @@ function renderToReadableStream(
143 createRootFormatContext(options ? options.namespaceURI : undefined),
144 options ? options.progressiveChunkSize : undefined,
145 options ? options.onError : undefined,
146 + options ? options.onBrowserBailout : undefined,
147 onAllReady,
148 onShellReady,
149 onShellError,
@@ -211,6 +214,7 @@ function resume(
214 options ? options.nonce : undefined,
215 ),
216 options ? options.onError : undefined,
217 + options ? options.onBrowserBailout : undefined,
218 onAllReady,
219 onShellReady,
220 onShellError,
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+2
@@ -49,6 +49,7 @@ type Options = {
49 progressiveChunkSize?: number,
50 signal?: AbortSignal,
51 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
52 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
53 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
54 importMap?: ImportMap,
55 formState?: ReactFormState<any, any> | null,
@@ -131,6 +132,7 @@ function renderToReadableStream(
132 createRootFormatContext(options ? options.namespaceURI : undefined),
133 options ? options.progressiveChunkSize : undefined,
134 options ? options.onError : undefined,
135 + options ? options.onBrowserBailout : undefined,
136 onAllReady,
137 onShellReady,
138 onShellError,
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+4
@@ -53,6 +53,7 @@ type Options = {
53 progressiveChunkSize?: number,
54 signal?: AbortSignal,
55 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
56 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
57 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
58 importMap?: ImportMap,
59 formState?: ReactFormState<any, any> | null,
@@ -64,6 +65,7 @@ type ResumeOptions = {
65 nonce?: NonceOption,
66 signal?: AbortSignal,
67 onError?: (error: mixed) => ?string,
68 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
69 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
70 };
71
@@ -141,6 +143,7 @@ function renderToReadableStream(
143 createRootFormatContext(options ? options.namespaceURI : undefined),
144 options ? options.progressiveChunkSize : undefined,
145 options ? options.onError : undefined,
146 + options ? options.onBrowserBailout : undefined,
147 onAllReady,
148 onShellReady,
149 onShellError,
@@ -211,6 +214,7 @@ function resume(
214 options ? options.nonce : undefined,
215 ),
216 options ? options.onError : undefined,
217 + options ? options.onBrowserBailout : undefined,
218 onAllReady,
219 onShellReady,
220 onShellError,
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+6
@@ -76,6 +76,7 @@ type Options = {
76 onShellError?: (error: mixed) => void,
77 onAllReady?: () => void,
78 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
79 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
80 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
81 importMap?: ImportMap,
82 formState?: ReactFormState<any, any> | null,
@@ -89,6 +90,7 @@ type ResumeOptions = {
90 onShellError?: (error: mixed) => void,
91 onAllReady?: () => void,
92 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
93 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
94 };
95
96 type PipeableStream = {
@@ -120,6 +122,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
122 createRootFormatContext(options ? options.namespaceURI : undefined),
123 options ? options.progressiveChunkSize : undefined,
124 options ? options.onError : undefined,
125 + options ? options.onBrowserBailout : undefined,
126 options ? options.onAllReady : undefined,
127 options ? options.onShellReady : undefined,
128 options ? options.onShellError : undefined,
@@ -278,6 +281,7 @@ function renderToReadableStream(
281 createRootFormatContext(options ? options.namespaceURI : undefined),
282 options ? options.progressiveChunkSize : undefined,
283 options ? options.onError : undefined,
284 + options ? options.onBrowserBailout : undefined,
285 onAllReady,
286 onShellReady,
287 onShellError,
@@ -313,6 +317,7 @@ function resumeRequestImpl(
317 options ? options.nonce : undefined,
318 ),
319 options ? options.onError : undefined,
320 + options ? options.onBrowserBailout : undefined,
321 options ? options.onAllReady : undefined,
322 options ? options.onShellReady : undefined,
323 options ? options.onShellError : undefined,
@@ -415,6 +420,7 @@ function resume(
420 options ? options.nonce : undefined,
421 ),
422 options ? options.onError : undefined,
423 + options ? options.onBrowserBailout : undefined,
424 onAllReady,
425 onShellReady,
426 onShellError,
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+4
@@ -53,6 +53,7 @@ type Options = {
53 progressiveChunkSize?: number,
54 signal?: AbortSignal,
55 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
56 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
57 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
58 importMap?: ImportMap,
59 onHeaders?: (headers: Headers) => void,
@@ -124,6 +125,7 @@ function prerender(
125 createRootFormatContext(options ? options.namespaceURI : undefined),
126 options ? options.progressiveChunkSize : undefined,
127 options ? options.onError : undefined,
128 + options ? options.onBrowserBailout : undefined,
129 onAllReady,
130 undefined,
131 undefined,
@@ -149,6 +151,7 @@ type ResumeOptions = {
151 nonce?: NonceOption,
152 signal?: AbortSignal,
153 onError?: (error: mixed) => ?string,
154 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
155 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
156 };
157
@@ -189,6 +192,7 @@ function resumeAndPrerender(
192 postponedState,
193 resumeRenderState(postponedState.resumableState, undefined),
194 options ? options.onError : undefined,
195 + options ? options.onBrowserBailout : undefined,
196 onAllReady,
197 undefined,
198 undefined,
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+4
@@ -53,6 +53,7 @@ type Options = {
53 progressiveChunkSize?: number,
54 signal?: AbortSignal,
55 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
56 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
57 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
58 importMap?: ImportMap,
59 onHeaders?: (headers: Headers) => void,
@@ -123,6 +124,7 @@ function prerender(
124 createRootFormatContext(options ? options.namespaceURI : undefined),
125 options ? options.progressiveChunkSize : undefined,
126 options ? options.onError : undefined,
127 + options ? options.onBrowserBailout : undefined,
128 onAllReady,
129 undefined,
130 undefined,
@@ -148,6 +150,7 @@ type ResumeOptions = {
150 nonce?: NonceOption,
151 signal?: AbortSignal,
152 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
153 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
154 };
155
156 function resumeAndPrerender(
@@ -187,6 +190,7 @@ function resumeAndPrerender(
190 postponedState,
191 resumeRenderState(postponedState.resumableState, undefined),
192 options ? options.onError : undefined,
193 + options ? options.onBrowserBailout : undefined,
194 onAllReady,
195 undefined,
196 undefined,
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+6
@@ -57,6 +57,7 @@ type Options = {
57 progressiveChunkSize?: number,
58 signal?: AbortSignal,
59 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
60 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
61 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
62 importMap?: ImportMap,
63 onHeaders?: (headers: HeadersDescriptor) => void,
@@ -155,6 +156,7 @@ function prerenderToNodeStream(
156 createRootFormatContext(options ? options.namespaceURI : undefined),
157 options ? options.progressiveChunkSize : undefined,
158 options ? options.onError : undefined,
159 + options ? options.onBrowserBailout : undefined,
160 onAllReady,
161 undefined,
162 undefined,
@@ -245,6 +247,7 @@ function prerender(
247 createRootFormatContext(options ? options.namespaceURI : undefined),
248 options ? options.progressiveChunkSize : undefined,
249 options ? options.onError : undefined,
250 + options ? options.onBrowserBailout : undefined,
251 onAllReady,
252 undefined,
253 undefined,
@@ -270,6 +273,7 @@ type ResumeOptions = {
273 nonce?: NonceOption,
274 signal?: AbortSignal,
275 onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
276 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
277 };
278
279 function resumeAndPrerenderToNodeStream(
@@ -299,6 +303,7 @@ function resumeAndPrerenderToNodeStream(
303 postponedState,
304 resumeRenderState(postponedState.resumableState, undefined),
305 options ? options.onError : undefined,
306 + options ? options.onBrowserBailout : undefined,
307 onAllReady,
308 undefined,
309 undefined,
@@ -365,6 +370,7 @@ function resumeAndPrerender(
370 postponedState,
371 resumeRenderState(postponedState.resumableState, undefined),
372 options ? options.onError : undefined,
373 + options ? options.onBrowserBailout : undefined,
374 onAllReady,
375 undefined,
376 undefined,
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js
+1
@@ -72,6 +72,7 @@ function renderToStringImpl(
72 Infinity,
73 onError,
74 undefined,
75 + undefined,
76 onShellReady,
77 undefined,
78 undefined,
packages/react-dom/src/shared/ReactDOMBrowser.js
+1 -6
@@ -16,12 +16,7 @@ const browserImpl = function browser(): ReactRecoverable {
16 // Recoverables are Errors so that a renderer can preserve the browser() call
17 // site as the cause if no downstream renderer can recover the subtree.
18 const recoverable = new Error(
19 - "Recoverable Exception: This is not a real error! It's an " +
20 - 'implementation detail of `use(browser())` to defer rendering to the ' +
21 - 'browser. `use(browser())` can only be used inside a `<Suspense>` ' +
22 - 'boundary. If a server render errors with this as its cause, the ' +
23 - 'component that called `use(browser())` does not have a `<Suspense>` ' +
24 - 'boundary above it.',
19 + 'Browser-only rendering was requested by `browser()`.',
20 );
21 Object.defineProperty(recoverable as any, '$$typeof', {
22 value: REACT_RECOVERABLE_TYPE,
packages/react-markup/src/ReactMarkupClient.js
+1
@@ -85,6 +85,7 @@ export function experimental_renderToHTML(
85 undefined,
86 undefined,
87 undefined,
88 + undefined,
89 );
90 if (options && options.signal) {
91 const signal = options.signal;
packages/react-markup/src/ReactMarkupServer.js
+1
@@ -215,6 +215,7 @@ export function experimental_renderToHTML(
215 undefined,
216 undefined,
217 undefined,
218 + undefined,
219 );
220 if (options && options.signal) {
221 const signal = options.signal;
packages/react-noop-renderer/src/ReactNoopServer.js
+2
@@ -358,6 +358,7 @@ type Options = {
358 onShellReady?: () => void,
359 onAllReady?: () => void,
360 onError?: (error: mixed) => ?string,
361 + onBrowserBailout?: (error: mixed) => void,
362 };
363
364 function render(children: React$Element<any>, options?: Options): Destination {
@@ -383,6 +384,7 @@ function render(children: React$Element<any>, options?: Options): Destination {
384 null,
385 options ? options.progressiveChunkSize : undefined,
386 options ? options.onError : undefined,
387 + options ? options.onBrowserBailout : undefined,
388 options ? options.onAllReady : undefined,
389 options ? options.onShellReady : undefined,
390 );
packages/react-server-dom-fb/src/ReactDOMServerFB.js
+3 -1
@@ -9,7 +9,7 @@
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11
12 -import type {Request} from 'react-server/src/ReactFizzServer';
12 +import type {Request, ErrorInfo} from 'react-server/src/ReactFizzServer';
13
14 import type {Destination} from 'react-server/src/ReactServerStreamConfig';
15 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
@@ -35,6 +35,7 @@ type Options = {
35 bootstrapModules: Array<string>,
36 progressiveChunkSize?: number,
37 onError: (error: mixed) => void,
38 + onBrowserBailout?: (error: mixed, errorInfo: ErrorInfo) => void,
39 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
40 };
41
@@ -68,6 +69,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream {
69 createRootFormatContext(undefined),
70 options ? options.progressiveChunkSize : undefined,
71 options.onError,
72 + options.onBrowserBailout,
73 undefined,
74 undefined,
75 );
packages/react-server/src/ReactFizzHooks.js
+3 -4
@@ -107,6 +107,9 @@ let suspendedRecoverableError: Error | null = null;
107 export function createFatalRecoverableError(
108 recoverable: ReactRecoverable,
109 ): Error {
110 + // This is created eagerly when use() encounters the recoverable so its stack
111 + // points to the component call site. It only becomes fatal if no Suspense
112 + // boundary can recover the render.
113 return new Error(
114 'The server render could not complete because client rendering was ' +
115 "requested outside a Suspense boundary. See this error's cause for " +
@@ -314,10 +317,6 @@ export function getSuspendedRecoverableError(): Error {
317 return error;
318 }
319
317 -export function clearSuspendedRecoverableError(): void {
318 - suspendedRecoverableError = null;
319 -}
320 -
320 export function checkDidRenderIdHook(): boolean {
321 // This should be called immediately after every finishHooks call.
322 // Conceptually, it's part of the return value of finishHooks; it's only a
packages/react-server/src/ReactFizzServer.js
+56 -8
@@ -138,7 +138,6 @@ import {
138 RecoverableException,
139 createFatalRecoverableError,
140 getSuspendedRecoverableError,
141 - clearSuspendedRecoverableError,
141 } from './ReactFizzHooks';
142 import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher';
143 import {
@@ -415,6 +414,9 @@ export opaque type Request = {
414 // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
415 // Returning null/undefined will cause a default error message in production
416 onError: (error: mixed, errorInfo: ThrownInfo) => ?string,
417 + // onBrowserBailout is called when Fizz recovers by intentionally deferring
418 + // rendering to the browser.
419 + onBrowserBailout: (error: mixed, errorInfo: ThrownInfo) => void,
420 // onAllReady is called when all pending task is done but it may not have flushed yet.
421 // This is a good time to start writing if you want only HTML and no intermediate steps.
422 onAllReady: () => void,
@@ -536,6 +538,7 @@ function RequestInstance(
538 rootFormatContext: FormatContext,
539 progressiveChunkSize: void | number,
540 onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
541 + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void),
542 onAllReady: void | (() => void),
543 onShellReady: void | (() => void),
544 onShellError: void | ((error: mixed) => void),
@@ -572,6 +575,8 @@ function RequestInstance(
575 this.trackedPostpones = null;
576 this.postponedState = null;
577 this.onError = onError === undefined ? defaultErrorHandler : onError;
578 + this.onBrowserBailout =
579 + onBrowserBailout === undefined ? noop : onBrowserBailout;
580 this.onAllReady = onAllReady === undefined ? noop : onAllReady;
581 this.onShellReady = onShellReady === undefined ? noop : onShellReady;
582 this.onShellError = onShellError === undefined ? noop : onShellError;
@@ -589,6 +594,7 @@ export function createRequest(
594 rootFormatContext: FormatContext,
595 progressiveChunkSize: void | number,
596 onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
597 + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void),
598 onAllReady: void | (() => void),
599 onShellReady: void | (() => void),
600 onShellError: void | ((error: mixed) => void),
@@ -606,6 +612,7 @@ export function createRequest(
612 rootFormatContext,
613 progressiveChunkSize,
614 onError,
615 + onBrowserBailout,
616 onAllReady,
617 onShellReady,
618 onShellError,
@@ -656,6 +663,7 @@ export function createPrerenderRequest(
663 rootFormatContext: FormatContext,
664 progressiveChunkSize: void | number,
665 onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
666 + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void),
667 onAllReady: void | (() => void),
668 onShellReady: void | (() => void),
669 onShellError: void | ((error: mixed) => void),
@@ -668,6 +676,7 @@ export function createPrerenderRequest(
676 rootFormatContext,
677 progressiveChunkSize,
678 onError,
679 + onBrowserBailout,
680 onAllReady,
681 onShellReady,
682 onShellError,
@@ -688,6 +697,7 @@ export function resumeRequest(
697 postponedState: PostponedState,
698 renderState: RenderState,
699 onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
700 + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void),
701 onAllReady: void | (() => void),
702 onShellReady: void | (() => void),
703 onShellError: void | ((error: mixed) => void),
@@ -704,6 +714,7 @@ export function resumeRequest(
714 postponedState.rootFormatContext,
715 postponedState.progressiveChunkSize,
716 onError,
717 + onBrowserBailout,
718 onAllReady,
719 onShellReady,
720 onShellError,
@@ -782,6 +793,7 @@ export function resumeAndPrerenderRequest(
793 postponedState: PostponedState,
794 renderState: RenderState,
795 onError: void | ((error: mixed, errorInfo: ErrorInfo) => ?string),
796 + onBrowserBailout: void | ((error: mixed, errorInfo: ErrorInfo) => void),
797 onAllReady: void | (() => void),
798 onShellReady: void | (() => void),
799 onShellError: void | ((error: mixed) => void),
@@ -792,6 +804,7 @@ export function resumeAndPrerenderRequest(
804 postponedState,
805 renderState,
806 onError,
807 + onBrowserBailout,
808 onAllReady,
809 onShellReady,
810 onShellError,
@@ -1363,7 +1376,16 @@ function logRecoverableError(
1376 debugTask: null | ConsoleTask,
1377 ): ?string {
1378 if (error === RecoverableException) {
1366 - clearSuspendedRecoverableError();
1379 + // The fatal wrapper was created eagerly to capture the use() call site, but
1380 + // this path recovered at a Suspense boundary. Report its original cause and
1381 + // discard the wrapper.
1382 + const fatalRecoverableError = getSuspendedRecoverableError();
1383 + logBrowserBailout(
1384 + request,
1385 + fatalRecoverableError.cause,
1386 + errorInfo,
1387 + debugTask,
1388 + );
1389 return REACT_RECOVERABLE_DIGEST;
1390 }
1391
@@ -1391,6 +1413,22 @@ function logRecoverableError(
1413 return errorDigest === '' ? undefined : errorDigest;
1414 }
1415
1416 +function logBrowserBailout(
1417 + request: Request,
1418 + error: mixed,
1419 + errorInfo: ThrownInfo,
1420 + debugTask: null | ConsoleTask,
1421 +): void {
1422 + // If this callback errors, we intentionally let that error bubble up to
1423 + // become a fatal error, matching the behavior of onError.
1424 + const onBrowserBailout = request.onBrowserBailout;
1425 + if (__DEV__ && debugTask) {
1426 + debugTask.run(onBrowserBailout.bind(null, error, errorInfo));
1427 + } else {
1428 + onBrowserBailout(error, errorInfo);
1429 + }
1430 +}
1431 +
1432 function fatalError(
1433 request: Request,
1434 error: mixed,
@@ -4861,6 +4899,7 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4899 let errorDigest;
4900 let errorForBoundary;
4901 if (isRecoverableAbort) {
4902 + logBrowserBailout(request, error, errorInfo, null);
4903 errorDigest = REACT_RECOVERABLE_DIGEST;
4904 errorForBoundary = RecoverableException;
4905 } else {
@@ -4908,12 +4947,21 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4947 boundary.status = CLIENT_RENDERED;
4948 // We are aborting a render or resume which should put boundaries
4949 // into an explicitly client rendered state
4911 - const errorDigest = isRecoverableAbort
4912 - ? REACT_RECOVERABLE_DIGEST
4913 - : logRecoverableError(request, error, errorInfo, task.debugTask);
4914 - const errorForBoundary = isRecoverableAbort
4915 - ? RecoverableException
4916 - : error;
4950 + let errorDigest;
4951 + let errorForBoundary;
4952 + if (isRecoverableAbort) {
4953 + logBrowserBailout(request, error, errorInfo, task.debugTask);
4954 + errorDigest = REACT_RECOVERABLE_DIGEST;
4955 + errorForBoundary = RecoverableException;
4956 + } else {
4957 + errorDigest = logRecoverableError(
4958 + request,
4959 + error,
4960 + errorInfo,
4961 + task.debugTask,
4962 + );
4963 + errorForBoundary = error;
4964 + }
4965 encodeErrorForBoundary(
4966 boundary,
4967 errorDigest,
scripts/error-codes/codes.json
+1 -1
@@ -588,7 +588,7 @@
588 "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'`.",
589 "601": "A chunk pair is incomplete. This is a bug in React.",
590 "602": "Cannot handle action key. This is a bug in React.",
591 - "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 `<Suspense>` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `<Suspense>` boundary above it.",
591 + "603": "Browser-only rendering was requested by `browser()`.",
592 "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.",
593 "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.",
594 "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.",