diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 8e245a6c47e..ee964c815fc 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -35,6 +35,7 @@ import { import { REACT_MEMO_CACHE_SENTINEL, REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, } from 'shared/ReactSymbols'; import hasOwnProperty from 'shared/hasOwnProperty'; @@ -110,6 +111,11 @@ function getPrimitiveStackCache(): Map> { $$typeof: REACT_CONTEXT_TYPE, _currentValue: null, } as any); + const recoverable = new Error(); + Object.defineProperty(recoverable as any, '$$typeof', { + value: REACT_RECOVERABLE_TYPE, + }); + Dispatcher.use(recoverable as any); Dispatcher.use({ then() {}, status: 'fulfilled', @@ -240,6 +246,16 @@ function use(usable: Usable): T { dispatcherHookName: 'Use', }); throw SuspenseException; + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + hookLog.push({ + displayName: null, + primitive: 'Recoverable', + stackError: new Error(), + value: undefined, + debugInfo: null, + dispatcherHookName: 'Use', + }); + return undefined as any; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; const value = readContext(context); diff --git a/packages/react-devtools-extensions/src/contentScripts/backendManager.js b/packages/react-devtools-extensions/src/contentScripts/backendManager.js index 5589bcc1cb6..3d8fcc21dd3 100644 --- a/packages/react-devtools-extensions/src/contentScripts/backendManager.js +++ b/packages/react-devtools-extensions/src/contentScripts/backendManager.js @@ -22,6 +22,37 @@ import { let welcomeHasInitialized = false; const requiredBackends = new Set(); +const activeBackendsShutdownCallbacks = new Set<() => void>(); +let cleanupBackendManagerSetup: (() => void) | null = null; +let hasShutdownBackendManager = false; + +function finishBackendManagerShutdown() { + if (hasShutdownBackendManager) { + return; + } + hasShutdownBackendManager = true; + + window.removeEventListener('message', welcome); + window.removeEventListener('pagehide', handlePageHide); + + const cleanup = cleanupBackendManagerSetup; + cleanupBackendManagerSetup = null; + cleanup?.(); + + delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__; +} + +function handlePageHide() { + // A document in the back-forward cache keeps its JavaScript heap but loses + // its extension messaging port. Shut down locally while the document is + // still active so a restored page can attach a new Agent and replay its tree. + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const shutdownBackend of activeBackendsShutdownCallbacks) { + shutdownBackend(); + } + + finishBackendManagerShutdown(); +} function welcome(event: $FlowFixMe) { if ( @@ -89,11 +120,26 @@ function setup(hook: ?DevToolsHook) { }, ); - const unsubscribeShutdownListener: () => void = hook.sub('shutdown', () => { + let didCleanup = false; + let unsubscribeShutdownListener: (() => void) | null = null; + const cleanup = () => { + if (didCleanup) { + return; + } + didCleanup = true; + unsubscribeRendererListener(); unsubscribeBackendInstallationListener(); - unsubscribeShutdownListener(); - }); + unsubscribeShutdownListener?.(); + unsubscribeShutdownListener = null; + + if (cleanupBackendManagerSetup === cleanup) { + cleanupBackendManagerSetup = null; + } + }; + + unsubscribeShutdownListener = hook.sub('shutdown', cleanup); + cleanupBackendManagerSetup = cleanup; } function registerRenderer(renderer: ReactRenderer, hook: DevToolsHook) { @@ -115,6 +161,7 @@ function activateBackend(version: string, hook: DevToolsHook) { } const {Agent, Bridge, initBackend, setupNativeStyleEditor} = backend; + let shouldSendMessages = true; const bridge = new Bridge({ listen(fn) { const listener = (event: $FlowFixMe) => { @@ -134,6 +181,10 @@ function activateBackend(version: string, hook: DevToolsHook) { }; }, send(event: string, payload: mixed, transferable?: $ReadOnlyArray) { + if (!shouldSendMessages) { + return; + } + window.postMessage( { source: 'react-devtools-bridge', @@ -154,11 +205,28 @@ function activateBackend(version: string, hook: DevToolsHook) { // Clean up flags, so that next reload won't start profiling onReloadAndProfileFlagsReset(); + let hasShutdownBackend = false; + const shutdownBackend = () => { + if (hasShutdownBackend) { + return; + } + hasShutdownBackend = true; + shouldSendMessages = false; + + bridge.shutdown(); + }; + activeBackendsShutdownCallbacks.add(shutdownBackend); + agent.addListener('shutdown', () => { - // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down, - // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here. + hasShutdownBackend = true; + shouldSendMessages = false; + activeBackendsShutdownCallbacks.delete(shutdownBackend); + hook.emit('shutdown'); - delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__; + + if (activeBackendsShutdownCallbacks.size === 0) { + finishBackendManagerShutdown(); + } }); initBackend(hook, agent, window, getIsReloadAndProfileSupported()); @@ -207,4 +275,5 @@ if (!window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__) { window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__ = true; window.addEventListener('message', welcome); + window.addEventListener('pagehide', handlePageHide); } diff --git a/packages/react-devtools-extensions/src/contentScripts/proxy.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js index 99e4080a344..0469023089b 100644 --- a/packages/react-devtools-extensions/src/contentScripts/proxy.js +++ b/packages/react-devtools-extensions/src/contentScripts/proxy.js @@ -17,6 +17,8 @@ import { } from '../constants'; function injectProxy() { + isTransportActive = true; + // Firefox's behaviour for injecting this content script can be unpredictable // While navigating the history, some content scripts might not be re-injected and still be alive if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) { @@ -29,9 +31,9 @@ function injectProxy() { // The backend waits to install the global hook until notified by the content script. // In the event of a page reload, the content script might be loaded before the backend manager is injected. // Because of this we need to poll the backend manager until it has been initialized. - const intervalID: IntervalID = setInterval(() => { + backendManagerHelloIntervalID = setInterval(() => { if (backendInitialized) { - clearInterval(intervalID); + stopPollingForBackendManager(); } else { sayHelloToBackendManager(); } @@ -62,14 +64,29 @@ window.addEventListener('pagehide', function ({target}) { return; } + isTransportActive = false; + backendInitialized = false; + isBridgeConnected = false; + pendingMessages.length = 0; + stopPollingForBackendManager(); + delete window.__REACT_DEVTOOLS_PROXY_INJECTED__; }); let port: ExtensionRuntimePort | null = null; +let isTransportActive: boolean = true; let backendInitialized: boolean = false; let isBridgeConnected: boolean = false; let isListeningToMessagesFromBackend: boolean = false; const pendingMessages: Array = []; +let backendManagerHelloIntervalID: IntervalID | null = null; + +function stopPollingForBackendManager() { + if (backendManagerHelloIntervalID !== null) { + clearInterval(backendManagerHelloIntervalID); + backendManagerHelloIntervalID = null; + } +} function listenToMessagesFromBackend() { if (!isListeningToMessagesFromBackend) { @@ -116,7 +133,7 @@ function handleMessageFromDevtools( sourcePort: ExtensionRuntimePort, message: mixed, ) { - if (port !== sourcePort) { + if (!isTransportActive || port !== sourcePort) { return; } @@ -153,7 +170,7 @@ function handleMessageFromDevtools( } function handleMessageFromPage(event: any) { - if (event.source !== window || !event.data) { + if (!isTransportActive || event.source !== window || !event.data) { return; } @@ -206,6 +223,10 @@ function handleDisconnect(disconnectedPort: ExtensionRuntimePort) { // Creates port from application page to the React DevTools' service worker // Which then connects it with extension port function connectPort() { + if (!isTransportActive) { + return; + } + isBridgeConnected = false; const nextPort = chrome.runtime.connect({ name: 'proxy', diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index d8373090366..f0504cc24c6 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -67,6 +67,13 @@ type PendingBridgeMessage = { transferable?: $ReadOnlyArray, }; +type DevToolsInstance = { + bridge: FrontendBridge, + store: Store, + render: (overrideTab?: TabID) => void, + root: RootType, +}; + function flushPendingBridgeMessages(): void { const currentPort = port; if (!isBridgeConnected || currentPort === null) { @@ -128,8 +135,8 @@ function addBridgePortListener(nextPort: ExtensionRuntimePort): void { bridgePortListener = nextBridgePortListener; } -function createBridge() { - bridge = new Bridge({ +function createBridge(): FrontendBridge { + const bridge: FrontendBridge = new Bridge({ listen(fn) { const currentPort = port; if (currentPort === null) { @@ -199,7 +206,13 @@ function createBridge() { }, }; // Rerender with the new file selection. - render(); + const instance = devToolsInstance; + if (instance === null) { + throw new Error( + 'Cannot sync source selection: DevTools instance is not initialized.', + ); + } + instance.render(); } else { // Update the ref to the latest position without updating the url. No need to rerender. const selectionRef = currentSelectedSource.selectionRef; @@ -229,14 +242,16 @@ function createBridge() { onBrowserSourceSelectionChanged, ); } + + return bridge; } -function createBridgeAndStore() { - createBridge(); +function createDevToolsInstance(): DevToolsInstance { + const bridge = createBridge(); const {isProfiling} = getProfilingFlags(); - store = new Store(bridge, { + const store = new Store(bridge, { isProfiling, supportsReloadAndProfile: __IS_CHROME__ || __IS_EDGE__, // At this time, the timeline can only parse Chrome performance profiles. @@ -285,9 +300,9 @@ function createBridgeAndStore() { ); }; - root = createRoot(document.createElement('div')); + const root = createRoot(document.createElement('div')); - render = (overrideTab: TabID | null = mostRecentOverrideTab) => { + const render = (overrideTab: TabID | null = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; root.render( @@ -314,6 +329,8 @@ function createBridgeAndStore() { }), ); }; + + return {bridge, store, render, root}; } function ensureInitialHTMLIsCleared( @@ -328,14 +345,6 @@ function ensureInitialHTMLIsCleared( } function createComponentsPanel() { - if (componentsPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); - - return; - } - if (componentsPanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -350,35 +359,30 @@ function createComponentsPanel() { createdPanel.onShown.addListener(portal => { componentsPortalContainer = portal.container; - if (componentsPortalContainer != null && render) { - ensureInitialHTMLIsCleared(componentsPortalContainer); + const currentInstance = devToolsInstance; + if (componentsPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('components'); - logEvent({event_name: 'selected-components-tab'}); + logEvent({event_name: 'selected-components-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); createdPanel.onShown.addListener(() => { - bridge.emit('extensionComponentsPanelShown'); + devToolsInstance?.bridge.emit('extensionComponentsPanelShown'); }); createdPanel.onHidden.addListener(() => { - bridge.emit('extensionComponentsPanelHidden'); + devToolsInstance?.bridge.emit('extensionComponentsPanelHidden'); }); }, ); } function createElementsInspectPanel() { - if (inspectedElementPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - render(); - - return; - } - if (inspectedElementPane) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -403,28 +407,23 @@ function createElementsInspectPanel() { createdPane.onShown.addListener(portal => { inspectedElementPortalContainer = portal.container; - if (inspectedElementPortalContainer != null && render) { - ensureInitialHTMLIsCleared(inspectedElementPortalContainer); - bridge.send('syncSelectionFromBuiltinElementsPanel'); + const currentInstance = devToolsInstance; + if (inspectedElementPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + currentInstance.bridge.send('syncSelectionFromBuiltinElementsPanel'); - render(); - portal.injectStyles(cloneStyleTags); + currentInstance.render(); - logEvent({event_name: 'selected-inspected-element-pane'}); + logEvent({event_name: 'selected-inspected-element-pane'}); + } + portal.injectStyles(cloneStyleTags); } }); }); } function createProfilerPanel() { - if (profilerPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); - - return; - } - if (profilerPanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -439,13 +438,16 @@ function createProfilerPanel() { createdPanel.onShown.addListener(portal => { profilerPortalContainer = portal.container; - if (profilerPortalContainer != null && render) { - ensureInitialHTMLIsCleared(profilerPortalContainer); + const currentInstance = devToolsInstance; + if (profilerPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('profiler'); - logEvent({event_name: 'selected-profiler-tab'}); + logEvent({event_name: 'selected-profiler-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); }, @@ -453,14 +455,6 @@ function createProfilerPanel() { } function createSourcesEditorPanel() { - if (editorPortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(editorPortalContainer); - render(); - - return; - } - if (editorPane) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -480,27 +474,22 @@ function createSourcesEditorPanel() { createdPane.onShown.addListener(portal => { editorPortalContainer = portal.container; - if (editorPortalContainer != null && render) { - ensureInitialHTMLIsCleared(editorPortalContainer); + const currentInstance = devToolsInstance; + if (editorPortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(editorPortalContainer); - render(); - portal.injectStyles(cloneStyleTags); + currentInstance.render(); - logEvent({event_name: 'selected-editor-pane'}); + logEvent({event_name: 'selected-editor-pane'}); + } + portal.injectStyles(cloneStyleTags); } }); }); } function createSuspensePanel() { - if (suspensePortalContainer) { - // Panel is created and user opened it at least once - ensureInitialHTMLIsCleared(suspensePortalContainer); - render('suspense'); - - return; - } - if (suspensePanel) { // Panel is created, but wasn't opened yet, so no document is present for it return; @@ -515,27 +504,66 @@ function createSuspensePanel() { createdPanel.onShown.addListener(portal => { suspensePortalContainer = portal.container; - if (suspensePortalContainer != null && render) { - ensureInitialHTMLIsCleared(suspensePortalContainer); + const currentInstance = devToolsInstance; + if (suspensePortalContainer != null) { + if (currentInstance !== null) { + ensureInitialHTMLIsCleared(suspensePortalContainer); - render('suspense'); - portal.injectStyles(cloneStyleTags); + currentInstance.render('suspense'); - logEvent({event_name: 'selected-suspense-tab'}); + logEvent({event_name: 'selected-suspense-tab'}); + } + portal.injectStyles(cloneStyleTags); } }); }, ); } +function createDevToolsPanels(): void { + createComponentsPanel(); + createProfilerPanel(); + createSourcesEditorPanel(); + createElementsInspectPanel(); + createSuspensePanel(); +} + +function renderOpenedDevToolsPanels(instance: DevToolsInstance): void { + if (componentsPortalContainer) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + instance.render('components'); + } + + if (profilerPortalContainer) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + instance.render('profiler'); + } + + if (editorPortalContainer) { + ensureInitialHTMLIsCleared(editorPortalContainer); + instance.render(); + } + + if (inspectedElementPortalContainer) { + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + instance.bridge.send('syncSelectionFromBuiltinElementsPanel'); + instance.render(); + } + + if (suspensePortalContainer) { + ensureInitialHTMLIsCleared(suspensePortalContainer); + instance.render('suspense'); + } +} + function performInTabNavigationCleanup() { // Potentially, if react hasn't loaded yet and user performs in-tab navigation clearReactPollingInstance(); - // $FlowFixMe[invalid-compare] - if (store !== null) { + const instance = devToolsInstance; + if (instance !== null) { // Store profiling data, so it can be used later - profilingData = store.profilerStore.profilingData; + profilingData = instance.store.profilerStore.profilingData; } // If panels were already created, and we have already mounted React root to display @@ -544,17 +572,17 @@ function performInTabNavigationCleanup() { (componentsPortalContainer || profilerPortalContainer || suspensePortalContainer) && - root + instance !== null ) { // It's easiest to recreate the DevTools panel (to clean up potential stale state). // We can revisit this in the future as a small optimization. // This should also emit bridge.shutdown, but only if this root was mounted - flushSync(() => root.unmount()); + flushSync(() => instance.root.unmount()); } else { // In case Browser DevTools were opened, but user never pressed on extension panels // They were never mounted and there is nothing to unmount, but we need to emit shutdown event // because bridge was already created - bridge?.shutdown(); + instance?.bridge.shutdown(); } // Do not nullify componentsPanelPortal and profilerPanelPortal on purpose, @@ -565,10 +593,7 @@ function performInTabNavigationCleanup() { // Do not clean mostRecentOverrideTab on purpose, so we remember last opened // React DevTools tab, when user does in-tab navigation - store = null as $FlowFixMe; - bridge = null as $FlowFixMe; - render = null as $FlowFixMe; - root = null as $FlowFixMe; + devToolsInstance = null; pendingBridgeMessages.length = 0; } @@ -576,27 +601,25 @@ function performFullCleanup() { // Potentially, if react hasn't loaded yet and user closed the browser DevTools clearReactPollingInstance(); + const instance = devToolsInstance; if ( (componentsPortalContainer || profilerPortalContainer || suspensePortalContainer) && - root + instance !== null ) { // This should also emit bridge.shutdown, but only if this root was mounted - flushSync(() => root.unmount()); + flushSync(() => instance.root.unmount()); } else { - bridge?.shutdown(); + instance?.bridge.shutdown(); } componentsPortalContainer = null; profilerPortalContainer = null; suspensePortalContainer = null; - root = null as $FlowFixMe; mostRecentOverrideTab = null; - store = null as $FlowFixMe; - bridge = null as $FlowFixMe; - render = null as $FlowFixMe; + devToolsInstance = null; pendingBridgeMessages.length = 0; port?.disconnect(); @@ -646,13 +669,10 @@ function mountReactDevTools() { registerEventsLogger(); - createBridgeAndStore(); + const instance = createDevToolsInstance(); + devToolsInstance = instance; - createComponentsPanel(); - createProfilerPanel(); - createSourcesEditorPanel(); - createElementsInspectPanel(); - createSuspensePanel(); + renderOpenedDevToolsPanels(instance); } let reactPollingInstance = null; @@ -689,11 +709,10 @@ function mountReactDevToolsWhenReactHasLoaded() { ); } -let bridge: FrontendBridge = null as $FlowFixMe; +let devToolsInstance: DevToolsInstance | null = null; let lastSubscribedBridgeListener: ((message: mixed) => void) | null = null; let subscribedBridgePort: ExtensionRuntimePort | null = null; let bridgePortListener: ((message: mixed) => void) | null = null; -let store: Store = null as $FlowFixMe; let profilingData = null; @@ -709,8 +728,6 @@ let editorPortalContainer = null; let inspectedElementPortalContainer = null; let mostRecentOverrideTab: null | TabID = null; -let render: (overrideTab?: TabID) => void = null as $FlowFixMe; -let root: RootType = null as $FlowFixMe; let currentSelectedSource: null | SourceSelection = null; @@ -728,7 +745,7 @@ const debouncedMountReactDevToolsCallback = debounce( 500, ); -// Clean up everything, but start mounting React DevTools panels if user stays at this page +// Clean up everything, but remount React DevTools if user stays at this page function onNavigatedToOtherPage() { performInTabNavigationCleanup(); debouncedMountReactDevToolsCallback(); @@ -747,11 +764,13 @@ if (__IS_FIREFOX__) { connectExtensionPort(); +createDevToolsPanels(); + mountReactDevToolsWhenReactHasLoaded(); function onThemeChanged() { // Rerender with the new theme - render(); + devToolsInstance?.render(); } if (chrome.devtools.panels.setThemeChangeHandler) { diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 40a7f855a9f..4e98c81c903 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4707,7 +4707,7 @@ export function writeStartClientRenderedSuspenseBoundary( startClientRenderedSuspenseBoundary, ); writeChunk(destination, clientRenderedSuspenseBoundaryError1); - if (errorDigest) { + if (errorDigest != null) { writeChunk(destination, clientRenderedSuspenseBoundaryError1A); writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); writeChunk( @@ -5131,6 +5131,7 @@ const clientRenderScript1Full = stringToPrecomputedChunk( const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("'); const clientRenderScript1A = stringToPrecomputedChunk('"'); const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(','); +const clientRenderErrorScriptNull = stringToPrecomputedChunk('null'); const clientRenderScriptEnd = stringToPrecomputedChunk(')'); const clientRenderData1 = stringToPrecomputedChunk( @@ -5182,21 +5183,27 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, clientRenderScript1A); } - if (errorDigest || errorMessage || errorStack || errorComponentStack) { + if ( + errorDigest != null || + errorMessage || + errorStack || + errorComponentStack + ) { if (scriptFormat) { - // ,"JSONString" + // ,null or ,"JSONString" writeChunk(destination, clientRenderErrorScriptArgInterstitial); - writeChunk( - destination, - stringToChunk(escapeJSStringsForInstructionScripts(errorDigest || '')), - ); - } else { + if (errorDigest == null) { + writeChunk(destination, clientRenderErrorScriptNull); + } else { + writeChunk( + destination, + stringToChunk(escapeJSStringsForInstructionScripts(errorDigest)), + ); + } + } else if (errorDigest != null) { // " data-dgst="HTMLString writeChunk(destination, clientRenderData2); - writeChunk( - destination, - stringToChunk(escapeTextForBrowser(errorDigest || '')), - ); + writeChunk(destination, stringToChunk(escapeTextForBrowser(errorDigest))); } } if (errorMessage || errorStack || errorComponentStack) { diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 62efb120bdc..930467ddf18 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -4,7 +4,7 @@ export const markShellTime = 'requestAnimationFrame(function(){$RT=performance.now()});'; export const clientRenderBoundary = - '$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())};'; + '$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())};'; export const completeBoundary = '$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;ba&&2E3 { + beforeEach(() => { + jest.resetModules(); + }); + + // @gate enableBrowserAPI + it('can create browser-only content before the browser renderer is initialized', async () => { + const React = require('react'); + const ReactDOM = require('react-dom'); + const browserOnly = ReactDOM.browser(); + const ReactDOMClient = require('react-dom/client'); + const {act} = require('internal-test-utils'); + + function BrowserOnly() { + React.use(browserOnly); + return Browser; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + Fallback}> + + , + ); + }); + expect(container.innerHTML).toBe('Browser'); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 8ad9df6f78d..e1f3e7adc46 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -406,6 +406,373 @@ describe('ReactDOMFizzServer', () => { ); } + // @gate enableBrowserAPI + it('can opt a component into browser-only rendering', async () => { + let resolveBrowserText; + const browserText = new Promise(resolve => { + resolveBrowserText = resolve; + }); + const browserOnly = ReactDOM.browser(); + + function BrowserOnly() { + use(browserOnly); + const text = use(browserText); + Scheduler.log(text); + return {text}; + } + + function App() { + return ( +
+ Fallback}> + + +
+ ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + pipe(writable); + }); + + expect(serverErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + + await clientAct(() => { + resolveBrowserText('Browser'); + }); + assertLog(['Browser']); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Browser +
, + ); + }); + + // @gate enableBrowserAPI + it('can opt a component into browser-only rendering after streaming the fallback', async () => { + let resolveServerReady; + const serverReady = new Promise(resolve => { + resolveServerReady = resolve; + }); + + function BrowserOnly() { + use(serverReady); + use(ReactDOM.browser()); + return Browser; + } + + function App() { + return ( +
+ Fallback}> + + +
+ ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ Fallback +
, + ); + + await act(() => { + resolveServerReady(); + }); + + expect(serverErrors).toEqual([]); + + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Browser +
, + ); + }); + + // @gate enableBrowserAPI + it('errors if browser-only content is rendered outside Suspense', async () => { + function createBrowserValue() { + return ReactDOM.browser(); + } + const browserValue = createBrowserValue(); + + function BrowserOnly() { + use(browserValue); + return Browser; + } + + const reportedErrors = []; + let shellReady = false; + let shellError; + await act(() => { + renderToPipeableStream(, { + onError(error) { + reportedErrors.push(error); + }, + onShellReady() { + shellReady = true; + }, + onShellError(error) { + shellError = error; + }, + }); + }); + + expect(shellError).toBeInstanceOf(Error); + expect(shellError.message).toBe( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + ); + expect(shellError.stack).toContain('BrowserOnly'); + expect(shellError.cause).toBe(browserValue); + expect(shellError.cause.stack).toContain('createBrowserValue'); + expect(shellError.cause.message).toContain( + '`use(browser())` can only be used inside a `` boundary', + ); + expect(shellReady).toBe(false); + expect(reportedErrors).toEqual([shellError]); + }); + + // @gate enableBrowserAPI + it('can abort all pending boundaries into browser-only rendering', async () => { + const never = new Promise(() => {}); + let isClient = false; + + function Pending({children}) { + if (!isClient) { + use(never); + } + return {children}; + } + + function App() { + return ( +
+ Shell + Loading A}> + A + + Loading B}> + B + +
+ ); + } + + const serverErrors = []; + let abort; + await act(() => { + const controls = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + }, + }); + abort = controls.abort; + controls.pipe(writable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+ Shell + Loading A + Loading B +
, + ); + + await act(() => { + abort(ReactDOM.browser()); + }); + + expect(serverErrors).toEqual([]); + + isClient = true; + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+ Shell + A + B +
, + ); + }); + + // @gate enableBrowserAPI + it('errors if aborted with browser() before the shell completes', async () => { + const never = new Promise(() => {}); + const browserValue = ReactDOM.browser(); + + function PendingRoot() { + use(never); + return Root; + } + + const reportedErrors = []; + let shellReady = false; + let shellError; + let abort; + await act(() => { + const controls = renderToPipeableStream(, { + onError(error) { + reportedErrors.push(error); + }, + onShellReady() { + shellReady = true; + }, + onShellError(error) { + shellError = error; + }, + }); + abort = controls.abort; + }); + + await act(() => { + abort(browserValue); + }); + + expect(shellError).toBeInstanceOf(Error); + expect(shellError.message).toBe( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + ); + expect(shellError.cause).toBe(browserValue); + expect(shellReady).toBe(false); + expect(reportedErrors).toEqual([shellError]); + }); + + // @gate enableBrowserAPI + it('reports the browser value if it is thrown instead of passed to use', async () => { + const browserValue = ReactDOM.browser(); + + function BrowserOnly() { + throw browserValue; + } + + const reportedErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream( + Fallback}> + + , + { + onError(error) { + reportedErrors.push(error); + }, + }, + ); + pipe(writable); + }); + + expect(reportedErrors).toEqual([browserValue]); + expect(getVisibleChildren(container)).toEqual(Fallback); + }); + + ['', 'BROWSER'].forEach(userDigest => { + it(`does not reserve the ${JSON.stringify( + userDigest, + )} user error digest for browser rendering`, async () => { + let isClient = false; + const serverError = new Error('Server error'); + + function ServerError() { + if (!isClient) { + throw serverError; + } + return Client; + } + + function App() { + return ( + Fallback}> + + + ); + } + + const serverErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + serverErrors.push(error); + return userDigest; + }, + }); + pipe(writable); + }); + + expect(serverErrors).toEqual([serverError]); + expect(getVisibleChildren(container)).toEqual(Fallback); + + isClient = true; + const recoverableErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + await waitForAll([]); + + expect(recoverableErrors).toHaveLength(1); + expect(recoverableErrors[0].digest).toBe(userDigest || undefined); + expect(getVisibleChildren(container)).toEqual(Client); + }); + }); + it('should asynchronously load a lazy component', async () => { let resolveA; const LazyA = React.lazy(() => { diff --git a/packages/react-dom/src/client/ReactDOMClientFB.js b/packages/react-dom/src/client/ReactDOMClientFB.js index 58254e64ec8..b6e2b65b292 100644 --- a/packages/react-dom/src/client/ReactDOMClientFB.js +++ b/packages/react-dom/src/client/ReactDOMClientFB.js @@ -28,6 +28,7 @@ import ReactVersion from 'shared/ReactVersion'; import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; ensureCorrectIsomorphicReactVersion(); +import {browser} from '../shared/ReactDOMBrowser'; import { getInstanceFromNode, getNodeFromInstance, @@ -125,6 +126,7 @@ function unstable_batchedUpdates(fn: (a: A) => R, a: A): R { } export { + browser, createPortal, unstable_batchedUpdates, flushSync, diff --git a/packages/react-dom/src/shared/ReactDOM.js b/packages/react-dom/src/shared/ReactDOM.js index dc4c73316f6..062da7c019c 100644 --- a/packages/react-dom/src/shared/ReactDOM.js +++ b/packages/react-dom/src/shared/ReactDOM.js @@ -28,6 +28,7 @@ import { useFormStatus, useFormState, } from 'react-dom-bindings/src/shared/ReactDOMFormActions'; +import {browser} from './ReactDOMBrowser'; if (__DEV__) { if ( @@ -69,6 +70,7 @@ function createPortal( export { ReactVersion as version, + browser, createPortal, flushSync, batchedUpdates as unstable_batchedUpdates, diff --git a/packages/react-dom/src/shared/ReactDOMBrowser.js b/packages/react-dom/src/shared/ReactDOMBrowser.js new file mode 100644 index 00000000000..caabe635494 --- /dev/null +++ b/packages/react-dom/src/shared/ReactDOMBrowser.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactRecoverable} from 'shared/ReactTypes'; + +import {enableBrowserAPI} from 'shared/ReactFeatureFlags'; +import {REACT_RECOVERABLE_TYPE} from 'shared/ReactSymbols'; + +const browserImpl = function browser(): ReactRecoverable { + // Recoverables are Errors so that a renderer can preserve the browser() call + // site as the cause if no downstream renderer can recover the subtree. + const recoverable = new Error( + "Recoverable Exception: This is not a real error! It's an " + + 'implementation detail of `use(browser())` to defer rendering to the ' + + 'browser. `use(browser())` can only be used inside a `` ' + + 'boundary. If a server render errors with this as its cause, the ' + + 'component that called `use(browser())` does not have a `` ' + + 'boundary above it.', + ); + Object.defineProperty(recoverable as any, '$$typeof', { + value: REACT_RECOVERABLE_TYPE, + }); + return recoverable as any; +}; + +export const browser: (() => ReactRecoverable) | void = enableBrowserAPI + ? browserImpl + : undefined; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 29269e32924..29cb4f21baa 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -131,6 +131,7 @@ import { REACT_MEMO_TYPE, REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable'; import {setCurrentFiber} from './ReactCurrentFiber'; import {resolveTypeForHotReloading} from './ReactFiberHotReloading'; @@ -3001,25 +3002,29 @@ function updateDehydratedSuspenseComponent( ({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance)); } - let error: Error; - if (__DEV__ && message) { - // eslint-disable-next-line react-internal/prod-error-codes - error = new Error(message); - } else { - error = new Error( - 'The server could not finish this Suspense boundary, likely ' + - 'due to an error during server rendering. ' + - 'Switched to client rendering.', + // This is unreachable in renderers that do not support hydration. + // $FlowFixMe[invalid-compare] + if (digest !== REACT_RECOVERABLE_DIGEST) { + let error: Error; + if (__DEV__ && message) { + // eslint-disable-next-line react-internal/prod-error-codes + error = new Error(message); + } else { + error = new Error( + 'The server could not finish this Suspense boundary, likely ' + + 'due to an error during server rendering. ' + + 'Switched to client rendering.', + ); + } + // Replace the stack with the server stack + error.stack = (__DEV__ && stack) || ''; + (error as any).digest = digest; + const capturedValue = createCapturedValueFromError( + error, + componentStack === undefined ? null : componentStack, ); + queueHydrationError(capturedValue); } - // Replace the stack with the server stack - error.stack = (__DEV__ && stack) || ''; - (error as any).digest = digest; - const capturedValue = createCapturedValueFromError( - error, - componentStack === undefined ? null : componentStack, - ); - queueHydrationError(capturedValue); return retrySuspenseComponentWithoutHydrating( current, workInProgress, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index edb5eacc8cc..2558e9648b7 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -46,6 +46,7 @@ import { } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; @@ -1156,6 +1157,10 @@ function use(usable: Usable): T { // This is a thenable. const thenable: Thenable = usable as any; return useThenable(thenable); + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + // Fiber is the final renderer, so there is no downstream host that + // needs to recover this subtree. Continue rendering through it. + return undefined as any; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; return readContext(context); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 7f2e933c9f9..3a58b5688f9 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -14,6 +14,7 @@ import type { StartTransitionOptions, Thenable, Usable, + ReactRecoverable, ReactCustomFormAction, Awaited, } from 'shared/ReactTypes'; @@ -41,6 +42,7 @@ import {createFastHash} from './ReactServerStreamConfig'; import is from 'shared/objectIs'; import { REACT_CONTEXT_TYPE, + REACT_RECOVERABLE_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; @@ -89,6 +91,30 @@ let actionStateMatchingIndex: number = -1; // Counts the number of use(thenable) calls in this component let thenableIndexCounter: number = 0; let thenableState: ThenableState | null = null; +// An opaque exception that lets the Fizz work loop distinguish a recoverable +// from an Error thrown by application code. The actual errors are stored +// separately so this implementation detail cannot be mistaken for either +// diagnostic if it is caught by userspace. +export const RecoverableException: mixed = new Error( + "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.', +); +let suspendedRecoverableError: Error | null = null; + +export function createFatalRecoverableError( + recoverable: ReactRecoverable, +): Error { + return new Error( + 'The server render could not complete because client rendering was ' + + "requested outside a Suspense boundary. See this error's cause for " + + 'additional details.', + {cause: recoverable}, + ); +} + // Lazily created map of render-phase updates let renderPhaseUpdates: Map, Update> | null = null; // Counter to prevent infinite loops. @@ -276,6 +302,22 @@ export function getThenableStateAfterSuspending(): null | ThenableState { return state; } +export function getSuspendedRecoverableError(): Error { + if (suspendedRecoverableError === null) { + throw new Error( + 'Expected a suspended recoverable. This is a bug in React. Please file ' + + 'an issue.', + ); + } + const error = suspendedRecoverableError; + suspendedRecoverableError = null; + return error; +} + +export function clearSuspendedRecoverableError(): void { + suspendedRecoverableError = null; +} + export function checkDidRenderIdHook(): boolean { // This should be called immediately after every finishHooks call. // Conceptually, it's part of the return value of finishHooks; it's only a @@ -756,6 +798,15 @@ function use(usable: Usable): T { // This is a thenable. const thenable: Thenable = usable as any; return unwrapThenable(thenable); + } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) { + // Fizz can defer this subtree to a downstream renderer. Like a suspended + // thenable, keep the actual value out of userspace and throw an opaque + // sentinel to unwind the stack. Capture the use() call site eagerly so + // that if there is no Suspense boundary, the fatal error points here and + // its cause points to where the recoverable was created. + const recoverable: ReactRecoverable = usable as any; + suspendedRecoverableError = createFatalRecoverableError(recoverable); + throw RecoverableException; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext = usable as any; return readContext(context); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 9a03f202630..1af0265ea11 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -28,6 +28,7 @@ import type { SuspenseListProps, SuspenseListRevealOrder, ReactKey, + ReactRecoverable, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -134,6 +135,10 @@ import { readPreviousThenableFromState, getActionStateCount, getActionStateMatchingIndex, + RecoverableException, + createFatalRecoverableError, + getSuspendedRecoverableError, + clearSuspendedRecoverableError, } from './ReactFizzHooks'; import {DefaultAsyncDispatcher} from './ReactFizzAsyncDispatcher'; import { @@ -173,6 +178,7 @@ import { REACT_VIEW_TRANSITION_TYPE, REACT_ACTIVITY_TYPE, REACT_OPTIMISTIC_KEY, + REACT_RECOVERABLE_TYPE, } from 'shared/ReactSymbols'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -191,6 +197,7 @@ import assign from 'shared/assign'; import noop from 'shared/noop'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import isArray from 'shared/isArray'; +import {REACT_RECOVERABLE_DIGEST} from 'shared/ReactRecoverable'; import { SuspenseException, getSuspendedThenable, @@ -1317,6 +1324,14 @@ function encodeErrorForBoundary( ) { boundary.errorDigest = digest; if (__DEV__) { + if (error === RecoverableException) { + boundary.errorMessage = wasAborted + ? 'Switched to client rendering because the server render was aborted ' + + 'with a request to render on the client.' + : 'Switched to client rendering because a component requested it.'; + boundary.errorComponentStack = thrownInfo.componentStack; + return; + } let message, stack; // In dev we additionally encode the error message and component stack on the boundary if (error instanceof Error) { @@ -1347,6 +1362,11 @@ function logRecoverableError( errorInfo: ThrownInfo, debugTask: null | ConsoleTask, ): ?string { + if (error === RecoverableException) { + clearSuspendedRecoverableError(); + return REACT_RECOVERABLE_DIGEST; + } + // If this callback errors, we intentionally let that error bubble up to become a fatal error // so that someone fixes the error reporting instead of hiding it. const onError = request.onError; @@ -1365,7 +1385,10 @@ function logRecoverableError( } return; } - return errorDigest; + // An empty digest is reserved for React's internal client-render signal. + // Historically an empty digest was omitted from the wire format, so + // normalizing it to undefined preserves the existing user-space semantics. + return errorDigest === '' ? undefined : errorDigest; } function fatalError( @@ -4524,19 +4547,34 @@ function erroredTask( request.allPendingTasks--; - // Report the error to a global handler. // We don't handle halts here because we only halt when prerendering and // when prerendering we should be finishing tasks not erroring them when // they halt or postpone - const errorDigest = logRecoverableError(request, error, errorInfo, debugTask); if (boundary === null) { - fatalError(request, error, errorInfo, debugTask); + // Recoverables can remain silent when a Suspense boundary lets us emit a + // shell and defer its content to a downstream renderer. At the root there + // is no shell to stream, so this is a fatal error and must be reported like + // any other root error. + if (error === RecoverableException) { + const useError = getSuspendedRecoverableError(); + logRecoverableError(request, useError, errorInfo, debugTask); + fatalError(request, useError, errorInfo, debugTask); + } else { + logRecoverableError(request, error, errorInfo, debugTask); + fatalError(request, error, errorInfo, debugTask); + } // The shell fatally errored, so the render can never complete. Return before // the completeAll check below so we don't fire onAllReady for a render that // produced nothing. This mirrors finishAbortedTask, which also returns after // a fatalError on the root. return; } else { + const errorDigest = logRecoverableError( + request, + error, + errorInfo, + debugTask, + ); boundary.pendingTasks--; if (boundary.status !== CLIENT_RENDERED) { boundary.status = CLIENT_RENDERED; @@ -4769,19 +4807,43 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { } const errorInfo = getThrownInfo(task.componentStack); + // Only abort reasons get this interpretation. Throwing a recoverable + // directly is still an application error; it must be passed to use() or + // abort() for a renderer to recover it. + const isRecoverableAbort = + typeof error === 'object' && + error !== null && + // $FlowFixMe[prop-missing] + error.$$typeof === REACT_RECOVERABLE_TYPE; if (boundary === null) { const replay: null | ReplaySet = task.replay; if (replay === null) { // We didn't complete the root so we have nothing to show. We can close // the request; - if (request.trackedPostpones !== null && segment !== null) { + if ( + !isRecoverableAbort && + request.trackedPostpones !== null && + segment !== null + ) { const trackedPostpones = request.trackedPostpones; // We are aborting a prerender and must treat the shell as halted // We log the error but we still resolve the prerender logRecoverableError(request, error, errorInfo, task.debugTask); trackPostpone(request, trackedPostpones, task, segment); finishedTask(request, null, task.row, segment); + } else if (isRecoverableAbort) { + const recoverable: ReactRecoverable = error as any; + const fatalRecoverableError = createFatalRecoverableError(recoverable); + logRecoverableError( + request, + fatalRecoverableError, + errorInfo, + task.debugTask, + ); + if (request.status !== CLOSING && request.status !== CLOSED) { + fatalError(request, fatalRecoverableError, errorInfo, task.debugTask); + } } else { logRecoverableError(request, error, errorInfo, task.debugTask); if (request.status !== CLOSING && request.status !== CLOSED) { @@ -4796,18 +4858,21 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { // the ReplaySet. replay.pendingTasks--; if (replay.pendingTasks === 0 && replay.nodes.length > 0) { - const errorDigest = logRecoverableError( - request, - error, - errorInfo, - null, - ); + let errorDigest; + let errorForBoundary; + if (isRecoverableAbort) { + errorDigest = REACT_RECOVERABLE_DIGEST; + errorForBoundary = RecoverableException; + } else { + errorDigest = logRecoverableError(request, error, errorInfo, null); + errorForBoundary = error; + } abortRemainingReplayNodes( request, null, replay.nodes, replay.slots, - error, + errorForBoundary, errorDigest, errorInfo, true, @@ -4823,7 +4888,11 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { // boundary the message is referring to const trackedPostpones = request.trackedPostpones; if (boundary.status !== CLIENT_RENDERED) { - if (trackedPostpones !== null && segment !== null) { + if ( + !isRecoverableAbort && + trackedPostpones !== null && + segment !== null + ) { // We are aborting a prerender and must halt this boundary. // We treat this like other postpones during prerendering logRecoverableError(request, error, errorInfo, task.debugTask); @@ -4839,14 +4908,19 @@ function finishAbortedTask(task: Task, request: Request, error: mixed): void { boundary.status = CLIENT_RENDERED; // We are aborting a render or resume which should put boundaries // into an explicitly client rendered state - const errorDigest = logRecoverableError( - request, - error, + const errorDigest = isRecoverableAbort + ? REACT_RECOVERABLE_DIGEST + : logRecoverableError(request, error, errorInfo, task.debugTask); + const errorForBoundary = isRecoverableAbort + ? RecoverableException + : error; + encodeErrorForBoundary( + boundary, + errorDigest, + errorForBoundary, errorInfo, - task.debugTask, + true, ); - boundary.status = CLIENT_RENDERED; - encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true); untrackBoundary(request, boundary); diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 23d15618a88..20ba7ab8489 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -151,7 +151,11 @@ function use(usable: Usable): T { } if (isClientReference(usable)) { - if (usable.value != null && usable.value.$$typeof === REACT_CONTEXT_TYPE) { + const clientReference: any = usable; + if ( + clientReference.value != null && + clientReference.value.$$typeof === REACT_CONTEXT_TYPE + ) { // Show a more specific message since it's a common mistake. throw new Error('Cannot read a Client Context from a Server Component.'); } else { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index e596bcb0cb4..45ec1737611 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -22,6 +22,9 @@ // when it rolls out to prod. We should remove these as soon as possible. // ----------------------------------------------------------------------------- +// Enables the browser() API exported from react-dom. +export const enableBrowserAPI: boolean = true; + // ----------------------------------------------------------------------------- // Land or remove (moderate effort) // diff --git a/packages/shared/ReactRecoverable.js b/packages/shared/ReactRecoverable.js new file mode 100644 index 00000000000..428a263e4ab --- /dev/null +++ b/packages/shared/ReactRecoverable.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Empty digests are otherwise treated as if no digest was provided. This lets +// React distinguish an intentional client render without reserving a +// user-space digest value. +export const REACT_RECOVERABLE_DIGEST = ''; diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index 72d8bc020e2..a4c87538a38 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -46,6 +46,8 @@ export const REACT_VIEW_TRANSITION_TYPE: symbol = Symbol.for( 'react.view_transition', ); +export const REACT_RECOVERABLE_TYPE: symbol = Symbol.for('react.recoverable'); + const MAYBE_ITERATOR_SYMBOL = Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 22f6d42421f..71487a69ebe 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -144,11 +144,19 @@ export type Thenable = | FulfilledThenable | RejectedThenable; +// A recoverable lets an intermediate renderer defer a subtree to a downstream +// renderer. It does not produce a value: a renderer either continues through +// it or interrupts the current render so that a later renderer can recover the +// subtree. +export type ReactRecoverable = Error & { + $$typeof: symbol, +}; + export type StartTransitionOptions = { name?: string, }; -export type Usable = Thenable | ReactContext; +export type Usable = Thenable | ReactContext | ReactRecoverable; export type ReactCustomFormAction = { name?: string, diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index df30e0985eb..9b5d9497f85 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -42,6 +42,7 @@ export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; export const enableCPUSuspense: boolean = true; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableEffectEventMutationPhase: boolean = true; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3a4018e3ad6..30ace506bc3 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -28,6 +28,7 @@ export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; export const enableCPUSuspense: boolean = false; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index d2c644edd1b..bbd4530b022 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const enableScopeAPI: boolean = false; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableSuspenseCallback: boolean = false; export const enableTrustedTypesIntegration: boolean = true; export const disableTextareaChildren: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 15a0c6dded7..dd45f875a28 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -23,6 +23,7 @@ export const enableAsyncDebugInfo = true; export const enableAsyncIterableChildren = false; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; +export const enableBrowserAPI = true; export const enableMoveBefore = false; export const enableFizzExternalRuntime = true; export const enableInfiniteRenderLoopDetection = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5a4b5650189..c7f9c591d69 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -25,6 +25,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const enableScopeAPI: boolean = true; export const enableCreateEventHandleAPI: boolean = false; +export const enableBrowserAPI: boolean = true; export const enableSuspenseCallback: boolean = true; export const disableLegacyContext: boolean = false; export const disableLegacyContextForFunctionComponents: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 3bb77341f0a..a600292c965 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -80,6 +80,8 @@ export const disableCommentsAsDOMContainers: boolean = false; export const enableCreateEventHandleAPI: boolean = true; +export const enableBrowserAPI: boolean = true; + export const enableEffectEventMutationPhase: boolean = true; export const enableScopeAPI: boolean = true; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c5be606813f..fb7e296452c 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -587,5 +587,9 @@ "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.", "600": "A rejected Promise was passed to React without a `reason` property. React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", "601": "A chunk pair is incomplete. This is a bug in React.", - "602": "Cannot handle action key. This is a bug in React." + "602": "Cannot handle action key. This is a bug in React.", + "603": "Recoverable Exception: This is not a real error! It's an implementation detail of `use(browser())` to defer rendering to the browser. `use(browser())` can only be used inside a `` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `` boundary above it.", + "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", + "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", + "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue." }