@samitouri / QOS-React / commits / 15f7cd693e

Add ReactDOM `browser()` API (#37143)

## Summary Adds a new API to `react-dom` called `browser()`. `browser()` returns a "usable" that will error during SSR and resolve during rendering in the browser. The purpose is to allow you to express the idea that a component should suspend on the server but not in the browser. The method is not available inside a `react-server` environment. This is a client only feature. This is a `react-dom` API because the concept of browser doesn't apply generally to React itself. This codifies a pattern that is common in some apps where you error during SSR to prevent rendering some component on the server and you end up suppressing the error that is reported in the client to avoid this appearing like a problem rather than intended behavior. Unfortunately this is not an option for many because hacking around to prevent errors from being logged is not practical for many By making this a React API we enabled this common pattern in any React using library or application ```tsx import {use, Suspense} from 'react'; import {browser} from 'react-dom'; function BrowserOnly() { use(browser()); return <ClientContent />; } function App() { return ( <Suspense fallback={<Fallback />}> <BrowserOnly /> </Suspense> ); } ``` It is an error to `use(browser())` outside of a Suspense boundary because you cannot recover from the root. this restriction may be lifted in the future but is part of the current limitations of the API ## Implementation Deferring rendering to a downstream system is modeled in React already as recoverable errors. The idea is that in some environments you might not want to report something directly as an error because a later environment has an opportunity to recover from it without alerting the user to the mishap. This concept also shows up in RSC with halted references. They can "recover" in a later render by eventually resolving to some value. To model the idea of "render in the browser" we are really just modeling an intentional recoverable error. However since you don't want to treat this kind of error as exceptional we intentionally suppress logging. Additionally since aborting a server render is semantically equivalent to "erroring" in every unfinished task we also support aborting with a `browser()` so you can describe ending a stream with intentional holes that won't be logged as errors in the browser when hydrating. One interesting thing we do with this particular API is it returns an object that is isomorphic and it's the `use` or `abort` function that handles differing behaviors. This means you can create these objects in module scope and use them even in complex scenarios like server rendering inside the browser while React is rendering. This implementation is flagged so we can disable the feature quickly if we decide to not ship this in a stable. It is going into React unprefixed for now because the semantics are clear and the utility is widely known. ## Alternatives We considered `useBrowser()` or a similar hook however this means you must call it unconditionally. There are use cases where props might influence whether you want to allow something to render during SSR or not. for instance you might have a data fetching library that accepts initial data on the server but if it doesn't receive initial data it falls back to browser only rendering. Another consideration is a throwing function like just calling `browser()` would throw if called during an SSR render. The main reason we do not think this is a good idea is because you can then call this arbitrarily deep and the throw can be caught and might be suppressed accidentally. By making it a usable it can only be done in hooks or hook-like contexts.

Josh Story committed Jul 30, 2026 at 16:15 UTC 15f7cd693e102c568df501eeeb9684f507e0ec0b
28 files changed +701 -53
packages/react-debug-tools/src/ReactDebugHooks.js
+16
@@ -35,6 +35,7 @@ import {
35 import {
36 REACT_MEMO_CACHE_SENTINEL,
37 REACT_CONTEXT_TYPE,
38 + REACT_RECOVERABLE_TYPE,
39 } from 'shared/ReactSymbols';
40 import hasOwnProperty from 'shared/hasOwnProperty';
41
@@ -110,6 +111,11 @@ 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 + });
118 + Dispatcher.use(recoverable as any);
119 Dispatcher.use({
120 then() {},
121 status: 'fulfilled',
@@ -240,6 +246,16 @@ function use<T>(usable: Usable<T>): T {
246 dispatcherHookName: 'Use',
247 });
248 throw SuspenseException;
249 + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
250 + hookLog.push({
251 + displayName: null,
252 + primitive: 'Recoverable',
253 + stackError: new Error(),
254 + value: undefined,
255 + debugInfo: null,
256 + dispatcherHookName: 'Use',
257 + });
258 + return undefined as any;
259 } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
260 const context: ReactContext<T> = usable as any;
261 const value = readContext(context);
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+19 -12
@@ -4707,7 +4707,7 @@ export function writeStartClientRenderedSuspenseBoundary(
4707 startClientRenderedSuspenseBoundary,
4708 );
4709 writeChunk(destination, clientRenderedSuspenseBoundaryError1);
4710 - if (errorDigest) {
4710 + if (errorDigest != null) {
4711 writeChunk(destination, clientRenderedSuspenseBoundaryError1A);
4712 writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest)));
4713 writeChunk(
@@ -5131,6 +5131,7 @@ const clientRenderScript1Full = stringToPrecomputedChunk(
5131 const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
5132 const clientRenderScript1A = stringToPrecomputedChunk('"');
5133 const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
5134 +const clientRenderErrorScriptNull = stringToPrecomputedChunk('null');
5135 const clientRenderScriptEnd = stringToPrecomputedChunk(')</script>');
5136
5137 const clientRenderData1 = stringToPrecomputedChunk(
@@ -5182,21 +5183,27 @@ export function writeClientRenderBoundaryInstruction(
5183 writeChunk(destination, clientRenderScript1A);
5184 }
5185
5185 - if (errorDigest || errorMessage || errorStack || errorComponentStack) {
5186 + if (
5187 + errorDigest != null ||
5188 + errorMessage ||
5189 + errorStack ||
5190 + errorComponentStack
5191 + ) {
5192 if (scriptFormat) {
5187 - // ,"JSONString"
5193 + // ,null or ,"JSONString"
5194 writeChunk(destination, clientRenderErrorScriptArgInterstitial);
5189 - writeChunk(
5190 - destination,
5191 - stringToChunk(escapeJSStringsForInstructionScripts(errorDigest || '')),
5192 - );
5193 - } else {
5195 + if (errorDigest == null) {
5196 + writeChunk(destination, clientRenderErrorScriptNull);
5197 + } else {
5198 + writeChunk(
5199 + destination,
5200 + stringToChunk(escapeJSStringsForInstructionScripts(errorDigest)),
5201 + );
5202 + }
5203 + } else if (errorDigest != null) {
5204 // " data-dgst="HTMLString
5205 writeChunk(destination, clientRenderData2);
5196 - writeChunk(
5197 - destination,
5198 - stringToChunk(escapeTextForBrowser(errorDigest || '')),
5199 - );
5206 + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest)));
5207 }
5208 }
5209 if (errorMessage || errorStack || errorComponentStack) {
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js
+1 -1
@@ -4,7 +4,7 @@
4 export const markShellTime =
5 'requestAnimationFrame(function(){$RT=performance.now()});';
6 export const clientRenderBoundary =
7 - '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};';
7 + '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,null!=c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};';
8 export const completeBoundary =
9 '$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0};\n$RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};';
10 export const completeBoundaryUpgradeToViewTransitions =
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+1 -1
@@ -395,7 +395,7 @@ export function clientRenderBoundary(
395 suspenseNode.data = SUSPENSE_FALLBACK_START_DATA;
396 // assign error metadata to first sibling
397 const dataset = suspenseIdNode.dataset;
398 - if (errorDigest) dataset['dgst'] = errorDigest;
398 + if (errorDigest != null) dataset['dgst'] = errorDigest;
399 if (errorMsg) dataset['msg'] = errorMsg;
400 if (errorStack) dataset['stck'] = errorStack;
401 if (errorComponentStack) dataset['cstck'] = errorComponentStack;
packages/react-dom/index.js
+1
@@ -9,6 +9,7 @@
9
10 export {default as __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE} from './src/ReactDOMSharedInternals';
11 export {
12 + browser,
13 createPortal,
14 flushSync,
15 prefetchDNS,
packages/react-dom/src/ReactDOMFB.js
+1
@@ -21,6 +21,7 @@ Object.assign(Internals as any, {
21 export {Internals as __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE};
22
23 export {
24 + browser,
25 createPortal,
26 flushSync,
27 unstable_createEventHandle,
packages/react-dom/src/ReactDOMFB.modern.js
+1
@@ -10,6 +10,7 @@
10 export {default as __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE} from './ReactDOMSharedInternalsFB';
11
12 export {
13 + browser,
14 createPortal,
15 flushSync,
16 unstable_batchedUpdates,
packages/react-dom/src/__tests__/ReactDOMBrowser-test.js new
+41
@@ -0,0 +1,41 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +describe('ReactDOM.browser', () => {
13 + beforeEach(() => {
14 + jest.resetModules();
15 + });
16 +
17 + // @gate enableBrowserAPI
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();
22 + const ReactDOMClient = require('react-dom/client');
23 + const {act} = require('internal-test-utils');
24 +
25 + function BrowserOnly() {
26 + React.use(browserOnly);
27 + return <span>Browser</span>;
28 + }
29 +
30 + const container = document.createElement('div');
31 + const root = ReactDOMClient.createRoot(container);
32 + await act(() => {
33 + root.render(
34 + <React.Suspense fallback={<span>Fallback</span>}>
35 + <BrowserOnly />
36 + </React.Suspense>,
37 + );
38 + });
39 + expect(container.innerHTML).toBe('<span>Browser</span>');
40 + });
41 +});
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+367
@@ -406,6 +406,373 @@ describe('ReactDOMFizzServer', () => {
406 );
407 }
408
409 + // @gate enableBrowserAPI
410 + it('can opt a component into browser-only rendering', async () => {
411 + let resolveBrowserText;
412 + const browserText = new Promise(resolve => {
413 + resolveBrowserText = resolve;
414 + });
415 + const browserOnly = ReactDOM.browser();
416 +
417 + function BrowserOnly() {
418 + use(browserOnly);
419 + const text = use(browserText);
420 + Scheduler.log(text);
421 + return <span>{text}</span>;
422 + }
423 +
424 + function App() {
425 + return (
426 + <div>
427 + <Suspense fallback={<span>Fallback</span>}>
428 + <BrowserOnly />
429 + </Suspense>
430 + </div>
431 + );
432 + }
433 +
434 + const serverErrors = [];
435 + await act(() => {
436 + const {pipe} = renderToPipeableStream(<App />, {
437 + onError(error) {
438 + serverErrors.push(error);
439 + },
440 + });
441 + pipe(writable);
442 + });
443 +
444 + expect(serverErrors).toEqual([]);
445 + expect(getVisibleChildren(container)).toEqual(
446 + <div>
447 + <span>Fallback</span>
448 + </div>,
449 + );
450 + const recoverableErrors = [];
451 + ReactDOMClient.hydrateRoot(container, <App />, {
452 + onRecoverableError(error) {
453 + recoverableErrors.push(error);
454 + },
455 + });
456 + await waitForAll([]);
457 +
458 + expect(getVisibleChildren(container)).toEqual(
459 + <div>
460 + <span>Fallback</span>
461 + </div>,
462 + );
463 +
464 + await clientAct(() => {
465 + resolveBrowserText('Browser');
466 + });
467 + assertLog(['Browser']);
468 +
469 + expect(recoverableErrors).toEqual([]);
470 + expect(getVisibleChildren(container)).toEqual(
471 + <div>
472 + <span>Browser</span>
473 + </div>,
474 + );
475 + });
476 +
477 + // @gate enableBrowserAPI
478 + it('can opt a component into browser-only rendering after streaming the fallback', async () => {
479 + let resolveServerReady;
480 + const serverReady = new Promise(resolve => {
481 + resolveServerReady = resolve;
482 + });
483 +
484 + function BrowserOnly() {
485 + use(serverReady);
486 + use(ReactDOM.browser());
487 + return <span>Browser</span>;
488 + }
489 +
490 + function App() {
491 + return (
492 + <div>
493 + <Suspense fallback={<span>Fallback</span>}>
494 + <BrowserOnly />
495 + </Suspense>
496 + </div>
497 + );
498 + }
499 +
500 + const serverErrors = [];
501 + await act(() => {
502 + const {pipe} = renderToPipeableStream(<App />, {
503 + onError(error) {
504 + serverErrors.push(error);
505 + },
506 + });
507 + pipe(writable);
508 + });
509 +
510 + expect(getVisibleChildren(container)).toEqual(
511 + <div>
512 + <span>Fallback</span>
513 + </div>,
514 + );
515 +
516 + await act(() => {
517 + resolveServerReady();
518 + });
519 +
520 + expect(serverErrors).toEqual([]);
521 +
522 + const recoverableErrors = [];
523 + ReactDOMClient.hydrateRoot(container, <App />, {
524 + onRecoverableError(error) {
525 + recoverableErrors.push(error);
526 + },
527 + });
528 + await waitForAll([]);
529 +
530 + expect(recoverableErrors).toEqual([]);
531 + expect(getVisibleChildren(container)).toEqual(
532 + <div>
533 + <span>Browser</span>
534 + </div>,
535 + );
536 + });
537 +
538 + // @gate enableBrowserAPI
539 + it('errors if browser-only content is rendered outside Suspense', async () => {
540 + function createBrowserValue() {
541 + return ReactDOM.browser();
542 + }
543 + const browserValue = createBrowserValue();
544 +
545 + function BrowserOnly() {
546 + use(browserValue);
547 + return <span>Browser</span>;
548 + }
549 +
550 + const reportedErrors = [];
551 + let shellReady = false;
552 + let shellError;
553 + await act(() => {
554 + renderToPipeableStream(<BrowserOnly />, {
555 + onError(error) {
556 + reportedErrors.push(error);
557 + },
558 + onShellReady() {
559 + shellReady = true;
560 + },
561 + onShellError(error) {
562 + shellError = error;
563 + },
564 + });
565 + });
566 +
567 + expect(shellError).toBeInstanceOf(Error);
568 + expect(shellError.message).toBe(
569 + 'The server render could not complete because client rendering was ' +
570 + "requested outside a Suspense boundary. See this error's cause for " +
571 + 'additional details.',
572 + );
573 + expect(shellError.stack).toContain('BrowserOnly');
574 + expect(shellError.cause).toBe(browserValue);
575 + expect(shellError.cause.stack).toContain('createBrowserValue');
576 + expect(shellError.cause.message).toContain(
577 + '`use(browser())` can only be used inside a `<Suspense>` boundary',
578 + );
579 + expect(shellReady).toBe(false);
580 + expect(reportedErrors).toEqual([shellError]);
581 + });
582 +
583 + // @gate enableBrowserAPI
584 + it('can abort all pending boundaries into browser-only rendering', async () => {
585 + const never = new Promise(() => {});
586 + let isClient = false;
587 +
588 + function Pending({children}) {
589 + if (!isClient) {
590 + use(never);
591 + }
592 + return <span>{children}</span>;
593 + }
594 +
595 + function App() {
596 + return (
597 + <div>
598 + <span>Shell</span>
599 + <Suspense fallback={<span>Loading A</span>}>
600 + <Pending>A</Pending>
601 + </Suspense>
602 + <Suspense fallback={<span>Loading B</span>}>
603 + <Pending>B</Pending>
604 + </Suspense>
605 + </div>
606 + );
607 + }
608 +
609 + const serverErrors = [];
610 + let abort;
611 + await act(() => {
612 + const controls = renderToPipeableStream(<App />, {
613 + onError(error) {
614 + serverErrors.push(error);
615 + },
616 + });
617 + abort = controls.abort;
618 + controls.pipe(writable);
619 + });
620 +
621 + expect(getVisibleChildren(container)).toEqual(
622 + <div>
623 + <span>Shell</span>
624 + <span>Loading A</span>
625 + <span>Loading B</span>
626 + </div>,
627 + );
628 +
629 + await act(() => {
630 + abort(ReactDOM.browser());
631 + });
632 +
633 + expect(serverErrors).toEqual([]);
634 +
635 + isClient = true;
636 + const recoverableErrors = [];
637 + ReactDOMClient.hydrateRoot(container, <App />, {
638 + onRecoverableError(error) {
639 + recoverableErrors.push(error);
640 + },
641 + });
642 + await waitForAll([]);
643 +
644 + expect(recoverableErrors).toEqual([]);
645 + expect(getVisibleChildren(container)).toEqual(
646 + <div>
647 + <span>Shell</span>
648 + <span>A</span>
649 + <span>B</span>
650 + </div>,
651 + );
652 + });
653 +
654 + // @gate enableBrowserAPI
655 + it('errors if aborted with browser() before the shell completes', async () => {
656 + const never = new Promise(() => {});
657 + const browserValue = ReactDOM.browser();
658 +
659 + function PendingRoot() {
660 + use(never);
661 + return <span>Root</span>;
662 + }
663 +
664 + const reportedErrors = [];
665 + let shellReady = false;
666 + let shellError;
667 + let abort;
668 + await act(() => {
669 + const controls = renderToPipeableStream(<PendingRoot />, {
670 + onError(error) {
671 + reportedErrors.push(error);
672 + },
673 + onShellReady() {
674 + shellReady = true;
675 + },
676 + onShellError(error) {
677 + shellError = error;
678 + },
679 + });
680 + abort = controls.abort;
681 + });
682 +
683 + await act(() => {
684 + abort(browserValue);
685 + });
686 +
687 + expect(shellError).toBeInstanceOf(Error);
688 + expect(shellError.message).toBe(
689 + 'The server render could not complete because client rendering was ' +
690 + "requested outside a Suspense boundary. See this error's cause for " +
691 + 'additional details.',
692 + );
693 + expect(shellError.cause).toBe(browserValue);
694 + expect(shellReady).toBe(false);
695 + expect(reportedErrors).toEqual([shellError]);
696 + });
697 +
698 + // @gate enableBrowserAPI
699 + it('reports the browser value if it is thrown instead of passed to use', async () => {
700 + const browserValue = ReactDOM.browser();
701 +
702 + function BrowserOnly() {
703 + throw browserValue;
704 + }
705 +
706 + const reportedErrors = [];
707 + await act(() => {
708 + const {pipe} = renderToPipeableStream(
709 + <Suspense fallback={<span>Fallback</span>}>
710 + <BrowserOnly />
711 + </Suspense>,
712 + {
713 + onError(error) {
714 + reportedErrors.push(error);
715 + },
716 + },
717 + );
718 + pipe(writable);
719 + });
720 +
721 + expect(reportedErrors).toEqual([browserValue]);
722 + expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
723 + });
724 +
725 + ['', 'BROWSER'].forEach(userDigest => {
726 + it(`does not reserve the ${JSON.stringify(
727 + userDigest,
728 + )} user error digest for browser rendering`, async () => {
729 + let isClient = false;
730 + const serverError = new Error('Server error');
731 +
732 + function ServerError() {
733 + if (!isClient) {
734 + throw serverError;
735 + }
736 + return <span>Client</span>;
737 + }
738 +
739 + function App() {
740 + return (
741 + <Suspense fallback={<span>Fallback</span>}>
742 + <ServerError />
743 + </Suspense>
744 + );
745 + }
746 +
747 + const serverErrors = [];
748 + await act(() => {
749 + const {pipe} = renderToPipeableStream(<App />, {
750 + onError(error) {
751 + serverErrors.push(error);
752 + return userDigest;
753 + },
754 + });
755 + pipe(writable);
756 + });
757 +
758 + expect(serverErrors).toEqual([serverError]);
759 + expect(getVisibleChildren(container)).toEqual(<span>Fallback</span>);
760 +
761 + isClient = true;
762 + const recoverableErrors = [];
763 + ReactDOMClient.hydrateRoot(container, <App />, {
764 + onRecoverableError(error) {
765 + recoverableErrors.push(error);
766 + },
767 + });
768 + await waitForAll([]);
769 +
770 + expect(recoverableErrors).toHaveLength(1);
771 + expect(recoverableErrors[0].digest).toBe(userDigest || undefined);
772 + expect(getVisibleChildren(container)).toEqual(<span>Client</span>);
773 + });
774 + });
775 +
776 it('should asynchronously load a lazy component', async () => {
777 let resolveA;
778 const LazyA = React.lazy(() => {
packages/react-dom/src/client/ReactDOMClientFB.js
+2
@@ -28,6 +28,7 @@ import ReactVersion from 'shared/ReactVersion';
28 import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion';
29 ensureCorrectIsomorphicReactVersion();
30
31 +import {browser} from '../shared/ReactDOMBrowser';
32 import {
33 getInstanceFromNode,
34 getNodeFromInstance,
@@ -125,6 +126,7 @@ function unstable_batchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
126 }
127
128 export {
129 + browser,
130 createPortal,
131 unstable_batchedUpdates,
132 flushSync,
packages/react-dom/src/shared/ReactDOM.js
+2
@@ -28,6 +28,7 @@ import {
28 useFormStatus,
29 useFormState,
30 } from 'react-dom-bindings/src/shared/ReactDOMFormActions';
31 +import {browser} from './ReactDOMBrowser';
32
33 if (__DEV__) {
34 if (
@@ -69,6 +70,7 @@ function createPortal(
70
71 export {
72 ReactVersion as version,
73 + browser,
74 createPortal,
75 flushSync,
76 batchedUpdates as unstable_batchedUpdates,
packages/react-dom/src/shared/ReactDOMBrowser.js new
+34
@@ -0,0 +1,34 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {ReactRecoverable} 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 + "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.',
25 + );
26 + Object.defineProperty(recoverable as any, '$$typeof', {
27 + value: REACT_RECOVERABLE_TYPE,
28 + });
29 + return recoverable as any;
30 +};
31 +
32 +export const browser: (() => ReactRecoverable) | void = enableBrowserAPI
33 + ? browserImpl
34 + : undefined;
packages/react-reconciler/src/ReactFiberBeginWork.js
+22 -17
@@ -131,6 +131,7 @@ import {
131 REACT_MEMO_TYPE,
132 REACT_CONTEXT_TYPE,
133 } from 'shared/ReactSymbols';
134 +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable';
135 import {setCurrentFiber} from './ReactCurrentFiber';
136 import {resolveTypeForHotReloading} from './ReactFiberHotReloading';
137
@@ -3001,25 +3002,29 @@ function updateDehydratedSuspenseComponent(
3002 ({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance));
3003 }
3004
3004 - let error: Error;
3005 - if (__DEV__ && message) {
3006 - // eslint-disable-next-line react-internal/prod-error-codes
3007 - error = new Error(message);
3008 - } else {
3009 - error = new Error(
3010 - 'The server could not finish this Suspense boundary, likely ' +
3011 - 'due to an error during server rendering. ' +
3012 - 'Switched to client rendering.',
3005 + // This is unreachable in renderers that do not support hydration.
3006 + // $FlowFixMe[invalid-compare]
3007 + if (digest !== REACT_RECOVERABLE_DIGEST) {
3008 + let error: Error;
3009 + if (__DEV__ && message) {
3010 + // eslint-disable-next-line react-internal/prod-error-codes
3011 + error = new Error(message);
3012 + } else {
3013 + error = new Error(
3014 + 'The server could not finish this Suspense boundary, likely ' +
3015 + 'due to an error during server rendering. ' +
3016 + 'Switched to client rendering.',
3017 + );
3018 + }
3019 + // Replace the stack with the server stack
3020 + error.stack = (__DEV__ && stack) || '';
3021 + (error as any).digest = digest;
3022 + const capturedValue = createCapturedValueFromError(
3023 + error,
3024 + componentStack === undefined ? null : componentStack,
3025 );
3026 + queueHydrationError(capturedValue);
3027 }
3015 - // Replace the stack with the server stack
3016 - error.stack = (__DEV__ && stack) || '';
3017 - (error as any).digest = digest;
3018 - const capturedValue = createCapturedValueFromError(
3019 - error,
3020 - componentStack === undefined ? null : componentStack,
3021 - );
3022 - queueHydrationError(capturedValue);
3028 return retrySuspenseComponentWithoutHydrating(
3029 current,
3030 workInProgress,
packages/react-reconciler/src/ReactFiberHooks.js
+5
@@ -46,6 +46,7 @@ import {
46 } from 'shared/ReactFeatureFlags';
47 import {
48 REACT_CONTEXT_TYPE,
49 + REACT_RECOVERABLE_TYPE,
50 REACT_MEMO_CACHE_SENTINEL,
51 } from 'shared/ReactSymbols';
52
@@ -1156,6 +1157,10 @@ function use<T>(usable: Usable<T>): T {
1157 // This is a thenable.
1158 const thenable: Thenable<T> = usable as any;
1159 return useThenable(thenable);
1160 + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
1161 + // Fiber is the final renderer, so there is no downstream host that
1162 + // needs to recover this subtree. Continue rendering through it.
1163 + return undefined as any;
1164 } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
1165 const context: ReactContext<T> = usable as any;
1166 return readContext(context);
packages/react-server/src/ReactFizzHooks.js
+51
@@ -14,6 +14,7 @@ import type {
14 StartTransitionOptions,
15 Thenable,
16 Usable,
17 + ReactRecoverable,
18 ReactCustomFormAction,
19 Awaited,
20 } from 'shared/ReactTypes';
@@ -41,6 +42,7 @@ import {createFastHash} from './ReactServerStreamConfig';
42 import is from 'shared/objectIs';
43 import {
44 REACT_CONTEXT_TYPE,
45 + REACT_RECOVERABLE_TYPE,
46 REACT_MEMO_CACHE_SENTINEL,
47 } from 'shared/ReactSymbols';
48 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
@@ -89,6 +91,30 @@ let actionStateMatchingIndex: number = -1;
91 // Counts the number of use(thenable) calls in this component
92 let thenableIndexCounter: number = 0;
93 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 + return new Error(
111 + 'The server render could not complete because client rendering was ' +
112 + "requested outside a Suspense boundary. See this error's cause for " +
113 + 'additional details.',
114 + {cause: recoverable},
115 + );
116 +}
117 +
118 // Lazily created map of render-phase updates
119 let renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null = null;
120 // Counter to prevent infinite loops.
@@ -276,6 +302,22 @@ export function getThenableStateAfterSuspending(): null | ThenableState {
302 return state;
303 }
304
305 +export function getSuspendedRecoverableError(): Error {
306 + if (suspendedRecoverableError === null) {
307 + throw new Error(
308 + 'Expected a suspended recoverable. This is a bug in React. Please file ' +
309 + 'an issue.',
310 + );
311 + }
312 + const error = suspendedRecoverableError;
313 + suspendedRecoverableError = null;
314 + return error;
315 +}
316 +
317 +export function clearSuspendedRecoverableError(): void {
318 + suspendedRecoverableError = null;
319 +}
320 +
321 export function checkDidRenderIdHook(): boolean {
322 // This should be called immediately after every finishHooks call.
323 // Conceptually, it's part of the return value of finishHooks; it's only a
@@ -756,6 +798,15 @@ function use<T>(usable: Usable<T>): T {
798 // This is a thenable.
799 const thenable: Thenable<T> = usable as any;
800 return unwrapThenable(thenable);
801 + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
802 + // Fizz can defer this subtree to a downstream renderer. Like a suspended
803 + // thenable, keep the actual value out of userspace and throw an opaque
804 + // sentinel to unwind the stack. Capture the use() call site eagerly so
805 + // that if there is no Suspense boundary, the fatal error points here and
806 + // its cause points to where the recoverable was created.
807 + const recoverable: ReactRecoverable = usable as any;
808 + suspendedRecoverableError = createFatalRecoverableError(recoverable);
809 + throw RecoverableException;
810 } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
811 const context: ReactContext<T> = usable as any;
812 return readContext(context);
packages/react-server/src/ReactFizzServer.js
+93 -19
@@ -28,6 +28,7 @@ import type {
28 SuspenseListProps,
29 SuspenseListRevealOrder,
30 ReactKey,
31 + ReactRecoverable,
32 } from 'shared/ReactTypes';
33 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
34 import type {
@@ -134,6 +135,10 @@ import {
135 readPreviousThenableFromState,
136 getActionStateCount,
137 getActionStateMatchingIndex,
138 + RecoverableException,
139 + createFatalRecoverableError,
140 + getSuspendedRecoverableError,
141 + clearSuspendedRecoverableError,
142 } from './ReactFizzHooks';
143 import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher';
144 import {
@@ -173,6 +178,7 @@ import {
178 REACT_VIEW_TRANSITION_TYPE,
179 REACT_ACTIVITY_TYPE,
180 REACT_OPTIMISTIC_KEY,
181 + REACT_RECOVERABLE_TYPE,
182 } from 'shared/ReactSymbols';
183 import ReactSharedInternals from 'shared/ReactSharedInternals';
184 import {
@@ -191,6 +197,7 @@ import assign from 'shared/assign';
197 import noop from 'shared/noop';
198 import getComponentNameFromType from 'shared/getComponentNameFromType';
199 import isArray from 'shared/isArray';
200 +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable';
201 import {
202 SuspenseException,
203 getSuspendedThenable,
@@ -1317,6 +1324,14 @@ function encodeErrorForBoundary(
1324 ) {
1325 boundary.errorDigest = digest;
1326 if (__DEV__) {
1327 + if (error === RecoverableException) {
1328 + boundary.errorMessage = wasAborted
1329 + ? 'Switched to client rendering because the server render was aborted ' +
1330 + 'with a request to render on the client.'
1331 + : 'Switched to client rendering because a component requested it.';
1332 + boundary.errorComponentStack = thrownInfo.componentStack;
1333 + return;
1334 + }
1335 let message, stack;
1336 // In dev we additionally encode the error message and component stack on the boundary
1337 if (error instanceof Error) {
@@ -1347,6 +1362,11 @@ function logRecoverableError(
1362 errorInfo: ThrownInfo,
1363 debugTask: null | ConsoleTask,
1364 ): ?string {
1365 + if (error === RecoverableException) {
1366 + clearSuspendedRecoverableError();
1367 + return REACT_RECOVERABLE_DIGEST;
1368 + }
1369 +
1370 // If this callback errors, we intentionally let that error bubble up to become a fatal error
1371 // so that someone fixes the error reporting instead of hiding it.
1372 const onError = request.onError;
@@ -1365,7 +1385,10 @@ function logRecoverableError(
1385 }
1386 return;
1387 }
1368 - return errorDigest;
1388 + // An empty digest is reserved for React's internal client-render signal.
1389 + // Historically an empty digest was omitted from the wire format, so
1390 + // normalizing it to undefined preserves the existing user-space semantics.
1391 + return errorDigest === '' ? undefined : errorDigest;
1392 }
1393
1394 function fatalError(
@@ -4524,19 +4547,34 @@ function erroredTask(
4547
4548 request.allPendingTasks--;
4549
4527 - // Report the error to a global handler.
4550 // We don't handle halts here because we only halt when prerendering and
4551 // when prerendering we should be finishing tasks not erroring them when
4552 // they halt or postpone
4531 - const errorDigest = logRecoverableError(request, error, errorInfo, debugTask);
4553 if (boundary === null) {
4533 - fatalError(request, error, errorInfo, debugTask);
4554 + // Recoverables can remain silent when a Suspense boundary lets us emit a
4555 + // shell and defer its content to a downstream renderer. At the root there
4556 + // is no shell to stream, so this is a fatal error and must be reported like
4557 + // any other root error.
4558 + if (error === RecoverableException) {
4559 + const useError = getSuspendedRecoverableError();
4560 + logRecoverableError(request, useError, errorInfo, debugTask);
4561 + fatalError(request, useError, errorInfo, debugTask);
4562 + } else {
4563 + logRecoverableError(request, error, errorInfo, debugTask);
4564 + fatalError(request, error, errorInfo, debugTask);
4565 + }
4566 // The shell fatally errored, so the render can never complete. Return before
4567 // the completeAll check below so we don't fire onAllReady for a render that
4568 // produced nothing. This mirrors finishAbortedTask, which also returns after
4569 // a fatalError on the root.
4570 return;
4571 } else {
4572 + const errorDigest = logRecoverableError(
4573 + request,
4574 + error,
4575 + errorInfo,
4576 + debugTask,
4577 + );
4578 boundary.pendingTasks--;
4579 if (boundary.status !== CLIENT_RENDERED) {
4580 boundary.status = CLIENT_RENDERED;
@@ -4769,19 +4807,43 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4807 }
4808
4809 const errorInfo = getThrownInfo(task.componentStack);
4810 + // Only abort reasons get this interpretation. Throwing a recoverable
4811 + // directly is still an application error; it must be passed to use() or
4812 + // abort() for a renderer to recover it.
4813 + const isRecoverableAbort =
4814 + typeof error === 'object' &&
4815 + error !== null &&
4816 + // $FlowFixMe[prop-missing]
4817 + error.$$typeof === REACT_RECOVERABLE_TYPE;
4818
4819 if (boundary === null) {
4820 const replay: null | ReplaySet = task.replay;
4821 if (replay === null) {
4822 // We didn't complete the root so we have nothing to show. We can close
4823 // the request;
4778 - if (request.trackedPostpones !== null && segment !== null) {
4824 + if (
4825 + !isRecoverableAbort &&
4826 + request.trackedPostpones !== null &&
4827 + segment !== null
4828 + ) {
4829 const trackedPostpones = request.trackedPostpones;
4830 // We are aborting a prerender and must treat the shell as halted
4831 // We log the error but we still resolve the prerender
4832 logRecoverableError(request, error, errorInfo, task.debugTask);
4833 trackPostpone(request, trackedPostpones, task, segment);
4834 finishedTask(request, null, task.row, segment);
4835 + } else if (isRecoverableAbort) {
4836 + const recoverable: ReactRecoverable = error as any;
4837 + const fatalRecoverableError = createFatalRecoverableError(recoverable);
4838 + logRecoverableError(
4839 + request,
4840 + fatalRecoverableError,
4841 + errorInfo,
4842 + task.debugTask,
4843 + );
4844 + if (request.status !== CLOSING && request.status !== CLOSED) {
4845 + fatalError(request, fatalRecoverableError, errorInfo, task.debugTask);
4846 + }
4847 } else {
4848 logRecoverableError(request, error, errorInfo, task.debugTask);
4849 if (request.status !== CLOSING && request.status !== CLOSED) {
@@ -4796,18 +4858,21 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4858 // the ReplaySet.
4859 replay.pendingTasks--;
4860 if (replay.pendingTasks === 0 && replay.nodes.length > 0) {
4799 - const errorDigest = logRecoverableError(
4800 - request,
4801 - error,
4802 - errorInfo,
4803 - null,
4804 - );
4861 + let errorDigest;
4862 + let errorForBoundary;
4863 + if (isRecoverableAbort) {
4864 + errorDigest = REACT_RECOVERABLE_DIGEST;
4865 + errorForBoundary = RecoverableException;
4866 + } else {
4867 + errorDigest = logRecoverableError(request, error, errorInfo, null);
4868 + errorForBoundary = error;
4869 + }
4870 abortRemainingReplayNodes(
4871 request,
4872 null,
4873 replay.nodes,
4874 replay.slots,
4810 - error,
4875 + errorForBoundary,
4876 errorDigest,
4877 errorInfo,
4878 true,
@@ -4823,7 +4888,11 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4888 // boundary the message is referring to
4889 const trackedPostpones = request.trackedPostpones;
4890 if (boundary.status !== CLIENT_RENDERED) {
4826 - if (trackedPostpones !== null && segment !== null) {
4891 + if (
4892 + !isRecoverableAbort &&
4893 + trackedPostpones !== null &&
4894 + segment !== null
4895 + ) {
4896 // We are aborting a prerender and must halt this boundary.
4897 // We treat this like other postpones during prerendering
4898 logRecoverableError(request, error, errorInfo, task.debugTask);
@@ -4839,14 +4908,19 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void {
4908 boundary.status = CLIENT_RENDERED;
4909 // We are aborting a render or resume which should put boundaries
4910 // into an explicitly client rendered state
4842 - const errorDigest = logRecoverableError(
4843 - request,
4844 - error,
4911 + const errorDigest = isRecoverableAbort
4912 + ? REACT_RECOVERABLE_DIGEST
4913 + : logRecoverableError(request, error, errorInfo, task.debugTask);
4914 + const errorForBoundary = isRecoverableAbort
4915 + ? RecoverableException
4916 + : error;
4917 + encodeErrorForBoundary(
4918 + boundary,
4919 + errorDigest,
4920 + errorForBoundary,
4921 errorInfo,
4846 - task.debugTask,
4922 + true,
4923 );
4848 - boundary.status = CLIENT_RENDERED;
4849 - encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);
4924
4925 untrackBoundary(request, boundary);
4926
packages/react-server/src/ReactFlightHooks.js
+5 -1
@@ -151,7 +151,11 @@ function use<T>(usable: Usable<T>): T {
151 }
152
153 if (isClientReference(usable)) {
154 - if (usable.value != null && usable.value.$$typeof === REACT_CONTEXT_TYPE) {
154 + const clientReference: any = usable;
155 + if (
156 + clientReference.value != null &&
157 + clientReference.value.$$typeof === REACT_CONTEXT_TYPE
158 + ) {
159 // Show a more specific message since it's a common mistake.
160 throw new Error('Cannot read a Client Context from a Server Component.');
161 } else {
packages/shared/ReactFeatureFlags.js
+3
@@ -22,6 +22,9 @@
22 // when it rolls out to prod. We should remove these as soon as possible.
23 // -----------------------------------------------------------------------------
24
25 +// Enables the browser() API exported from react-dom.
26 +export const enableBrowserAPI: boolean = true;
27 +
28 // -----------------------------------------------------------------------------
29 // Land or remove (moderate effort)
30 //
packages/shared/ReactRecoverable.js new
+13
@@ -0,0 +1,13 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +// Empty digests are otherwise treated as if no digest was provided. This lets
11 +// React distinguish an intentional client render without reserving a
12 +// user-space digest value.
13 +export const REACT_RECOVERABLE_DIGEST = '';
packages/shared/ReactSymbols.js
+2
@@ -46,6 +46,8 @@ export const REACT_VIEW_TRANSITION_TYPE: symbol = Symbol.for(
46 'react.view_transition',
47 );
48
49 +export const REACT_RECOVERABLE_TYPE: symbol = Symbol.for('react.recoverable');
50 +
51 const MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
52 const FAUX_ITERATOR_SYMBOL = '@@iterator';
53
packages/shared/ReactTypes.js
+9 -1
@@ -144,11 +144,19 @@ export type Thenable<T> =
144 | FulfilledThenable<T>
145 | RejectedThenable<T>;
146
147 +// A recoverable lets an intermediate renderer defer a subtree to a downstream
148 +// renderer. It does not produce a value: a renderer either continues through
149 +// it or interrupts the current render so that a later renderer can recover the
150 +// subtree.
151 +export type ReactRecoverable = Error & {
152 + $$typeof: symbol,
153 +};
154 +
155 export type StartTransitionOptions = {
156 name?: string,
157 };
158
151 -export type Usable<T> = Thenable<T> | ReactContext<T>;
159 +export type Usable<T> = Thenable<T> | ReactContext<T> | ReactRecoverable;
160
161 export type ReactCustomFormAction = {
162 name?: string,
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -42,6 +42,7 @@ export const enableAsyncDebugInfo: boolean = true;
42 export const enableAsyncIterableChildren: boolean = false;
43 export const enableCPUSuspense: boolean = true;
44 export const enableCreateEventHandleAPI: boolean = false;
45 +export const enableBrowserAPI: boolean = true;
46 export const enableEffectEventMutationPhase: boolean = true;
47 export const enableMoveBefore: boolean = true;
48 export const enableFizzExternalRuntime: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -28,6 +28,7 @@ export const enableAsyncDebugInfo: boolean = true;
28 export const enableAsyncIterableChildren: boolean = false;
29 export const enableCPUSuspense: boolean = false;
30 export const enableCreateEventHandleAPI: boolean = false;
31 +export const enableBrowserAPI: boolean = true;
32 export const enableMoveBefore: boolean = true;
33 export const enableFizzExternalRuntime: boolean = true;
34 export const enableInfiniteRenderLoopDetection: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true;
25 export const disableInputAttributeSyncing: boolean = false;
26 export const enableScopeAPI: boolean = false;
27 export const enableCreateEventHandleAPI: boolean = false;
28 +export const enableBrowserAPI: boolean = true;
29 export const enableSuspenseCallback: boolean = false;
30 export const enableTrustedTypesIntegration: boolean = true;
31 export const disableTextareaChildren: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -23,6 +23,7 @@ export const enableAsyncDebugInfo = true;
23 export const enableAsyncIterableChildren = false;
24 export const enableCPUSuspense = true;
25 export const enableCreateEventHandleAPI = false;
26 +export const enableBrowserAPI = true;
27 export const enableMoveBefore = false;
28 export const enableFizzExternalRuntime = true;
29 export const enableInfiniteRenderLoopDetection = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true;
25 export const disableInputAttributeSyncing: boolean = false;
26 export const enableScopeAPI: boolean = true;
27 export const enableCreateEventHandleAPI: boolean = false;
28 +export const enableBrowserAPI: boolean = true;
29 export const enableSuspenseCallback: boolean = true;
30 export const disableLegacyContext: boolean = false;
31 export const disableLegacyContextForFunctionComponents: boolean = false;
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -80,6 +80,8 @@ export const disableCommentsAsDOMContainers: boolean = false;
80
81 export const enableCreateEventHandleAPI: boolean = true;
82
83 +export const enableBrowserAPI: boolean = true;
84 +
85 export const enableEffectEventMutationPhase: boolean = true;
86
87 export const enableScopeAPI: boolean = true;
scripts/error-codes/codes.json
+5 -1
@@ -587,5 +587,9 @@
587 "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.",
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."
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.",
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."
595 }