From 279888bd024bb171acc1db2596e7e24f2179ca59 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 30 May 2025 16:02:37 +0200 Subject: [PATCH 1/6] [dev-overlay] Move render indicator into Dev Overlay state Same as for build indicator. The sync update avoided is especially nice since it used to happen during navigation where we don't want to interfere with the update priority. --- .../react-dev-overlay/app/app-dev-overlay.tsx | 9 ++++- .../components/react-dev-overlay/shared.ts | 19 ++++++++++ .../dev-tools-indicator.stories.tsx | 1 + .../dev-tools-indicator.tsx | 6 ++- .../ui/dev-overlay.stories.tsx | 1 + .../dev-indicator/dev-render-indicator.tsx | 37 ------------------- .../use-app-dev-rendering-indicator.tsx | 24 ++++++++++++ .../use-sync-dev-render-indicator.tsx | 16 -------- .../src/client/components/use-action-queue.ts | 9 ++--- 9 files changed, 60 insertions(+), 62 deletions(-) delete mode 100644 packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/dev-render-indicator.tsx create mode 100644 packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx delete mode 100644 packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator.tsx diff --git a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx index cd283fc724d9..87e0b3afe2f8 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx @@ -6,6 +6,7 @@ import { import type { GlobalErrorComponent } from '../../global-error' import { useCallback } from 'react' +import { createContext } from 'react' import { AppDevOverlayErrorBoundary } from './app-dev-overlay-error-boundary' import { FontStyles } from '../font/font-styles' import { DevOverlay } from '../ui/dev-overlay' @@ -15,6 +16,10 @@ function getSquashedHydrationErrorDetails() { return null } +export const AppDevOverlayDispatchContext = + createContext(null) +AppDevOverlayDispatchContext.displayName = 'AppDevOverlayDispatchContext' + export function AppDevOverlay({ state, dispatch, @@ -31,7 +36,7 @@ export function AppDevOverlay({ }, [dispatch]) return ( - <> + - + ) } diff --git a/packages/next/src/client/components/react-dev-overlay/shared.ts b/packages/next/src/client/components/react-dev-overlay/shared.ts index 486ea269b7db..11ee5a60164c 100644 --- a/packages/next/src/client/components/react-dev-overlay/shared.ts +++ b/packages/next/src/client/components/react-dev-overlay/shared.ts @@ -23,6 +23,7 @@ export interface OverlayState { versionInfo: VersionInfo notFound: boolean buildingIndicator: boolean + renderingIndicator: boolean staticIndicator: boolean showIndicator: boolean disableDevIndicator: boolean @@ -47,6 +48,8 @@ export const ACTION_ERROR_OVERLAY_CLOSE = 'error-overlay-close' export const ACTION_ERROR_OVERLAY_TOGGLE = 'error-overlay-toggle' export const ACTION_BUILDING_INDICATOR_SHOW = 'building-indicator-show' export const ACTION_BUILDING_INDICATOR_HIDE = 'building-indicator-hide' +export const ACTION_RENDERING_INDICATOR_SHOW = 'rendering-indicator-show' +export const ACTION_RENDERING_INDICATOR_HIDE = 'rendering-indicator-hide' export const STORAGE_KEY_THEME = '__nextjs-dev-tools-theme' export const STORAGE_KEY_POSITION = '__nextjs-dev-tools-position' @@ -112,6 +115,13 @@ export interface BuildingIndicatorHideAction { type: typeof ACTION_BUILDING_INDICATOR_HIDE } +export interface RenderingIndicatorShowAction { + type: typeof ACTION_RENDERING_INDICATOR_SHOW +} +export interface RenderingIndicatorHideAction { + type: typeof ACTION_RENDERING_INDICATOR_HIDE +} + export type BusEvent = | BuildOkAction | BuildErrorAction @@ -128,6 +138,8 @@ export type BusEvent = | ErrorOverlayToggleAction | BuildingIndicatorShowAction | BuildingIndicatorHideAction + | RenderingIndicatorShowAction + | RenderingIndicatorHideAction const REACT_ERROR_STACK_BOTTOM_FRAME_REGEX = // 1st group: v8 @@ -153,6 +165,7 @@ export const INITIAL_OVERLAY_STATE: Omit< buildError: null, errors: [], notFound: false, + renderingIndicator: false, staticIndicator: false, /* This is set to `true` when we can reliably know @@ -315,6 +328,12 @@ export function useErrorOverlayReducer( case ACTION_BUILDING_INDICATOR_HIDE: { return { ...state, buildingIndicator: false } } + case ACTION_RENDERING_INDICATOR_SHOW: { + return { ...state, renderingIndicator: true } + } + case ACTION_RENDERING_INDICATOR_HIDE: { + return { ...state, renderingIndicator: false } + } default: { return state } diff --git a/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx b/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx index 2db3adb04dfd..50ed035aba30 100644 --- a/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx +++ b/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx @@ -55,6 +55,7 @@ const state: OverlayState = { versionInfo: mockVersionInfo, notFound: false, buildingIndicator: false, + renderingIndicator: false, staticIndicator: true, debugInfo: { devtoolsFrontendUrl: undefined }, isErrorOverlayOpen: false, diff --git a/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx index ab0a4d31868c..01b2ee4a91e5 100644 --- a/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx +++ b/packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx @@ -10,7 +10,6 @@ import { import { useState, useEffect, useRef, createContext, useContext } from 'react' import { Toast } from '../../toast' import { NextLogo } from './next-logo' -import { useIsDevRendering } from '../../../../utils/dev-indicator/dev-render-indicator' import { useDelayedRender } from '../../../hooks/use-delayed-render' import { TurbopackInfo } from './dev-tools-info/turbopack-info' import { RouteInfo } from './dev-tools-info/route-info' @@ -54,6 +53,7 @@ export function DevToolsIndicator({ semver={state.versionInfo.installed} issueCount={errorCount} isDevBuilding={state.buildingIndicator} + isDevRendering={state.renderingIndicator} isStaticRoute={state.staticIndicator} hide={() => { setIsDevToolsIndicatorVisible(false) @@ -96,6 +96,7 @@ function DevToolsPopover({ disabled, issueCount, isDevBuilding, + isDevRendering, isStaticRoute, isTurbopack, isBuildError, @@ -110,6 +111,7 @@ function DevToolsPopover({ isStaticRoute: boolean semver: string | undefined isDevBuilding: boolean + isDevRendering: boolean isTurbopack: boolean isBuildError: boolean hide: () => void @@ -297,7 +299,7 @@ function DevToolsPopover({ onTriggerClick={onTriggerClick} toggleErrorOverlay={toggleErrorOverlay} isDevBuilding={isDevBuilding} - isDevRendering={useIsDevRendering()} + isDevRendering={isDevRendering} isBuildError={isBuildError} scale={scale} /> diff --git a/packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.stories.tsx b/packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.stories.tsx index d338cfa76539..13f8b2167b10 100644 --- a/packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.stories.tsx +++ b/packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.stories.tsx @@ -74,6 +74,7 @@ const initialState: OverlayState = { refreshState: { type: 'idle' }, notFound: false, buildingIndicator: false, + renderingIndicator: false, staticIndicator: false, debugInfo: { devtoolsFrontendUrl: undefined }, versionInfo: { diff --git a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/dev-render-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/dev-render-indicator.tsx deleted file mode 100644 index 61e2a4c2151f..000000000000 --- a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/dev-render-indicator.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Singleton store to track whether the app is currently being rendered - * Used by the dev tools indicator to show render status - */ - -import { useSyncExternalStore } from 'react' - -let isVisible = false -let listeners: Array<() => void> = [] - -const subscribe = (listener: () => void) => { - listeners.push(listener) - return () => { - listeners = listeners.filter((l) => l !== listener) - } -} - -const getSnapshot = () => isVisible - -const show = () => { - isVisible = true - listeners.forEach((listener) => listener()) -} - -const hide = () => { - isVisible = false - listeners.forEach((listener) => listener()) -} - -export function useIsDevRendering() { - return useSyncExternalStore(subscribe, getSnapshot) -} - -export const devRenderIndicator = { - show, - hide, -} diff --git a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx new file mode 100644 index 000000000000..4eabdda8a7dd --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx @@ -0,0 +1,24 @@ +import { useContext, useEffect, useTransition } from 'react' +import { AppDevOverlayDispatchContext } from '../../app/app-dev-overlay' +import { + ACTION_RENDERING_INDICATOR_HIDE, + ACTION_RENDERING_INDICATOR_SHOW, +} from '../../shared' + +export const useAppDevRenderingIndicator = () => { + const dispatch = useContext(AppDevOverlayDispatchContext) + const [isPending, startTransition] = useTransition() + + useEffect(() => { + // Only supported in App Router + if (dispatch !== null) { + if (isPending) { + dispatch({ type: ACTION_RENDERING_INDICATOR_SHOW }) + } else { + dispatch({ type: ACTION_RENDERING_INDICATOR_HIDE }) + } + } + }, [dispatch, isPending]) + + return startTransition +} diff --git a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator.tsx deleted file mode 100644 index 354bdf93f076..000000000000 --- a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect, useTransition } from 'react' -import { devRenderIndicator } from './dev-render-indicator' - -export const useSyncDevRenderIndicator = () => { - const [isPending, startTransition] = useTransition() - - useEffect(() => { - if (isPending) { - devRenderIndicator.show() - } else { - devRenderIndicator.hide() - } - }, [isPending]) - - return startTransition -} diff --git a/packages/next/src/client/components/use-action-queue.ts b/packages/next/src/client/components/use-action-queue.ts index 8abda0b85330..fbb2d2469ba6 100644 --- a/packages/next/src/client/components/use-action-queue.ts +++ b/packages/next/src/client/components/use-action-queue.ts @@ -35,14 +35,13 @@ export function useActionQueue( // this is conceptually how we're modeling the app router state, despite the // weird implementation details. if (process.env.NODE_ENV !== 'production') { - const useSyncDevRenderIndicator = ( - require('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator') as typeof import('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator') - ).useSyncDevRenderIndicator + const { useAppDevRenderingIndicator } = + require('./react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator') as typeof import('./react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator') // eslint-disable-next-line react-hooks/rules-of-hooks - const syncDevRenderIndicator = useSyncDevRenderIndicator() + const appDevRenderingIndicator = useAppDevRenderingIndicator() dispatch = (action: ReducerActions) => { - syncDevRenderIndicator(() => { + appDevRenderingIndicator(() => { actionQueue.dispatch(action, setState) }) } From b36ce5cf312331504b54604e1201e242e69332b7 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Tue, 27 May 2025 16:03:53 +0200 Subject: [PATCH 2/6] [dev-overlay] Render Dev Overlay in a leaf in /app Ensures Dev Overlay re-render will never trigger user code re-render. --- .../app/app-dev-overlay-error-boundary.tsx | 4 +- .../react-dev-overlay/app/app-dev-overlay.tsx | 206 ++++++++++++++---- .../react-dev-overlay/app/client-entry.tsx | 7 +- .../app/hot-reloader-client.tsx | 128 ++--------- .../use-app-dev-rendering-indicator.tsx | 22 +- 5 files changed, 190 insertions(+), 177 deletions(-) diff --git a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx index ce005e6a2262..66d26bde4b8c 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx @@ -4,11 +4,11 @@ import { ErrorBoundary } from '../../error-boundary' import DefaultGlobalError, { type GlobalErrorComponent, } from '../../global-error' +import { dispatcher } from './app-dev-overlay' type AppDevOverlayErrorBoundaryProps = { children: React.ReactNode globalError: [GlobalErrorComponent, React.ReactNode] - onError: () => void } type AppDevOverlayErrorBoundaryState = { @@ -53,7 +53,7 @@ export class AppDevOverlayErrorBoundary extends PureComponent< } componentDidCatch() { - this.props.onError() + dispatcher.openErrorOverlay() } render() { diff --git a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx index 87e0b3afe2f8..294b849e473f 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx @@ -1,57 +1,181 @@ import { + ACTION_BEFORE_REFRESH, + ACTION_BUILD_ERROR, + ACTION_BUILD_OK, + ACTION_DEBUG_INFO, + ACTION_DEV_INDICATOR, + ACTION_REFRESH, + ACTION_ERROR_OVERLAY_CLOSE, ACTION_ERROR_OVERLAY_OPEN, - type OverlayDispatch, - type OverlayState, + ACTION_ERROR_OVERLAY_TOGGLE, + ACTION_STATIC_INDICATOR, + ACTION_UNHANDLED_ERROR, + ACTION_UNHANDLED_REJECTION, + ACTION_VERSION_INFO, + useErrorOverlayReducer, + ACTION_BUILDING_INDICATOR_HIDE, + ACTION_BUILDING_INDICATOR_SHOW, + ACTION_RENDERING_INDICATOR_HIDE, + ACTION_RENDERING_INDICATOR_SHOW, } from '../shared' -import type { GlobalErrorComponent } from '../../global-error' -import { useCallback } from 'react' -import { createContext } from 'react' -import { AppDevOverlayErrorBoundary } from './app-dev-overlay-error-boundary' +import { useInsertionEffect } from 'react' import { FontStyles } from '../font/font-styles' +import type { DebugInfo } from '../types' import { DevOverlay } from '../ui/dev-overlay' +import { getComponentStack, getOwnerStack } from '../../errors/stitched-error' +import { isRecoverableError } from '../../../react-client-callbacks/on-recoverable-error' +import type { DevIndicatorServerState } from '../../../../server/dev/dev-indicator-server-state' +import type { VersionInfo } from '../../../../server/dev/parse-version-info' + +export interface Dispatcher { + onBuildOk(): void + onBuildError(message: string): void + onVersionInfo(versionInfo: VersionInfo): void + onDebugInfo(debugInfo: DebugInfo): void + onBeforeRefresh(): void + onRefresh(): void + onStaticIndicator(status: boolean): void + onDevIndicator(devIndicator: DevIndicatorServerState): void + onUnhandledError(reason: Error): void + onUnhandledRejection(reason: Error): void + openErrorOverlay(): void + closeErrorOverlay(): void + toggleErrorOverlay(): void + buildingIndicatorHide(): void + buildingIndicatorShow(): void + renderingIndicatorHide(): void + renderingIndicatorShow(): void +} + +type Dispatch = ReturnType[1] +let maybeDispatch: Dispatch | null = null +const queue: Array<(dispatch: Dispatch) => void> = [] + +// Events might be dispatched before we get a `dispatch` from React (e.g. console.error during module eval). +// We need to queue them until we have a `dispatch` function available. +function createQueuable( + queueableFunction: (dispatch: Dispatch, ...args: Args) => void +) { + return (...args: Args) => { + if (maybeDispatch) { + queueableFunction(maybeDispatch, ...args) + } else { + queue.push((dispatch: Dispatch) => { + queueableFunction(dispatch, ...args) + }) + } + } +} + +// TODO: Extract into separate functions that are imported +export const dispatcher: Dispatcher = { + onBuildOk: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_BUILD_OK }) + }), + onBuildError: createQueuable((dispatch: Dispatch, message: string) => { + dispatch({ type: ACTION_BUILD_ERROR, message }) + }), + onBeforeRefresh: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_BEFORE_REFRESH }) + }), + onRefresh: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_REFRESH }) + }), + onVersionInfo: createQueuable( + (dispatch: Dispatch, versionInfo: VersionInfo) => { + dispatch({ type: ACTION_VERSION_INFO, versionInfo }) + } + ), + onStaticIndicator: createQueuable((dispatch: Dispatch, status: boolean) => { + dispatch({ type: ACTION_STATIC_INDICATOR, staticIndicator: status }) + }), + onDebugInfo: createQueuable((dispatch: Dispatch, debugInfo: DebugInfo) => { + dispatch({ type: ACTION_DEBUG_INFO, debugInfo }) + }), + onDevIndicator: createQueuable( + (dispatch: Dispatch, devIndicator: DevIndicatorServerState) => { + dispatch({ type: ACTION_DEV_INDICATOR, devIndicator }) + } + ), + onUnhandledError: createQueuable((dispatch: Dispatch, error: Error) => { + dispatch({ + type: ACTION_UNHANDLED_ERROR, + reason: error, + }) + }), + onUnhandledRejection: createQueuable((dispatch: Dispatch, error: Error) => { + dispatch({ + type: ACTION_UNHANDLED_REJECTION, + reason: error, + }) + }), + openErrorOverlay: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_ERROR_OVERLAY_OPEN }) + }), + closeErrorOverlay: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_ERROR_OVERLAY_CLOSE }) + }), + toggleErrorOverlay: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_ERROR_OVERLAY_TOGGLE }) + }), + buildingIndicatorHide: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_BUILDING_INDICATOR_HIDE }) + }), + buildingIndicatorShow: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_BUILDING_INDICATOR_SHOW }) + }), + renderingIndicatorHide: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_RENDERING_INDICATOR_HIDE }) + }), + renderingIndicatorShow: createQueuable((dispatch: Dispatch) => { + dispatch({ type: ACTION_RENDERING_INDICATOR_SHOW }) + }), +} + +function replayQueuedEvents(dispatch: NonNullable) { + try { + for (const queuedFunction of queue) { + queuedFunction(dispatch) + } + } finally { + // TODO: What to do with failed events? + queue.length = 0 + } +} function getSquashedHydrationErrorDetails() { // We don't squash hydration errors in the App Router. return null } -export const AppDevOverlayDispatchContext = - createContext(null) -AppDevOverlayDispatchContext.displayName = 'AppDevOverlayDispatchContext' - -export function AppDevOverlay({ - state, - dispatch, - globalError, - children, -}: { - state: OverlayState - dispatch: OverlayDispatch - globalError: [GlobalErrorComponent, React.ReactNode] - children: React.ReactNode -}) { - const openOverlay = useCallback(() => { - dispatch({ type: ACTION_ERROR_OVERLAY_OPEN }) - }, [dispatch]) +export function AppDevOverlay() { + const [state, dispatch] = useErrorOverlayReducer( + 'app', + getComponentStack, + getOwnerStack, + isRecoverableError + ) + + useInsertionEffect(() => { + maybeDispatch = dispatch + + replayQueuedEvents(dispatch) + + return () => { + maybeDispatch = null + } + }) return ( - - - {children} - - <> - {/* Fonts can only be loaded outside the Shadow DOM. */} - - - - + <> + {/* Fonts can only be loaded outside the Shadow DOM. */} + + + ) } diff --git a/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx b/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx index b14acd17b7a8..678edbfa897a 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/client-entry.tsx @@ -4,8 +4,6 @@ import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../../../server/dev/hot-reloader import GlobalError from '../../global-error' import { AppDevOverlayErrorBoundary } from './app-dev-overlay-error-boundary' -const noop = () => {} - // if an error is thrown while rendering an RSC stream, this will catch it in dev // and show the error overlay export function createRootLevelDevOverlayElement(reactEl: React.ReactElement) { @@ -31,10 +29,7 @@ export function createRootLevelDevOverlayElement(reactEl: React.ReactElement) { socket.addEventListener('message', handler) return ( - + {reactEl} ) diff --git a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx index d45b1a2e3d6b..14dd4f7519f2 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx @@ -1,30 +1,14 @@ /// import type { ReactNode } from 'react' -import { useEffect, startTransition, useMemo, useRef } from 'react' +import { useEffect, startTransition, useRef } from 'react' import stripAnsi from 'next/dist/compiled/strip-ansi' import formatWebpackMessages from '../utils/format-webpack-messages' import { useRouter } from '../../navigation' -import { - ACTION_BEFORE_REFRESH, - ACTION_BUILDING_INDICATOR_HIDE, - ACTION_BUILD_ERROR, - ACTION_BUILD_OK, - ACTION_BUILDING_INDICATOR_SHOW, - ACTION_DEBUG_INFO, - ACTION_DEV_INDICATOR, - ACTION_ERROR_OVERLAY_OPEN, - ACTION_REFRESH, - ACTION_STATIC_INDICATOR, - ACTION_UNHANDLED_ERROR, - ACTION_UNHANDLED_REJECTION, - ACTION_VERSION_INFO, - REACT_REFRESH_FULL_RELOAD, - reportInvalidHmrMessage, - useErrorOverlayReducer, -} from '../shared' -import { AppDevOverlay } from './app-dev-overlay' +import { REACT_REFRESH_FULL_RELOAD, reportInvalidHmrMessage } from '../shared' +import { AppDevOverlay, dispatcher } from './app-dev-overlay' import { ReplaySsrOnlyErrors } from './replay-ssr-only-errors' +import { AppDevOverlayErrorBoundary } from './app-dev-overlay-error-boundary' import { useErrorHandler } from '../../errors/use-error-handler' import { RuntimeErrorHandler } from '../../errors/runtime-error-handler' import { @@ -33,39 +17,20 @@ import { useWebsocket, useWebsocketPing, } from '../utils/use-websocket' -import type { VersionInfo } from '../../../../server/dev/parse-version-info' import { HMR_ACTIONS_SENT_TO_BROWSER } from '../../../../server/dev/hot-reloader-types' import type { HMR_ACTION_TYPES, TurbopackMsgToBrowser, } from '../../../../server/dev/hot-reloader-types' import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared' -import type { DebugInfo } from '../types' import { useUntrackedPathname } from '../../navigation-untracked' import type { GlobalErrorComponent } from '../../global-error' -import type { DevIndicatorServerState } from '../../../../server/dev/dev-indicator-server-state' import reportHmrLatency from '../utils/report-hmr-latency' import { TurbopackHmr } from '../utils/turbopack-hot-reloader-common' import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../app-router-headers' import { getComponentStack, getOwnerStack } from '../../errors/stitched-error' import { isRecoverableError } from '../../../react-client-callbacks/on-recoverable-error' -export interface Dispatcher { - onBuildOk(): void - onBuildError(message: string): void - onVersionInfo(versionInfo: VersionInfo): void - onDebugInfo(debugInfo: DebugInfo): void - onBeforeRefresh(): void - onRefresh(): void - onStaticIndicator(status: boolean): void - onDevIndicator(devIndicator: DevIndicatorServerState): void - onUnhandledError(error: Error): void - onUnhandledRejection(error: Error): void - openErrorOverlay(): void - buildingIndicatorHide(): void - buildingIndicatorShow(): void -} - let mostRecentCompilationHash: any = null let __nextDevClientId = Math.round(Math.random() * 100 + Date.now()) let reloading = false @@ -150,10 +115,7 @@ function performFullReload(err: any, sendMessage: any) { } // Attempt to update code on the fly, fall back to a hard reload. -function tryApplyUpdatesWebpack( - sendMessage: (message: string) => void, - dispatcher: Dispatcher -) { +function tryApplyUpdatesWebpack(sendMessage: (message: string) => void) { if (!isUpdateAvailable() || !canApplyUpdates()) { resolvePendingHotUpdateWebpack() dispatcher.onBuildOk() @@ -179,7 +141,7 @@ function tryApplyUpdatesWebpack( if (isUpdateAvailable()) { // While we were updating, there was a new update! Do it again. - tryApplyUpdatesWebpack(sendMessage, dispatcher) + tryApplyUpdatesWebpack(sendMessage) return } @@ -233,7 +195,6 @@ function processMessage( sendMessage: (message: string) => void, processTurbopackMessage: (msg: TurbopackMsgToBrowser) => void, router: ReturnType, - dispatcher: Dispatcher, appIsrManifestRef: ReturnType, pathnameRef: ReturnType ) { @@ -281,7 +242,7 @@ function processMessage( } dispatcher.onBuildOk() } else { - tryApplyUpdatesWebpack(sendMessage, dispatcher) + tryApplyUpdatesWebpack(sendMessage) } } @@ -491,66 +452,6 @@ export default function HotReload({ children: ReactNode globalError: [GlobalErrorComponent, React.ReactNode] }) { - const [state, dispatch] = useErrorOverlayReducer( - 'app', - getComponentStack, - getOwnerStack, - isRecoverableError - ) - - const dispatcher = useMemo(() => { - return { - onBuildOk() { - dispatch({ type: ACTION_BUILD_OK }) - }, - onBuildError(message) { - dispatch({ type: ACTION_BUILD_ERROR, message }) - }, - onBeforeRefresh() { - dispatch({ type: ACTION_BEFORE_REFRESH }) - }, - onRefresh() { - dispatch({ type: ACTION_REFRESH }) - }, - onVersionInfo(versionInfo) { - dispatch({ type: ACTION_VERSION_INFO, versionInfo }) - }, - onStaticIndicator(status: boolean) { - dispatch({ type: ACTION_STATIC_INDICATOR, staticIndicator: status }) - }, - onDebugInfo(debugInfo) { - dispatch({ type: ACTION_DEBUG_INFO, debugInfo }) - }, - onDevIndicator(devIndicator) { - dispatch({ - type: ACTION_DEV_INDICATOR, - devIndicator, - }) - }, - onUnhandledError(error) { - dispatch({ - type: ACTION_UNHANDLED_ERROR, - reason: error, - }) - }, - onUnhandledRejection(error) { - dispatch({ - type: ACTION_UNHANDLED_REJECTION, - reason: error, - }) - }, - openErrorOverlay() { - dispatch({ type: ACTION_ERROR_OVERLAY_OPEN }) - }, - buildingIndicatorHide() { - dispatch({ type: ACTION_BUILDING_INDICATOR_HIDE }) - }, - buildingIndicatorShow() { - dispatch({ type: ACTION_BUILDING_INDICATOR_SHOW }) - }, - } - }, [dispatch]) - useErrorHandler(dispatcher.onUnhandledError, dispatcher.onUnhandledRejection) const webSocketRef = useWebsocket(assetPrefix) @@ -599,7 +500,7 @@ export default function HotReload({ dispatcher.onStaticIndicator(false) } } - }, [pathname, dispatcher]) + }, [pathname]) } useEffect(() => { @@ -614,7 +515,6 @@ export default function HotReload({ sendMessage, processTurbopackMessage, router, - dispatcher, appIsrManifestRef, pathnameRef ) @@ -629,15 +529,17 @@ export default function HotReload({ sendMessage, router, webSocketRef, - dispatcher, processTurbopackMessage, appIsrManifestRef, ]) return ( - - - {children} - + <> + + + + {children} + + ) } diff --git a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx index 4eabdda8a7dd..e999f368e8e6 100644 --- a/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx +++ b/packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-app-dev-rendering-indicator.tsx @@ -1,24 +1,16 @@ -import { useContext, useEffect, useTransition } from 'react' -import { AppDevOverlayDispatchContext } from '../../app/app-dev-overlay' -import { - ACTION_RENDERING_INDICATOR_HIDE, - ACTION_RENDERING_INDICATOR_SHOW, -} from '../../shared' +import { useEffect, useTransition } from 'react' +import { dispatcher } from '../../app/app-dev-overlay' export const useAppDevRenderingIndicator = () => { - const dispatch = useContext(AppDevOverlayDispatchContext) const [isPending, startTransition] = useTransition() useEffect(() => { - // Only supported in App Router - if (dispatch !== null) { - if (isPending) { - dispatch({ type: ACTION_RENDERING_INDICATOR_SHOW }) - } else { - dispatch({ type: ACTION_RENDERING_INDICATOR_HIDE }) - } + if (isPending) { + dispatcher.renderingIndicatorShow() + } else { + dispatcher.renderingIndicatorHide() } - }, [dispatch, isPending]) + }, [isPending]) return startTransition } From 57f00ef68b3611a84d2d3c63145adc20f0146282 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Wed, 28 May 2025 15:05:12 +0200 Subject: [PATCH 3/6] Isolate React --- packages/next/next-runtime.webpack-config.js | 1 + packages/next/src/build/webpack-config.ts | 22 ++++++ packages/next/src/client/app-next-dev.ts | 12 +++- .../next/src/client/app-next-turbopack.ts | 12 +++- .../app/app-dev-overlay-error-boundary.tsx | 2 +- .../react-dev-overlay/app/app-dev-overlay.tsx | 67 +++++++++++++++++-- .../app/hot-reloader-client.tsx | 15 ++--- .../pages/pages-dev-overlay.tsx | 7 ++ .../ui/components/shadow-portal.tsx | 17 ++--- .../use-app-dev-rendering-indicator.tsx | 2 +- packages/next/src/client/index.tsx | 4 +- .../acceptance-app/hydration-error.test.ts | 43 ++++++------ tsec-exemptions.json | 3 + 13 files changed, 154 insertions(+), 53 deletions(-) diff --git a/packages/next/next-runtime.webpack-config.js b/packages/next/next-runtime.webpack-config.js index 372a63330fc9..9cb1b7107817 100644 --- a/packages/next/next-runtime.webpack-config.js +++ b/packages/next/next-runtime.webpack-config.js @@ -254,6 +254,7 @@ module.exports = ({ dev, turbo, bundleType, experimental, ...rest }) => { module: { rules: [ { test: /\.m?js$/, loader: `source-map-loader`, enforce: `pre` }, + // TODO: Empty next-devtools-app layer. The runtime shouldn't use any NDT bridge APIs. { include: /[\\/]react-server\.node/, layer: 'react-server', diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 1df2386a7c14..9d8c66edd594 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1364,6 +1364,28 @@ export default async function getBaseWebpackConfig( }, module: { rules: [ + // Get isolated react-dom instance for NDT. + { + layer: 'nextjs-devtools-frontend', + test: [ + /next[\\/]dist[\\/]client[\\/]components[\\/]react-dev-overlay[\\/]app[\\/]app-dev-overlay\./, + ], + }, + { + issuerLayer: 'nextjs-devtools-frontend', + resolve: { + alias: { + // TODO: Get dedicated React version for NDT to uncouple development. + react: `next/dist/compiled/react${bundledReactChannel}`, + 'react-dom$': `next/dist/compiled/react-dom${bundledReactChannel}`, + 'react-dom/client$': `next/dist/compiled/react-dom${bundledReactChannel}/client`, + 'react-is$': `next/dist/compiled/react-is${bundledReactChannel}`, + // Required to avoid "switched to client rendering" warnings. + // But why is this needed? + scheduler$: `next/dist/compiled/scheduler${bundledReactChannel}`, + }, + }, + }, // Alias server-only and client-only to proper exports based on bundling layers { issuerLayer: { diff --git a/packages/next/src/client/app-next-dev.ts b/packages/next/src/client/app-next-dev.ts index 5827f1efabcd..358e1c04cc1e 100644 --- a/packages/next/src/client/app-next-dev.ts +++ b/packages/next/src/client/app-next-dev.ts @@ -3,11 +3,21 @@ import './app-webpack' import { appBootstrap } from './app-bootstrap' +import { + getComponentStack, + getOwnerStack, +} from './components/errors/stitched-error' +import { renderAppDevOverlay } from './components/react-dev-overlay/app/app-dev-overlay' with { 'turbopack-transition': 'nextjs-devtools' } +import { isRecoverableError } from './react-client-callbacks/on-recoverable-error' // eslint-disable-next-line @next/internal/typechecked-require const instrumentationHooks = require('../lib/require-instrumentation-client') appBootstrap(() => { const { hydrate } = require('./app-index') as typeof import('./app-index') - hydrate(instrumentationHooks) + try { + hydrate(instrumentationHooks) + } finally { + renderAppDevOverlay(getComponentStack, getOwnerStack, isRecoverableError) + } }) diff --git a/packages/next/src/client/app-next-turbopack.ts b/packages/next/src/client/app-next-turbopack.ts index 7e083ec07484..3b55318ef5a1 100644 --- a/packages/next/src/client/app-next-turbopack.ts +++ b/packages/next/src/client/app-next-turbopack.ts @@ -1,6 +1,12 @@ // TODO-APP: hydration warning import { appBootstrap } from './app-bootstrap' +import { + getComponentStack, + getOwnerStack, +} from './components/errors/stitched-error' +import { renderAppDevOverlay } from './components/react-dev-overlay/app/app-dev-overlay' with { 'turbopack-transition': 'nextjs-devtools' } +import { isRecoverableError } from './react-client-callbacks/on-recoverable-error' window.next.version += '-turbo' ;(self as any).__webpack_hash__ = '' @@ -10,5 +16,9 @@ const instrumentationHooks = require('../lib/require-instrumentation-client') appBootstrap(() => { const { hydrate } = require('./app-index') as typeof import('./app-index') - hydrate(instrumentationHooks) + try { + hydrate(instrumentationHooks) + } finally { + renderAppDevOverlay(getComponentStack, getOwnerStack, isRecoverableError) + } }) diff --git a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx index 66d26bde4b8c..bcc2dfb4deda 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay-error-boundary.tsx @@ -1,10 +1,10 @@ import { PureComponent } from 'react' +import { dispatcher } from './app-dev-overlay' with { 'turbopack-transition': 'nextjs-devtools' } import { RuntimeErrorHandler } from '../../errors/runtime-error-handler' import { ErrorBoundary } from '../../error-boundary' import DefaultGlobalError, { type GlobalErrorComponent, } from '../../global-error' -import { dispatcher } from './app-dev-overlay' type AppDevOverlayErrorBoundaryProps = { children: React.ReactNode diff --git a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx index 294b849e473f..f28c9dc234de 100644 --- a/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.tsx @@ -19,12 +19,11 @@ import { ACTION_RENDERING_INDICATOR_SHOW, } from '../shared' -import { useInsertionEffect } from 'react' +import { startTransition, useInsertionEffect } from 'react' +import { createRoot } from 'react-dom/client' import { FontStyles } from '../font/font-styles' import type { DebugInfo } from '../types' import { DevOverlay } from '../ui/dev-overlay' -import { getComponentStack, getOwnerStack } from '../../errors/stitched-error' -import { isRecoverableError } from '../../../react-client-callbacks/on-recoverable-error' import type { DevIndicatorServerState } from '../../../../server/dev/dev-indicator-server-state' import type { VersionInfo } from '../../../../server/dev/parse-version-info' @@ -149,7 +148,15 @@ function getSquashedHydrationErrorDetails() { return null } -export function AppDevOverlay() { +function AppDevOverlay({ + getComponentStack, + getOwnerStack, + isRecoverableError, +}: { + getComponentStack: (error: Error) => string | undefined + getOwnerStack: (error: Error) => string | null | undefined + isRecoverableError: (error: Error) => boolean +}) { const [state, dispatch] = useErrorOverlayReducer( 'app', getComponentStack, @@ -160,12 +167,18 @@ export function AppDevOverlay() { useInsertionEffect(() => { maybeDispatch = dispatch - replayQueuedEvents(dispatch) + // Can't schedule updates from useInsertionEffect, so we need to defer. + // Could move this into a passive Effect but we don't want replaying when + // we reconnect. + const replayTimeout = setTimeout(() => { + replayQueuedEvents(dispatch) + }) return () => { maybeDispatch = null + clearTimeout(replayTimeout) } - }) + }, []) return ( <> @@ -179,3 +192,45 @@ export function AppDevOverlay() { ) } + +let isMounted = false +export function renderAppDevOverlay( + getComponentStack: (error: Error) => string | undefined, + getOwnerStack: (error: Error) => string | null | undefined, + isRecoverableError: (error: Error) => boolean +): void { + if (!isMounted) { + // React 19 will not throw away `