@samitouri / QOS-React-2 / commits / 807d21fdfd

Add lazy reasons to browser() (#37241)

Changes `ReactDOM.browser()` to return a cheap branded recoverable token instead of eagerly constructing an `Error`. It accepts an optional reason string or initializer that runs only when a server renderer consumes the token and may return any value; the client renderer ignores the reason without invoking the initializer, so browser-only rendering does not pay for an unused stack. When Fizz consumes the token through `use()` or `abort()`, it creates a consistent browser-bailout error at the consumption point so its stack identifies the relevant operation. The initialized reason is preserved unchanged as the optional `cause`, allowing strings, errors, and structured framework metadata without runtime validation. If an initializer throws, Fizz substitutes a stable diagnostic fallback so reason generation cannot change rendering control flow. Successful recoveries report the error through `onBrowserBailout`. When no Suspense boundary can recover the render, Fizz clones the branded recoverable error into an unbranded fatal diagnostic while preserving its cause and consumption frames. During an abort, the request retains the original branded error so every remaining task observes the same reason; fatal clones are created only when reporting a fatal root or closing the stream. Centralized recoverable logging uses the brand to route successful bailouts through `onBrowserBailout` and fatal clones through `onError`. The empty recoverable digest and client hydration suppression behavior remain unchanged. Tests cover omitted and direct reasons, lazy string, error, structured, and primitive reasons, repeated use sites, throwing initializers, lazy client behavior, consumption stacks, flattened fatal errors, recoverable and fatal use and abort paths, nested aborts, direct throws, debug tools, and development and production rendering.

Josh Story committed Aug 10, 2026 at 11:42 UTC 807d21fdfdcf0da588e4ce3bdb9e8e539a34b5a1
8 files changed +633 -145
packages/react-debug-tools/src/ReactDebugHooks.js
+4 -4
@@ -111,10 +111,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
111 $$typeof: REACT_CONTEXT_TYPE,
112 _currentValue: null,
113 } as any);
114 - const recoverable = new Error();
115 - Object.defineProperty(recoverable as any, '$$typeof', {
116 - value: REACT_RECOVERABLE_TYPE,
117 - });
114 + const recoverable = {
115 + $$typeof: REACT_RECOVERABLE_TYPE,
116 + _reason: undefined,
117 + };
118 Dispatcher.use(recoverable as any);
119 Dispatcher.use({
120 then() {},
packages/react-dom/src/__tests__/ReactDOMBrowser-test.js
+5 -1
@@ -18,7 +18,10 @@ describe('ReactDOM.browser', () => {
18 it('can create browser-only content before the browser renderer is initialized', async () => {
19 const React = require('react');
20 const ReactDOM = require('react-dom');
21 - const browserOnly = ReactDOM.browser();
21 + const initializeReason = jest.fn(
22 + () => new Error('Only render this content in a browser'),
23 + );
24 + const browserOnly = ReactDOM.browser(initializeReason);
25 const ReactDOMClient = require('react-dom/client');
26 const {act} = require('internal-test-utils');
27
@@ -37,5 +40,6 @@ describe('ReactDOM.browser', () => {
40 );
41 });
42 expect(container.innerHTML).toBe('<span>Browser</span>');
43 + expect(initializeReason).not.toHaveBeenCalled();
44 });
45 });
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+439 -16
@@ -412,7 +412,14 @@ describe('ReactDOMFizzServer', () => {
412 const browserText = new Promise(resolve => {
413 resolveBrowserText = resolve;
414 });
415 - const browserOnly = ReactDOM.browser();
415 + let browserReason;
416 + const initializeReason = jest.fn(() => {
417 + browserReason = Object.freeze(
418 + new Error('Only render this content in a browser'),
419 + );
420 + return browserReason;
421 + });
422 + const browserOnly = ReactDOM.browser(initializeReason);
423
424 function BrowserOnly() {
425 use(browserOnly);
@@ -446,8 +453,14 @@ describe('ReactDOMFizzServer', () => {
453 });
454
455 expect(serverErrors).toEqual([]);
456 + expect(initializeReason).toHaveBeenCalledTimes(1);
457 expect(browserBailouts).toHaveLength(1);
450 - expect(browserBailouts[0].error).toBe(browserOnly);
458 + expect(browserBailouts[0].error).toBeInstanceOf(Error);
459 + expect(browserBailouts[0].error.message).toBe(
460 + 'Browser-only rendering was requested by `browser()`.',
461 + );
462 + expect(browserBailouts[0].error.stack).toContain('BrowserOnly');
463 + expect(browserBailouts[0].error.cause).toBe(browserReason);
464 expect(
465 normalizeCodeLocInfo(browserBailouts[0].errorInfo.componentStack),
466 ).toBe(componentStack(['BrowserOnly', 'Suspense', 'div', 'App']));
@@ -476,6 +489,7 @@ describe('ReactDOMFizzServer', () => {
489 assertLog(['Browser']);
490
491 expect(recoverableErrors).toEqual([]);
492 + expect(initializeReason).toHaveBeenCalledTimes(1);
493 expect(getVisibleChildren(container)).toEqual(
494 <div>
495 <span>Browser</span>
@@ -489,10 +503,13 @@ describe('ReactDOMFizzServer', () => {
503 const serverReady = new Promise(resolve => {
504 resolveServerReady = resolve;
505 });
506 + const initializeReason = jest.fn(
507 + () => 'Only render this content in a browser',
508 + );
509
510 function BrowserOnly() {
511 use(serverReady);
495 - use(ReactDOM.browser());
512 + use(ReactDOM.browser(initializeReason));
513 return <span>Browser</span>;
514 }
515
@@ -507,11 +524,15 @@ describe('ReactDOMFizzServer', () => {
524 }
525
526 const serverErrors = [];
527 + const browserBailouts = [];
528 await act(() => {
529 const {pipe} = renderToPipeableStream(<App />, {
530 onError(error) {
531 serverErrors.push(error);
532 },
533 + onBrowserBailout(error) {
534 + browserBailouts.push(error);
535 + },
536 });
537 pipe(writable);
538 });
@@ -527,6 +548,15 @@ describe('ReactDOMFizzServer', () => {
548 });
549
550 expect(serverErrors).toEqual([]);
551 + expect(initializeReason).toHaveBeenCalledTimes(1);
552 + expect(browserBailouts).toHaveLength(1);
553 + expect(browserBailouts[0].message).toBe(
554 + 'Browser-only rendering was requested by `browser()`.',
555 + );
556 + expect(browserBailouts[0].stack).toContain('BrowserOnly');
557 + expect(browserBailouts[0].cause).toBe(
558 + 'Only render this content in a browser',
559 + );
560
561 const recoverableErrors = [];
562 ReactDOMClient.hydrateRoot(container, <App />, {
@@ -537,6 +567,7 @@ describe('ReactDOMFizzServer', () => {
567 await waitForAll([]);
568
569 expect(recoverableErrors).toEqual([]);
570 + expect(initializeReason).toHaveBeenCalledTimes(1);
571 expect(getVisibleChildren(container)).toEqual(
572 <div>
573 <span>Browser</span>
@@ -545,11 +576,207 @@ describe('ReactDOMFizzServer', () => {
576 });
577
578 // @gate enableBrowserAPI
548 - it('errors if browser-only content is rendered outside Suspense', async () => {
549 - function createBrowserValue() {
550 - return ReactDOM.browser();
579 + it('supports omitted and direct string browser reasons', async () => {
580 + const directReason = 'Only render this content in a browser';
581 + const withoutReason = ReactDOM.browser();
582 + const withDirectReason = ReactDOM.browser(directReason);
583 +
584 + function WithoutReason() {
585 + use(withoutReason);
586 + return <span>Browser</span>;
587 }
552 - const browserValue = createBrowserValue();
588 +
589 + function WithDirectReason() {
590 + use(withDirectReason);
591 + return <span>Browser</span>;
592 + }
593 +
594 + const serverErrors = [];
595 + const browserBailouts = [];
596 + await act(() => {
597 + const {pipe} = renderToPipeableStream(
598 + <>
599 + <Suspense fallback={<span>Fallback A</span>}>
600 + <WithoutReason />
601 + </Suspense>
602 + <Suspense fallback={<span>Fallback B</span>}>
603 + <WithDirectReason />
604 + </Suspense>
605 + </>,
606 + {
607 + onError(error) {
608 + serverErrors.push(error);
609 + },
610 + onBrowserBailout(error) {
611 + browserBailouts.push(error);
612 + },
613 + },
614 + );
615 + pipe(writable);
616 + });
617 +
618 + expect(serverErrors).toEqual([]);
619 + expect(browserBailouts).toHaveLength(2);
620 + expect(browserBailouts[0].message).toBe(
621 + 'Browser-only rendering was requested by `browser()`.',
622 + );
623 + expect(browserBailouts[0].stack).toContain('WithoutReason');
624 + expect(
625 + Object.prototype.hasOwnProperty.call(browserBailouts[0], 'cause'),
626 + ).toBe(false);
627 + expect(browserBailouts[1].message).toBe(
628 + 'Browser-only rendering was requested by `browser()`.',
629 + );
630 + expect(browserBailouts[1].stack).toContain('WithDirectReason');
631 + expect(browserBailouts[1].cause).toBe(directReason);
632 + });
633 +
634 + // @gate enableBrowserAPI
635 + it('supports any value returned by a browser reason initializer', async () => {
636 + const reasonValues = [undefined, null, 42, Symbol('browser reason')];
637 + const initializeReasons = reasonValues.map(reason => jest.fn(() => reason));
638 + const browserValues = initializeReasons.map(initializeReason =>
639 + ReactDOM.browser(initializeReason),
640 + );
641 +
642 + function BrowserOnly({browserValue}) {
643 + use(browserValue);
644 + return <span>Browser</span>;
645 + }
646 +
647 + const serverErrors = [];
648 + const browserBailouts = [];
649 + await act(() => {
650 + const {pipe} = renderToPipeableStream(
651 + <>
652 + {browserValues.map((browserValue, index) => (
653 + <Suspense key={index} fallback={<span>Fallback</span>}>
654 + <BrowserOnly browserValue={browserValue} />
655 + </Suspense>
656 + ))}
657 + </>,
658 + {
659 + onError(error) {
660 + serverErrors.push(error);
661 + },
662 + onBrowserBailout(error) {
663 + browserBailouts.push(error);
664 + },
665 + },
666 + );
667 + pipe(writable);
668 + });
669 +
670 + expect(serverErrors).toEqual([]);
671 + expect(browserBailouts).toHaveLength(reasonValues.length);
672 + initializeReasons.forEach(initializeReason => {
673 + expect(initializeReason).toHaveBeenCalledTimes(1);
674 + });
675 + browserBailouts.forEach((error, index) => {
676 + expect(error).toBeInstanceOf(Error);
677 + expect(error.message).toBe(
678 + 'Browser-only rendering was requested by `browser()`.',
679 + );
680 + expect(Object.prototype.hasOwnProperty.call(error, 'cause')).toBe(true);
681 + expect(error.cause).toBe(reasonValues[index]);
682 + });
683 + });
684 +
685 + // @gate enableBrowserAPI
686 + it('initializes a shared browser reason at each use site', async () => {
687 + const browserReasons = [];
688 + const initializeReason = jest.fn(() => {
689 + const browserReason = {index: browserReasons.length};
690 + browserReasons.push(browserReason);
691 + return browserReason;
692 + });
693 + const browserValue = ReactDOM.browser(initializeReason);
694 +
695 + function BrowserOnlyA() {
696 + use(browserValue);
697 + return <span>Browser A</span>;
698 + }
699 +
700 + function BrowserOnlyB() {
701 + use(browserValue);
702 + return <span>Browser B</span>;
703 + }
704 +
705 + const browserBailouts = [];
706 + await act(() => {
707 + const {pipe} = renderToPipeableStream(
708 + <>
709 + <Suspense fallback={<span>Fallback A</span>}>
710 + <BrowserOnlyA />
711 + </Suspense>
712 + <Suspense fallback={<span>Fallback B</span>}>
713 + <BrowserOnlyB />
714 + </Suspense>
715 + </>,
716 + {
717 + onBrowserBailout(error) {
718 + browserBailouts.push(error);
719 + },
720 + },
721 + );
722 + pipe(writable);
723 + });
724 +
725 + expect(initializeReason).toHaveBeenCalledTimes(2);
726 + expect(browserBailouts).toHaveLength(2);
727 + expect(browserBailouts[0]).not.toBe(browserBailouts[1]);
728 + expect(browserBailouts[0].cause).toBe(browserReasons[0]);
729 + expect(browserBailouts[0].stack).toContain('BrowserOnlyA');
730 + expect(browserBailouts[1].cause).toBe(browserReasons[1]);
731 + expect(browserBailouts[1].stack).toContain('BrowserOnlyB');
732 + });
733 +
734 + // @gate enableBrowserAPI
735 + it('uses a fallback if a browser reason initializer throws', async () => {
736 + const reasonError = new Error('Failed to initialize browser reason');
737 + const initializeReason = jest.fn(() => {
738 + throw reasonError;
739 + });
740 + const browserValue = ReactDOM.browser(initializeReason);
741 +
742 + function BrowserOnly() {
743 + use(browserValue);
744 + return <span>Browser</span>;
745 + }
746 +
747 + const serverErrors = [];
748 + const browserBailouts = [];
749 + await act(() => {
750 + const {pipe} = renderToPipeableStream(
751 + <Suspense fallback={<span>Fallback</span>}>
752 + <BrowserOnly />
753 + </Suspense>,
754 + {
755 + onError(error) {
756 + serverErrors.push(error);
757 + },
758 + onBrowserBailout(error) {
759 + browserBailouts.push(error);
760 + },
761 + },
762 + );
763 + pipe(writable);
764 + });
765 +
766 + expect(initializeReason).toHaveBeenCalledTimes(1);
767 + expect(serverErrors).toEqual([]);
768 + expect(browserBailouts).toHaveLength(1);
769 + expect(browserBailouts[0].cause).toBe(
770 + 'The reason for browser-only rendering could not be determined because ' +
771 + 'its initializer threw.',
772 + );
773 + expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
774 + });
775 +
776 + // @gate enableBrowserAPI
777 + it('errors if browser-only content is rendered outside Suspense', async () => {
778 + const browserReason = 'Only render this content in a browser';
779 + const browserValue = ReactDOM.browser(browserReason);
780
781 function BrowserOnly() {
782 use(browserValue);
@@ -583,9 +810,11 @@ describe('ReactDOMFizzServer', () => {
810 "requested outside a Suspense boundary. See this error's cause for " +
811 'additional details.',
812 );
813 + expect(shellError.cause).toBe(browserReason);
814 expect(shellError.stack).toContain('BrowserOnly');
587 - expect(shellError.cause).toBe(browserValue);
588 - expect(shellError.cause.stack).toContain('createBrowserValue');
815 + expect(shellError.stack.split('\n')[0]).toBe(
816 + 'Error: ' + shellError.message,
817 + );
818 expect(shellReady).toBe(false);
819 expect(reportedErrors).toEqual([shellError]);
820 expect(browserBailouts).toEqual([]);
@@ -619,7 +848,9 @@ describe('ReactDOMFizzServer', () => {
848
849 const serverErrors = [];
850 const browserBailouts = [];
622 - const browserValue = ReactDOM.browser();
851 + const browserReason = {code: 'render-pending-content-in-browser'};
852 + const initializeReason = jest.fn(() => browserReason);
853 + const browserValue = ReactDOM.browser(initializeReason);
854 let abort;
855 await act(() => {
856 const controls = renderToPipeableStream(<App />, {
@@ -643,11 +874,22 @@ describe('ReactDOMFizzServer', () => {
874 );
875
876 await act(() => {
646 - abort(browserValue);
877 + function abortToBrowser() {
878 + abort(browserValue);
879 + }
880 + abortToBrowser();
881 });
882
883 expect(serverErrors).toEqual([]);
650 - expect(browserBailouts).toEqual([browserValue, browserValue]);
884 + expect(initializeReason).toHaveBeenCalledTimes(1);
885 + expect(browserBailouts).toHaveLength(2);
886 + expect(browserBailouts[0]).toBeInstanceOf(Error);
887 + expect(browserBailouts[0].message).toBe(
888 + 'Browser-only rendering was requested by `browser()`.',
889 + );
890 + expect(browserBailouts[0].stack).toContain('abortToBrowser');
891 + expect(browserBailouts[0].cause).toBe(browserReason);
892 + expect(browserBailouts[1]).toBe(browserBailouts[0]);
893
894 isClient = true;
895 const recoverableErrors = [];
@@ -671,7 +913,12 @@ describe('ReactDOMFizzServer', () => {
913 // @gate enableBrowserAPI
914 it('errors if aborted with browser() before the shell completes', async () => {
915 const never = new Promise(() => {});
674 - const browserValue = ReactDOM.browser();
916 + let browserReason;
917 + const initializeReason = jest.fn(() => {
918 + browserReason = new Error('Only abort this render on the server');
919 + return browserReason;
920 + });
921 + const browserValue = ReactDOM.browser(initializeReason);
922
923 function PendingRoot() {
924 use(never);
@@ -702,24 +949,145 @@ describe('ReactDOMFizzServer', () => {
949 });
950
951 await act(() => {
705 - abort(browserValue);
952 + function abortToBrowser() {
953 + abort(browserValue);
954 + }
955 + abortToBrowser();
956 });
957
958 expect(shellError).toBeInstanceOf(Error);
959 + expect(initializeReason).toHaveBeenCalledTimes(1);
960 expect(shellError.message).toBe(
961 'The server render could not complete because client rendering was ' +
962 "requested outside a Suspense boundary. See this error's cause for " +
963 'additional details.',
964 );
714 - expect(shellError.cause).toBe(browserValue);
965 + expect(shellError.cause).toBe(browserReason);
966 + expect(shellError.stack).toContain('abortToBrowser');
967 expect(shellReady).toBe(false);
968 expect(reportedErrors).toEqual([shellError]);
969 expect(browserBailouts).toEqual([]);
970 });
971
972 + // @gate enableBrowserAPI
973 + it('reports nested browser bailouts if aborting fatals the shell', async () => {
974 + const never = new Promise(() => {});
975 + const browserReason = 'Abort pending work into browser rendering';
976 + const browserValue = ReactDOM.browser(browserReason);
977 +
978 + function Pending() {
979 + use(never);
980 + return <span>Pending</span>;
981 + }
982 +
983 + const reportedErrors = [];
984 + const browserBailouts = [];
985 + let shellError;
986 + let abort;
987 + await act(() => {
988 + const controls = renderToPipeableStream(
989 + <>
990 + <Suspense fallback={<span>Fallback</span>}>
991 + <Pending />
992 + </Suspense>
993 + <Pending />
994 + <Suspense fallback={<span>Fallback</span>}>
995 + <Pending />
996 + </Suspense>
997 + <Pending />
998 + </>,
999 + {
1000 + onError(error) {
1001 + reportedErrors.push(error);
1002 + },
1003 + onBrowserBailout(error) {
1004 + browserBailouts.push(error);
1005 + },
1006 + onShellError(error) {
1007 + shellError = error;
1008 + },
1009 + },
1010 + );
1011 + abort = controls.abort;
1012 + });
1013 +
1014 + await act(() => {
1015 + abort(browserValue);
1016 + });
1017 +
1018 + expect(shellError).toBeInstanceOf(Error);
1019 + expect(shellError.message).toBe(
1020 + 'The server render could not complete because client rendering was ' +
1021 + "requested outside a Suspense boundary. See this error's cause for " +
1022 + 'additional details.',
1023 + );
1024 + expect(shellError.cause).toBe(browserReason);
1025 + expect(reportedErrors).toHaveLength(2);
1026 + expect(reportedErrors[0]).toBe(shellError);
1027 + expect(reportedErrors[1].message).toBe(shellError.message);
1028 + expect(reportedErrors[1].cause).toBe(browserReason);
1029 + expect(browserBailouts).toHaveLength(2);
1030 + expect(browserBailouts[0]).toBe(browserBailouts[1]);
1031 + expect(browserBailouts[0]).not.toBe(shellError);
1032 + expect(browserBailouts[0].message).toBe(
1033 + 'Browser-only rendering was requested by `browser()`.',
1034 + );
1035 + expect(browserBailouts[0].cause).toBe(browserReason);
1036 + });
1037 +
1038 + // @gate enableBrowserAPI
1039 + it('uses a fallback if a browser reason initializer throws during abort', async () => {
1040 + const never = new Promise(() => {});
1041 + const reasonError = new Error('Failed to initialize browser reason');
1042 + const initializeReason = jest.fn(() => {
1043 + throw reasonError;
1044 + });
1045 + const browserValue = ReactDOM.browser(initializeReason);
1046 +
1047 + function PendingRoot() {
1048 + use(never);
1049 + return <span>Root</span>;
1050 + }
1051 +
1052 + const reportedErrors = [];
1053 + const browserBailouts = [];
1054 + let shellError;
1055 + let abort;
1056 + await act(() => {
1057 + const controls = renderToPipeableStream(<PendingRoot />, {
1058 + onError(error) {
1059 + reportedErrors.push(error);
1060 + },
1061 + onBrowserBailout(error) {
1062 + browserBailouts.push(error);
1063 + },
1064 + onShellError(error) {
1065 + shellError = error;
1066 + },
1067 + });
1068 + abort = controls.abort;
1069 + });
1070 +
1071 + await act(() => {
1072 + abort(browserValue);
1073 + });
1074 +
1075 + expect(initializeReason).toHaveBeenCalledTimes(1);
1076 + expect(shellError).toBeInstanceOf(Error);
1077 + expect(shellError.cause).toBe(
1078 + 'The reason for browser-only rendering could not be determined because ' +
1079 + 'its initializer threw.',
1080 + );
1081 + expect(reportedErrors).toEqual([shellError]);
1082 + expect(browserBailouts).toEqual([]);
1083 + });
1084 +
1085 // @gate enableBrowserAPI
1086 it('reports the browser value if it is thrown instead of passed to use', async () => {
722 - const browserValue = ReactDOM.browser();
1087 + const initializeReason = jest.fn(
1088 + () => new Error('Only render this content in a browser'),
1089 + );
1090 + const browserValue = ReactDOM.browser(initializeReason);
1091
1092 function BrowserOnly() {
1093 throw browserValue;
@@ -746,6 +1114,7 @@ describe('ReactDOMFizzServer', () => {
1114
1115 expect(reportedErrors).toEqual([browserValue]);
1116 expect(browserBailouts).toEqual([]);
1117 + expect(initializeReason).not.toHaveBeenCalled();
1118 expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
1119 });
1120
@@ -7605,6 +7974,60 @@ describe('ReactDOMFizzServer', () => {
7974 expect(errors).toEqual(['abort reason', 'abort reason']);
7975 });
7976
7977 + // @gate enableBrowserAPI
7978 + it('reports an in-flight browser bailout after another root task fatals while aborting', async () => {
7979 + const promise = new Promise(() => {});
7980 + function SuspendedRoot() {
7981 + use(promise);
7982 + return null;
7983 + }
7984 +
7985 + function Child() {
7986 + return 'child';
7987 + }
7988 +
7989 + const browserValue = ReactDOM.browser('abort reason');
7990 + const abortRef = {current: null};
7991 + function ComponentThatAborts() {
7992 + abortRef.current(browserValue);
7993 + return <Child />;
7994 + }
7995 +
7996 + const errors = [];
7997 + const browserBailouts = [];
7998 + let shellError;
7999 + await act(() => {
8000 + const {abort} = renderToPipeableStream(
8001 + <>
8002 + <SuspendedRoot />
8003 + <Suspense fallback="loading...">
8004 + <ComponentThatAborts />
8005 + </Suspense>
8006 + </>,
8007 + {
8008 + onError(error) {
8009 + errors.push(error);
8010 + },
8011 + onBrowserBailout(error) {
8012 + browserBailouts.push(error);
8013 + },
8014 + onShellError(error) {
8015 + shellError = error;
8016 + },
8017 + },
8018 + );
8019 + abortRef.current = abort;
8020 + });
8021 +
8022 + expect(errors).toEqual([shellError]);
8023 + expect(browserBailouts).toHaveLength(1);
8024 + expect(browserBailouts[0]).not.toBe(shellError);
8025 + expect(browserBailouts[0].message).toBe(
8026 + 'Browser-only rendering was requested by `browser()`.',
8027 + );
8028 + expect(browserBailouts[0].cause).toBe('abort reason');
8029 + });
8030 +
8031 it('reports a root task before rendering a suspended child returned after aborting', async () => {
8032 const promise = new Promise(() => {});
8033 function SuspendedRoot() {
packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js
+47
@@ -10,6 +10,7 @@
10 'use strict';
11
12 let React;
13 +let ReactDOM;
14 let ReactDOMClient;
15 let ReactDOMServer;
16 let act;
@@ -21,6 +22,7 @@ describe('ReactDOMServerSuspense', () => {
22 jest.resetModules();
23
24 React = require('react');
25 + ReactDOM = require('react-dom');
26 ReactDOMClient = require('react-dom/client');
27 ReactDOMServer = require('react-dom/server');
28 act = require('internal-test-utils').act;
@@ -98,6 +100,51 @@ describe('ReactDOMServerSuspense', () => {
100 expect(getVisibleChildren(container)).toEqual(<div>Fallback</div>);
101 });
102
103 + // @gate enableBrowserAPI
104 + it('hydrates browser-only content rendered with renderToString', async () => {
105 + function BrowserOnly() {
106 + React.use(ReactDOM.browser('Only render this content in the browser'));
107 + return <Text text="Children" />;
108 + }
109 +
110 + const app = (
111 + <React.Suspense fallback={<Text text="Fallback" />}>
112 + <BrowserOnly />
113 + </React.Suspense>
114 + );
115 + const container = document.createElement('div');
116 + container.innerHTML = ReactDOMServer.renderToString(app);
117 + expect(getVisibleChildren(container)).toEqual(<div>Fallback</div>);
118 +
119 + const recoverableErrors = [];
120 + await act(() => {
121 + ReactDOMClient.hydrateRoot(container, app, {
122 + onRecoverableError(error) {
123 + recoverableErrors.push(error);
124 + },
125 + });
126 + });
127 +
128 + expect(recoverableErrors).toEqual([]);
129 + expect(getVisibleChildren(container)).toEqual(<div>Children</div>);
130 + });
131 +
132 + // @gate enableBrowserAPI
133 + it('renders only the browser-only fallback with renderToStaticMarkup', () => {
134 + function BrowserOnly() {
135 + React.use(ReactDOM.browser('Only render this content in the browser'));
136 + return <Text text="Children" />;
137 + }
138 +
139 + const html = ReactDOMServer.renderToStaticMarkup(
140 + <React.Suspense fallback={<Text text="Fallback" />}>
141 + <BrowserOnly />
142 + </React.Suspense>,
143 + );
144 +
145 + expect(html).toBe('<div>Fallback</div>');
146 + });
147 +
148 it('should work with nested suspense components', async () => {
149 const container = document.createElement('div');
150 const html = ReactDOMServer.renderToString(
packages/react-dom/src/shared/ReactDOMBrowser.js
+13 -14
@@ -7,23 +7,22 @@
7 * @flow
8 */
9
10 -import type {ReactRecoverable} from 'shared/ReactTypes';
10 +import type {ReactRecoverable, ReactRecoverableReason} from 'shared/ReactTypes';
11
12 import {enableBrowserAPI} from 'shared/ReactFeatureFlags';
13 import {REACT_RECOVERABLE_TYPE} from 'shared/ReactSymbols';
14
15 -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 - 'Browser-only rendering was requested by `browser()`.',
20 - );
21 - Object.defineProperty(recoverable as any, '$$typeof', {
22 - value: REACT_RECOVERABLE_TYPE,
23 - });
24 - return recoverable as any;
15 +const browserImpl = function browser(
16 + reason?: ReactRecoverableReason,
17 +): ReactRecoverable {
18 + // This also runs in the browser, where the reason is never observed. Keep the
19 + // value cheap and let an SSR renderer initialize the error if it defers work.
20 + return {
21 + $$typeof: REACT_RECOVERABLE_TYPE,
22 + _reason: reason,
23 + };
24 };
25
27 -export const browser: (() => ReactRecoverable) | void = enableBrowserAPI
28 - ? browserImpl
29 - : undefined;
26 +export const browser:
27 + | ((reason?: ReactRecoverableReason) => ReactRecoverable)
28 + | void = enableBrowserAPI ? browserImpl : undefined;
packages/react-server/src/ReactFizzHooks.js
+63 -40
@@ -40,6 +40,7 @@ import {
40 import {createFastHash} from './ReactServerStreamConfig';
41
42 import is from 'shared/objectIs';
43 +import hasOwnProperty from 'shared/hasOwnProperty';
44 import {
45 REACT_CONTEXT_TYPE,
46 REACT_RECOVERABLE_TYPE,
@@ -91,31 +92,68 @@ let actionStateMatchingIndex: number = -1;
92 // Counts the number of use(thenable) calls in this component
93 let thenableIndexCounter: number = 0;
94 let thenableState: ThenableState | null = null;
94 -// An opaque exception that lets the Fizz work loop distinguish a recoverable
95 -// from an Error thrown by application code. The actual errors are stored
96 -// separately so this implementation detail cannot be mistaken for either
97 -// diagnostic if it is caught by userspace.
98 -export const RecoverableException: mixed = new Error(
99 - "Recoverable Exception: This is not a real error! It's an implementation " +
100 - 'detail of `use` to interrupt the current render so a downstream ' +
101 - 'renderer can recover it. You must either rethrow it immediately, or move ' +
102 - 'the `use` call outside of the `try/catch` block. Capturing without ' +
103 - 'rethrowing will lead to unexpected behavior.',
104 -);
105 -let suspendedRecoverableError: Error | null = null;
106 -
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(
95 +
96 +const browserReasonInitializationFallback =
97 + 'The reason for browser-only rendering could not be determined because its ' +
98 + 'initializer threw.';
99 +
100 +export function createRecoverableError(recoverable: ReactRecoverable): Error {
101 + const reason = recoverable._reason;
102 + let initializedReason;
103 + if (typeof reason === 'function') {
104 + try {
105 + initializedReason = reason();
106 + } catch {
107 + // A reason is only diagnostic metadata. Its initializer must not affect
108 + // whether the renderer can defer this subtree to the browser.
109 + initializedReason = browserReasonInitializationFallback;
110 + }
111 + } else {
112 + initializedReason = reason;
113 + }
114 + // Always create the recoverable at the consumption point so its stack
115 + // identifies the relevant use() or abort() call. A lazy reason is diagnostic
116 + // metadata and can be any value supported by Error.cause.
117 + const error = new Error(
118 + 'Browser-only rendering was requested by `browser()`.',
119 + reason === undefined ? undefined : {cause: initializedReason},
120 + );
121 + Object.defineProperty(error, REACT_RECOVERABLE_TYPE, {value: true});
122 + return error;
123 +}
124 +
125 +export function isRecoverableError(error: mixed): boolean {
126 + if (typeof error !== 'object' || error === null) {
127 + return false;
128 + }
129 + return (error as any)[REACT_RECOVERABLE_TYPE] === true;
130 +}
131 +
132 +export function cloneRecoverableErrorAsFatal(recoverableError: Error): Error {
133 + // Create a separate diagnostic for fatal reporting without changing the
134 + // branded recoverable error that other tasks may still need to observe.
135 + const fatalRecoverableError = new Error(
136 'The server render could not complete because client rendering was ' +
137 "requested outside a Suspense boundary. See this error's cause for " +
138 'additional details.',
117 - {cause: recoverable},
139 + hasOwnProperty.call(recoverableError, 'cause')
140 + ? {cause: (recoverableError as any).cause}
141 + : undefined,
142 );
143 + // Keep the frames captured where the recoverable was consumed, but replace
144 + // the first line with the fatal error's message.
145 + const stack = recoverableError.stack;
146 + if (stack !== undefined) {
147 + const frameStart = stack.indexOf('\n');
148 + fatalRecoverableError.stack =
149 + fatalRecoverableError.name +
150 + ': ' +
151 + fatalRecoverableError.message +
152 + (frameStart === -1 ? '' : stack.slice(frameStart));
153 + } else {
154 + (fatalRecoverableError as any).stack = undefined;
155 + }
156 + return fatalRecoverableError;
157 }
158
159 // Lazily created map of render-phase updates
@@ -305,18 +343,6 @@ export function getThenableStateAfterSuspending(): null | ThenableState {
343 return state;
344 }
345
308 -export function getSuspendedRecoverableError(): Error {
309 - if (suspendedRecoverableError === null) {
310 - throw new Error(
311 - 'Expected a suspended recoverable. This is a bug in React. Please file ' +
312 - 'an issue.',
313 - );
314 - }
315 - const error = suspendedRecoverableError;
316 - suspendedRecoverableError = null;
317 - return error;
318 -}
319 -
346 export function checkDidRenderIdHook(): boolean {
347 // This should be called immediately after every finishHooks call.
348 // Conceptually, it's part of the return value of finishHooks; it's only a
@@ -798,14 +824,11 @@ function use<T>(usable: Usable<T>): T {
824 const thenable: Thenable<T> = usable as any;
825 return unwrapThenable(thenable);
826 } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
801 - // Fizz can defer this subtree to a downstream renderer. Like a suspended
802 - // thenable, keep the actual value out of userspace and throw an opaque
803 - // sentinel to unwind the stack. Capture the use() call site eagerly so
804 - // that if there is no Suspense boundary, the fatal error points here and
805 - // its cause points to where the recoverable was created.
827 + // Create the recoverable error here so its stack captures the component
828 + // that passed this value to use(). The internal brand lets the renderer
829 + // distinguish it from an Error thrown by application code.
830 const recoverable: ReactRecoverable = usable as any;
807 - suspendedRecoverableError = createFatalRecoverableError(recoverable);
808 - throw RecoverableException;
831 + throw createRecoverableError(recoverable);
832 } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
833 const context: ReactContext<T> = usable as any;
834 return readContext(context);
packages/react-server/src/ReactFizzServer.js
+57 -68
@@ -28,7 +28,6 @@ import type {
28 SuspenseListProps,
29 SuspenseListRevealOrder,
30 ReactKey,
31 - ReactRecoverable,
31 } from 'shared/ReactTypes';
32 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
33 import type {
@@ -135,9 +134,9 @@ import {
134 readPreviousThenableFromState,
135 getActionStateCount,
136 getActionStateMatchingIndex,
138 - RecoverableException,
139 - createFatalRecoverableError,
140 - getSuspendedRecoverableError,
137 + createRecoverableError,
138 + isRecoverableError,
139 + cloneRecoverableErrorAsFatal,
140 } from './ReactFizzHooks';
141 import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher';
142 import {
@@ -1337,7 +1336,7 @@ function encodeErrorForBoundary(
1336 ) {
1337 boundary.errorDigest = digest;
1338 if (__DEV__) {
1340 - if (error === RecoverableException) {
1339 + if (isRecoverableError(error)) {
1340 boundary.errorMessage = wasAborted
1341 ? 'Switched to client rendering because the server render was aborted ' +
1342 'with a request to render on the client.'
@@ -1375,17 +1374,8 @@ function logRecoverableError(
1374 errorInfo: ThrownInfo,
1375 debugTask: null | ConsoleTask,
1376 ): ?string {
1378 - if (error === RecoverableException) {
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 - );
1377 + if (isRecoverableError(error)) {
1378 + logBrowserBailout(request, error, errorInfo, debugTask);
1379 return REACT_RECOVERABLE_DIGEST;
1380 }
1381
@@ -1460,7 +1450,11 @@ function fatalError(
1450 closeWithError(request.destination, error);
1451 } else {
1452 request.status = CLOSING;
1463 - request.fatalError = error;
1453 + // abort() already stored the reason that every remaining task must
1454 + // observe. This error may only be a fatal diagnostic derived from it.
1455 + if (!request.aborted) {
1456 + request.fatalError = error;
1457 + }
1458 }
1459 }
1460
@@ -4593,10 +4587,12 @@ function erroredTask(
4587 // shell and defer its content to a downstream renderer. At the root there
4588 // is no shell to stream, so this is a fatal error and must be reported like
4589 // any other root error.
4596 - if (error === RecoverableException) {
4597 - const useError = getSuspendedRecoverableError();
4598 - logRecoverableError(request, useError, errorInfo, debugTask);
4599 - fatalError(request, useError, errorInfo, debugTask);
4590 + if (isRecoverableError(error)) {
4591 + // This recoverable reached the root without a Suspense boundary, so
4592 + // report it using the fatal diagnostic while leaving the original intact.
4593 + const fatalRecoverableError = cloneRecoverableErrorAsFatal(error as any);
4594 + logRecoverableError(request, fatalRecoverableError, errorInfo, debugTask);
4595 + fatalError(request, fatalRecoverableError, errorInfo, debugTask);
4596 } else {
4597 logRecoverableError(request, error, errorInfo, debugTask);
4598 fatalError(request, error, errorInfo, debugTask);
@@ -4845,14 +4841,9 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4841 }
4842
4843 const errorInfo = getThrownInfo(task.componentStack);
4848 - // Only abort reasons get this interpretation. Throwing a recoverable
4849 - // directly is still an application error; it must be passed to use() or
4850 - // abort() for a renderer to recover it.
4851 - const isRecoverableAbort =
4852 - typeof error === 'object' &&
4853 - error !== null &&
4854 - // $FlowFixMe[prop-missing]
4855 - error.$$typeof === REACT_RECOVERABLE_TYPE;
4844 + // Only errors materialized by use() or abort() carry this internal brand.
4845 + // Throwing the browser() token directly is still an application error.
4846 + const isRecoverableReason = isRecoverableError(error);
4847
4848 if (boundary === null) {
4849 const replay: null | ReplaySet = task.replay;
@@ -4860,7 +4851,7 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4851 // We didn't complete the root so we have nothing to show. We can close
4852 // the request;
4853 if (
4863 - !isRecoverableAbort &&
4854 + !isRecoverableReason &&
4855 request.trackedPostpones !== null &&
4856 segment !== null
4857 ) {
@@ -4870,9 +4861,12 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4861 logRecoverableError(request, error, errorInfo, task.debugTask);
4862 trackPostpone(request, trackedPostpones, task, segment);
4863 finishedTask(request, null, task.row, segment);
4873 - } else if (isRecoverableAbort) {
4874 - const recoverable: ReactRecoverable = error as any;
4875 - const fatalRecoverableError = createFatalRecoverableError(recoverable);
4864 + } else if (isRecoverableReason) {
4865 + // This root task cannot recover from the abort. Report a fatal clone,
4866 + // but keep the original branded reason on the request for other tasks.
4867 + const fatalRecoverableError = cloneRecoverableErrorAsFatal(
4868 + error as any,
4869 + );
4870 logRecoverableError(
4871 request,
4872 fatalRecoverableError,
@@ -4896,22 +4890,18 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4890 // the ReplaySet.
4891 replay.pendingTasks--;
4892 if (replay.pendingTasks === 0 && replay.nodes.length > 0) {
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 {
4906 - errorDigest = logRecoverableError(request, error, errorInfo, null);
4907 - errorForBoundary = error;
4908 - }
4893 + const errorDigest = logRecoverableError(
4894 + request,
4895 + error,
4896 + errorInfo,
4897 + null,
4898 + );
4899 abortRemainingReplayNodes(
4900 request,
4901 null,
4902 replay.nodes,
4903 replay.slots,
4914 - errorForBoundary,
4904 + error,
4905 errorDigest,
4906 errorInfo,
4907 true,
@@ -4928,7 +4918,7 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4918 const trackedPostpones = request.trackedPostpones;
4919 if (boundary.status !== CLIENT_RENDERED) {
4920 if (
4931 - !isRecoverableAbort &&
4921 + !isRecoverableReason &&
4922 trackedPostpones !== null &&
4923 segment !== null
4924 ) {
@@ -4947,28 +4937,13 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4937 boundary.status = CLIENT_RENDERED;
4938 // We are aborting a render or resume which should put boundaries
4939 // into an explicitly client rendered state
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,
4968 - errorForBoundary,
4940 + const errorDigest = logRecoverableError(
4941 + request,
4942 + error,
4943 errorInfo,
4970 - true,
4944 + task.debugTask,
4945 );
4946 + encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);
4947
4948 untrackBoundary(request, boundary);
4949
@@ -6475,7 +6450,13 @@ export function prepareForStartFlowingIfBeforeAllReady(request: Request) {
6450 export function startFlowing(request: Request, destination: Destination): void {
6451 if (request.status === CLOSING) {
6452 request.status = CLOSED;
6478 - closeWithError(destination, request.fatalError);
6453 + let error = request.fatalError;
6454 + if (isRecoverableError(error)) {
6455 + // An aborted request keeps its original branded reason while tasks
6456 + // unwind. Convert it only now that the stream must receive a fatal.
6457 + error = cloneRecoverableErrorAsFatal(error as any);
6458 + }
6459 + closeWithError(destination, error);
6460 return;
6461 }
6462 if (request.status === CLOSED) {
@@ -6533,9 +6514,17 @@ export function abort(request: Request, reason: mixed): void {
6514 // can be aborted. in practice this makes abort callable at most once per render.
6515 return;
6516 }
6517 + const isRecoverableReason =
6518 + typeof reason === 'object' &&
6519 + reason !== null &&
6520 + // $FlowFixMe[prop-missing]
6521 + reason.$$typeof === REACT_RECOVERABLE_TYPE;
6522 + // Mark the request before initializing a recoverable reason so an initializer
6523 + // cannot reenter abort().
6524 request.aborted = true;
6537 - const error =
6538 - reason === undefined
6525 + const error = isRecoverableReason
6526 + ? createRecoverableError(reason as any)
6527 + : reason === undefined
6528 ? new Error('The render was aborted by the server without a reason.')
6529 : typeof reason === 'object' &&
6530 reason !== null &&
packages/shared/ReactTypes.js
+5 -2
@@ -150,12 +150,15 @@ export type Thenable<T> =
150 | FulfilledThenable<T>
151 | RejectedThenable<T>;
152
153 +export type ReactRecoverableReason = string | (() => mixed);
154 +
155 // A recoverable lets an intermediate renderer defer a subtree to a downstream
156 // renderer. It does not produce a value: a renderer either continues through
157 // it or interrupts the current render so that a later renderer can recover the
156 -// subtree.
157 -export type ReactRecoverable = Error & {
158 +// subtree. The reason is initialized only by a renderer that defers the work.
159 +export type ReactRecoverable = {
160 $$typeof: symbol,
161 + _reason: ReactRecoverableReason | void,
162 };
163
164 export type StartTransitionOptions = {