diff --git a/.eslintrc.json b/.eslintrc.json index 1e23c68be640..8f9646ddbfe2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -168,6 +168,7 @@ "files": ["packages/**"], "excludedFiles": [ "packages/next/taskfile*.js", + "packages/next/next-devtools.webpack-config.js", "packages/next/next-runtime.webpack-config.js" ], "rules": { diff --git a/packages/next/errors.json b/packages/next/errors.json index a7c92e9ae2fc..6619203c94ce 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -693,5 +693,7 @@ "692": "Expected clientReferenceManifest to be defined.", "693": "%s must not be used within a client component. Next.js should be preventing %s from being included in client components statically, but did not in this case.", "694": "createPrerenderPathname was called inside a client component scope.", - "695": "Expected workUnitAsyncStorage to have a store." + "695": "Expected workUnitAsyncStorage to have a store.", + "696": "Next DevTools: Can't dispatch %s in this environment. This is a bug in Next.js", + "697": "Next DevTools: Can't render in this environment. This is a bug in Next.js" } diff --git a/packages/next/next-devtools.webpack-config.js b/packages/next/next-devtools.webpack-config.js new file mode 100644 index 000000000000..e027e1cfe3dc --- /dev/null +++ b/packages/next/next-devtools.webpack-config.js @@ -0,0 +1,122 @@ +const path = require('path') +const webpack = require('@rspack/core') +const MODERN_BROWSERSLIST_TARGET = require('./src/shared/lib/modern-browserslist-target') +const DevToolsIgnoreListPlugin = require('./webpack-plugins/devtools-ignore-list-plugin') + +function shouldIgnorePath(modulePath) { + // For consumers, everything will be considered 3rd party dependency if they use + // the bundles we produce here. + // In other words, this is all library code and should therefore be ignored. + return true +} + +/** + * @param {Object} options + * @param {boolean} options.dev + * @param {Partial} options.rest + * @returns {webpack.Configuration} + */ +module.exports = ({ dev, ...rest }) => { + const experimental = false + const turbo = false + + const bundledReactChannel = experimental ? '-experimental' : '' + + const target = `browserslist:${MODERN_BROWSERSLIST_TARGET.join(', ')}` + + return { + entry: path.join( + __dirname, + 'src/client/components/react-dev-overlay/app/entrypoint.js' + ), + target, + mode: dev ? 'development' : 'production', + output: { + path: path.join(__dirname, 'dist/compiled/next-devtools'), + filename: `index.js`, + iife: false, + library: { + type: 'commonjs-static', + }, + }, + devtool: 'source-map', + optimization: { + moduleIds: 'named', + minimize: true, + concatenateModules: true, + minimizer: [ + new webpack.SwcJsMinimizerRspackPlugin({ + minimizerOptions: { + mangle: dev || process.env.NEXT_SERVER_NO_MANGLE ? false : true, + }, + }), + ], + }, + plugins: [ + // TODO: React Compiler + new DevToolsIgnoreListPlugin({ shouldIgnorePath }), + new webpack.DefinePlugin({ + // TODO: Hardcode it and ensure module resolution resolves to .node entrypoint in Node.js + // 'typeof window': JSON.stringify('object'), + 'process.env.NEXT_MINIMAL': JSON.stringify('true'), + 'this.serverOptions.experimentalTestProxy': JSON.stringify(false), + 'this.minimalMode': JSON.stringify(true), + 'this.renderOpts.dev': JSON.stringify(dev), + 'renderOpts.dev': JSON.stringify(dev), + 'process.env.NODE_ENV': JSON.stringify( + dev ? 'development' : 'production' + ), + 'process.env.__NEXT_EXPERIMENTAL_REACT': JSON.stringify( + experimental ? true : false + ), + 'process.env.NEXT_RUNTIME': JSON.stringify('nodejs'), + 'process.turbopack': JSON.stringify(turbo), + 'process.env.TURBOPACK': JSON.stringify(turbo), + }), + ].filter(Boolean), + stats: { + optimizationBailout: true, + }, + 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}`, + scheduler$: `next/dist/compiled/scheduler${bundledReactChannel}`, + }, + extensions: ['.ts', '.tsx', '.js', '.json'], + }, + module: { + rules: [ + { test: /\.m?js$/, loader: `source-map-loader`, enforce: `pre` }, + { + test: /\.(ts|tsx)$/, + exclude: [/node_modules/], + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + development: dev, + runtime: 'automatic', + // TODO: Fast Refresh + // refresh: dev, + }, + }, + }, + }, + type: 'javascript/auto', + }, + ], + }, + externals: [], + experiments: {}, + ...rest, + } +} diff --git a/packages/next/next-runtime.webpack-config.js b/packages/next/next-runtime.webpack-config.js index 372a63330fc9..c0f4a0fa229a 100644 --- a/packages/next/next-runtime.webpack-config.js +++ b/packages/next/next-runtime.webpack-config.js @@ -92,6 +92,8 @@ const sharedExternals = [ const externalsMap = { './web/sandbox': 'next/dist/server/web/sandbox', + 'next/dist/compiled/next-devtools': + 'commonjs next/dist/client/components/react-dev-overlay/app/app-dev-overlay.shim.js', } const externalsRegexMap = { diff --git a/packages/next/src/client/app-next-dev.ts b/packages/next/src/client/app-next-dev.ts index 5827f1efabcd..5867f598ca98 100644 --- a/packages/next/src/client/app-next-dev.ts +++ b/packages/next/src/client/app-next-dev.ts @@ -2,12 +2,22 @@ import './app-webpack' +import { renderAppDevOverlay } from 'next/dist/compiled/next-devtools' import { appBootstrap } from './app-bootstrap' +import { + getComponentStack, + getOwnerStack, +} from './components/errors/stitched-error' +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..1999c190f9ce 100644 --- a/packages/next/src/client/app-next-turbopack.ts +++ b/packages/next/src/client/app-next-turbopack.ts @@ -1,6 +1,9 @@ -// TODO-APP: hydration warning - import { appBootstrap } from './app-bootstrap' +import { + getComponentStack, + getOwnerStack, +} from './components/errors/stitched-error' +import { isRecoverableError } from './react-client-callbacks/on-recoverable-error' window.next.version += '-turbo' ;(self as any).__webpack_hash__ = '' @@ -10,5 +13,13 @@ const instrumentationHooks = require('../lib/require-instrumentation-client') appBootstrap(() => { const { hydrate } = require('./app-index') as typeof import('./app-index') - hydrate(instrumentationHooks) + try { + hydrate(instrumentationHooks) + } finally { + if (process.env.NODE_ENV !== 'production') { + const { renderAppDevOverlay } = + require('next/dist/compiled/next-devtools') as typeof import('next/dist/compiled/next-devtools') + 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 ce005e6a2262..362e62ed50c3 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,4 +1,5 @@ import { PureComponent } from 'react' +import { dispatcher } from 'next/dist/compiled/next-devtools' import { RuntimeErrorHandler } from '../../errors/runtime-error-handler' import { ErrorBoundary } from '../../error-boundary' import DefaultGlobalError, { @@ -8,7 +9,6 @@ import DefaultGlobalError, { 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.browser.tsx b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.browser.tsx new file mode 100644 index 000000000000..4bcab0aff335 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/app/app-dev-overlay.browser.tsx @@ -0,0 +1,238 @@ +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, + 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 { 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 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 +} + +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, + getOwnerStack, + isRecoverableError + ) + + useInsertionEffect(() => { + maybeDispatch = 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 ( + <> + {/* Fonts can only be loaded outside the Shadow DOM. */} + + + + ) +} + +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 `