diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index abdf69f321702..f1c1065048b3d 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -260,7 +260,7 @@ export function hydrate( const { createWebSocket } = require('./dev/hot-reloader/app/web-socket') as typeof import('./dev/hot-reloader/app/web-socket') - staticIndicatorState = { pathname: null, appIsrManifest: {} } + staticIndicatorState = { pathname: null, appIsrManifest: null } webSocket = createWebSocket(assetPrefix, staticIndicatorState) } diff --git a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx index 4030e4666db26..02051f1cee907 100644 --- a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx +++ b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx @@ -41,7 +41,7 @@ import { getOrCreateDebugChannelReadableWriterPair } from '../../debug-channel' export interface StaticIndicatorState { pathname: string | null - appIsrManifest: Record + appIsrManifest: Record | null } let mostRecentCompilationHash: any = null @@ -261,18 +261,18 @@ export function processMessage( if (process.env.__NEXT_DEV_INDICATOR) { staticIndicatorState.appIsrManifest = message.data - // handle initial status on receiving manifest - // navigation is handled in useEffect for pathname changes - // as we'll receive the updated manifest before usePathname - // triggers for new value - if ( - staticIndicatorState.pathname && - staticIndicatorState.pathname in message.data - ) { - dispatcher.onStaticIndicator(true) - } else { - dispatcher.onStaticIndicator(false) - } + // Handle the initial static indicator status on receiving the ISR + // manifest. Navigation is handled in an effect inside HotReload for + // pathname changes as we'll receive the updated manifest before + // usePathname triggers for a new value. + + const isStatic = staticIndicatorState.pathname + ? message.data[staticIndicatorState.pathname] + : undefined + + dispatcher.onStaticIndicator( + isStatic === undefined ? 'pending' : isStatic ? 'static' : 'dynamic' + ) } break } @@ -542,10 +542,14 @@ export default function HotReload({ staticIndicatorState.pathname = pathname - if (pathname && pathname in staticIndicatorState.appIsrManifest) { - dispatcher.onStaticIndicator(true) - } else { - dispatcher.onStaticIndicator(false) + if (staticIndicatorState.appIsrManifest) { + const isStatic = pathname + ? staticIndicatorState.appIsrManifest[pathname] + : undefined + + dispatcher.onStaticIndicator( + isStatic === undefined ? 'pending' : isStatic ? 'static' : 'dynamic' + ) } }, [pathname, staticIndicatorState]) } diff --git a/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts b/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts index c22424f52ff94..de6a175acebb9 100644 --- a/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts +++ b/packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts @@ -265,10 +265,10 @@ export function handleStaticIndicator() { appComponent?.getInitialProps !== appComponent?.origGetInitialProps const isPageStatic = - window.location.pathname in isrManifest || + isrManifest[window.location.pathname] || (!isDynamicPage && !hasAppGetInitialProps) - dispatcher.onStaticIndicator(isPageStatic) + dispatcher.onStaticIndicator(isPageStatic ? 'static' : 'dynamic') } } diff --git a/packages/next/src/next-devtools/dev-overlay.browser.tsx b/packages/next/src/next-devtools/dev-overlay.browser.tsx index 98a848cd7f83e..854508455fd3c 100644 --- a/packages/next/src/next-devtools/dev-overlay.browser.tsx +++ b/packages/next/src/next-devtools/dev-overlay.browser.tsx @@ -55,7 +55,7 @@ export interface Dispatcher { onDebugInfo(debugInfo: DebugInfo): void onBeforeRefresh(): void onRefresh(): void - onStaticIndicator(status: boolean): void + onStaticIndicator(status: 'pending' | 'static' | 'dynamic' | 'disabled'): void onDevIndicator(devIndicator: DevIndicatorServerState): void onDevToolsConfig(config: DevToolsConfig): void onUnhandledError(reason: Error): void @@ -147,9 +147,14 @@ export const dispatcher: Dispatcher = { dispatch({ type: ACTION_VERSION_INFO, versionInfo }) } ), - onStaticIndicator: createQueuable((dispatch: Dispatch, status: boolean) => { - dispatch({ type: ACTION_STATIC_INDICATOR, staticIndicator: status }) - }), + onStaticIndicator: createQueuable( + ( + dispatch: Dispatch, + status: 'pending' | 'static' | 'dynamic' | 'disabled' + ) => { + dispatch({ type: ACTION_STATIC_INDICATOR, staticIndicator: status }) + } + ), onDebugInfo: createQueuable((dispatch: Dispatch, debugInfo: DebugInfo) => { dispatch({ type: ACTION_DEBUG_INFO, debugInfo }) }), diff --git a/packages/next/src/next-devtools/dev-overlay/icons/loading-icon.tsx b/packages/next/src/next-devtools/dev-overlay/icons/loading-icon.tsx new file mode 100644 index 0000000000000..2f24c85f94bd3 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/icons/loading-icon.tsx @@ -0,0 +1,31 @@ +export function LoadingIcon() { + return ( + + + + + + ) +} diff --git a/packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx b/packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx index c4dc69fe78b9e..6463277eb8234 100644 --- a/packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx +++ b/packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx @@ -24,6 +24,7 @@ import { ACTION_ERROR_OVERLAY_OPEN, } from '../shared' import GearIcon from '../icons/gear-icon' +import { LoadingIcon } from '../icons/loading-icon' import { UserPreferencesBody } from '../components/errors/dev-tools-indicator/dev-tools-info/user-preferences' import { useShortcuts } from '../hooks/use-shortcuts' import { useUpdateAllPanelPositions } from '../components/devtools-indicator/devtools-indicator' @@ -60,17 +61,24 @@ const MenuPanel = () => { } }, }, - { - title: `Current route is ${state.staticIndicator ? 'static' : 'dynamic'}.`, - label: 'Route', - value: state.staticIndicator ? 'Static' : 'Dynamic', - onClick: () => setPanel('route-type'), - attributes: { - 'data-nextjs-route-type': state.staticIndicator - ? 'static' - : 'dynamic', - }, - }, + state.staticIndicator === 'disabled' + ? undefined + : state.staticIndicator === 'pending' + ? { + title: 'Loading...', + label: 'Route', + value: , + } + : { + title: `Current route is ${state.staticIndicator}.`, + label: 'Route', + value: + state.staticIndicator === 'static' ? 'Static' : 'Dynamic', + onClick: () => setPanel('route-type'), + attributes: { + 'data-nextjs-route-type': state.staticIndicator, + }, + }, !!process.env.TURBOPACK ? { title: 'Turbopack is enabled.', @@ -166,39 +174,39 @@ export const PanelRouter = () => { - - - } - > -
- - + } - /> -
-
-
+ > +
+ + +
+ + + )} {isAppRouter && ( diff --git a/packages/next/src/next-devtools/dev-overlay/shared.ts b/packages/next/src/next-devtools/dev-overlay/shared.ts index af92595fa64fd..ca43ae3fd9bc0 100644 --- a/packages/next/src/next-devtools/dev-overlay/shared.ts +++ b/packages/next/src/next-devtools/dev-overlay/shared.ts @@ -49,7 +49,7 @@ export interface OverlayState { readonly notFound: boolean readonly buildingIndicator: boolean readonly renderingIndicator: boolean - readonly staticIndicator: boolean + readonly staticIndicator: 'pending' | 'static' | 'dynamic' | 'disabled' readonly showIndicator: boolean readonly disableDevIndicator: boolean readonly debugInfo: DebugInfo @@ -112,7 +112,7 @@ export const ACTION_DEVTOOL_UPDATE_ROUTE_STATE = interface StaticIndicatorAction { type: typeof ACTION_STATIC_INDICATOR - staticIndicator: boolean + staticIndicator: 'pending' | 'static' | 'dynamic' | 'disabled' } interface BuildOkAction { @@ -263,7 +263,7 @@ export const INITIAL_OVERLAY_STATE: Omit< errors: [], notFound: false, renderingIndicator: false, - staticIndicator: false, + staticIndicator: 'disabled', /* This is set to `true` when we can reliably know whether the indicator is in disabled state or not. diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index bc7decf5004de..6e0540d597681 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1502,6 +1502,7 @@ async function renderToHTMLOrFlightImpl( serverActions, assetPrefix = '', enableTainting, + experimental, } = renderOpts // We need to expose the bundled `require` API globally for @@ -1566,10 +1567,18 @@ async function renderToHTMLOrFlightImpl( globalThis.__next_chunk_load__ = __next_chunk_load__ } - if (process.env.NODE_ENV === 'development') { - // reset isr status at start of request + if ( + process.env.NODE_ENV === 'development' && + renderOpts.setIsrStatus && + !experimental.cacheComponents + ) { + // Reset the ISR status at start of request. const { pathname } = new URL(req.url || '/', 'http://n') - renderOpts.setIsrStatus?.(pathname, false) + renderOpts.setIsrStatus( + pathname, + // Only pages using the Node runtime can use ISR, Edge is always dynamic. + process.env.NEXT_RUNTIME === 'edge' ? false : undefined + ) } if ( @@ -1844,19 +1853,19 @@ async function renderToHTMLOrFlightImpl( if ( process.env.NODE_ENV === 'development' && renderOpts.setIsrStatus && + !experimental.cacheComponents && + // Only pages using the Node runtime can use ISR, so we only need to + // update the status for those. // The type check here ensures that `req` is correctly typed, and the // environment variable check provides dead code elimination. process.env.NEXT_RUNTIME !== 'edge' && - isNodeNextRequest(req) && - !isDevWarmupRequest + isNodeNextRequest(req) ) { const setIsrStatus = renderOpts.setIsrStatus req.originalRequest.on('end', () => { - if (!requestStore.usedDynamic && !workStore.forceDynamic) { - // only node can be ISR so we only need to update the status here - const { pathname } = new URL(req.url || '/', 'http://n') - setIsrStatus(pathname, true) - } + const { pathname } = new URL(req.url || '/', 'http://n') + const isStatic = !requestStore.usedDynamic && !workStore.forceDynamic + setIsrStatus(pathname, isStatic) }) } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 1f6132f9e6f85..217760cb650c3 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -93,7 +93,7 @@ export interface RenderOptsPartial { } isOnDemandRevalidate?: boolean isPossibleServerAction?: boolean - setIsrStatus?: (key: string, value: boolean) => void + setIsrStatus?: (key: string, value: boolean | undefined) => void setReactDebugChannel?: ( debugChannel: { readable: ReadableStream }, htmlRequestId: string, diff --git a/packages/next/src/server/dev/hot-middleware.ts b/packages/next/src/server/dev/hot-middleware.ts index b5b0095fa95d9..eed2d8df90b9c 100644 --- a/packages/next/src/server/dev/hot-middleware.ts +++ b/packages/next/src/server/dev/hot-middleware.ts @@ -30,6 +30,7 @@ import type { HmrMessageSentToBrowser } from './hot-reloader-types' import { HMR_MESSAGE_SENT_TO_BROWSER } from './hot-reloader-types' import { devIndicatorServerState } from './dev-indicator-server-state' import { createBinaryHmrMessageData } from './messages' +import type { NextConfigComplete } from '../config-shared' function isMiddlewareStats(stats: webpack.Stats) { for (const key of stats.compilation.entrypoints.keys()) { @@ -70,31 +71,21 @@ function getStatsForSyncEvent( } export class WebpackHotMiddleware { - private clients = new Set() + private clientsWithoutRequestId = new Set() private clientsByRequestId: Map = new Map() - - clientLatestStats: { ts: number; stats: webpack.Stats } | null - middlewareLatestStats: { ts: number; stats: webpack.Stats } | null - serverLatestStats: { ts: number; stats: webpack.Stats } | null - closed: boolean - versionInfo: VersionInfo - devtoolsFrontendUrl: string | undefined - devToolsConfig: DevToolsConfig + private closed = false + private clientLatestStats: { ts: number; stats: webpack.Stats } | null = null + private middlewareLatestStats: { ts: number; stats: webpack.Stats } | null = + null + private serverLatestStats: { ts: number; stats: webpack.Stats } | null = null constructor( compilers: webpack.Compiler[], - versionInfo: VersionInfo, - devtoolsFrontendUrl: string | undefined, - devToolsConfig: DevToolsConfig + private versionInfo: VersionInfo, + private devtoolsFrontendUrl: string | undefined, + private config: NextConfigComplete, + private devToolsConfig: DevToolsConfig ) { - this.clientLatestStats = null - this.middlewareLatestStats = null - this.serverLatestStats = null - this.closed = false - this.versionInfo = versionInfo - this.devtoolsFrontendUrl = devtoolsFrontendUrl - this.devToolsConfig = devToolsConfig || ({} as DevToolsConfig) - compilers[0].hooks.invalid.tap( 'webpack-hot-middleware', this.onClientInvalid @@ -175,17 +166,17 @@ export class WebpackHotMiddleware { onHMR = (client: ws, requestId: string | null) => { if (this.closed) return - this.clients.add(client) - if (requestId) { this.clientsByRequestId.set(requestId, client) + } else { + this.clientsWithoutRequestId.add(client) } client.addEventListener('close', () => { - this.clients.delete(client) - if (requestId) { this.clientsByRequestId.delete(requestId) + } else { + this.clientsWithoutRequestId.delete(client) } }) @@ -259,7 +250,32 @@ export class WebpackHotMiddleware { return } - for (const wsClient of this.clients) { + for (const wsClient of [ + ...this.clientsWithoutRequestId, + ...this.clientsByRequestId.values(), + ]) { + this.publishToClient(wsClient, message) + } + } + + publishToLegacyClients = (message: HmrMessageSentToBrowser) => { + if (this.closed) { + return + } + + // Clients with a request ID are inferred App Router clients. If Cache + // Components is not enabled, we consider those legacy clients. Pages + // Router clients are also considered legacy clients. TODO: Maybe mark + // clients as App Router / Pages Router clients explicitly, instead of + // inferring it from the presence of a request ID. + + if (!this.config.experimental.cacheComponents) { + for (const wsClient of this.clientsByRequestId.values()) { + this.publishToClient(wsClient, message) + } + } + + for (const wsClient of this.clientsWithoutRequestId) { this.publishToClient(wsClient, message) } } @@ -273,28 +289,31 @@ export class WebpackHotMiddleware { // https://github.com/webpack/tapable/issues/32#issuecomment-350644466 this.closed = true - for (const wsClient of this.clients) { + for (const wsClient of [ + ...this.clientsWithoutRequestId, + ...this.clientsByRequestId.values(), + ]) { // it's okay to not cleanly close these websocket connections, this is dev wsClient.terminate() } - this.clients.clear() + this.clientsWithoutRequestId.clear() this.clientsByRequestId.clear() } deleteClient = (client: ws, requestId: string | null) => { - this.clients.delete(client) - if (requestId) { this.clientsByRequestId.delete(requestId) + } else { + this.clientsWithoutRequestId.delete(client) } } hasClients = () => { - return this.clients.size > 0 + return this.clientsWithoutRequestId.size + this.clientsByRequestId.size > 0 } getClientCount = () => { - return this.clients.size + return this.clientsWithoutRequestId.size + this.clientsByRequestId.size } } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 6a4eec631fa42..0b93c6217a294 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -433,7 +433,7 @@ export async function createHotReloaderTurbopack( let hmrEventHappened = false let hmrHash = 0 - const clients = new Set() + const clientsWithoutRequestId = new Set() const clientsByRequestId = new Map() const clientStates = new WeakMap() @@ -457,7 +457,10 @@ export async function createHotReloaderTurbopack( } } - for (const client of clients) { + for (const client of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { const state = clientStates.get(client) if (!state) { continue @@ -490,7 +493,10 @@ export async function createHotReloaderTurbopack( const sendEnqueuedMessagesDebounce = debounce(sendEnqueuedMessages, 2) const sendHmr: SendHmr = (id: string, message: HmrMessageSentToBrowser) => { - for (const client of clients) { + for (const client of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { clientStates.get(client)?.messages.set(id, message) } @@ -505,7 +511,10 @@ export async function createHotReloaderTurbopack( payload.diagnostics = [] payload.issues = [] - for (const client of clients) { + for (const client of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { clientStates.get(client)?.turbopackUpdates.push(payload) } @@ -667,7 +676,7 @@ export async function createHotReloaderTurbopack( dev: { assetMapper, changeSubscriptions, - clients, + clients: [...clientsWithoutRequestId, ...clientsByRequestId.values()], clientStates, serverFields, @@ -762,7 +771,8 @@ export async function createHotReloaderTurbopack( projectPath, distDir, sendHmrMessage: (message) => hotReloader.send(message), - getActiveConnectionCount: () => clients.size, + getActiveConnectionCount: () => + clientsWithoutRequestId.size + clientsByRequestId.size, getDevServerUrl: () => process.env.__NEXT_PRIVATE_ORIGIN, }), ] @@ -856,18 +866,26 @@ export async function createHotReloaderTurbopack( // TODO: Figure out if socket type can match the NextJsHotReloaderInterface onHMR(req, socket: Socket, head, onUpgrade) { wsServer.handleUpgrade(req, socket, head, (client) => { - onUpgrade(client) const clientIssues: EntryIssuesMap = new Map() const subscriptions: Map> = new Map() - clients.add(client) - const requestId = req.url ? new URL(req.url, 'http://n').searchParams.get('id') : null + // Clients with a request ID are inferred App Router clients. If Cache + // Components is not enabled, we consider those legacy clients. Pages + // Router clients are also considered legacy clients. TODO: Maybe mark + // clients as App Router / Pages Router clients explicitly, instead of + // inferring it from the presence of a request ID. if (requestId) { clientsByRequestId.set(requestId, client) + onUpgrade(client, { + isLegacyClient: !nextConfig.experimental.cacheComponents, + }) + } else { + clientsWithoutRequestId.add(client) + onUpgrade(client, { isLegacyClient: true }) } clientStates.set(client, { @@ -883,11 +901,12 @@ export async function createHotReloaderTurbopack( subscription.return?.() } clientStates.delete(client) - clients.delete(client) if (requestId) { clientsByRequestId.delete(requestId) deleteReactDebugChannel(requestId) + } else { + clientsWithoutRequestId.delete(client) } }) @@ -1066,7 +1085,30 @@ export async function createHotReloaderTurbopack( send(action) { const payload = JSON.stringify(action) - for (const client of clients) { + for (const client of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { + client.send(payload) + } + }, + + sendToLegacyClients(action) { + const payload = JSON.stringify(action) + + // Clients with a request ID are inferred App Router clients. If Cache + // Components is not enabled, we consider those legacy clients. Pages + // Router clients are also considered legacy clients. TODO: Maybe mark + // clients as App Router / Pages Router clients explicitly, instead of + // inferring it from the presence of a request ID. + + if (!nextConfig.experimental.cacheComponents) { + for (const client of clientsByRequestId.values()) { + client.send(payload) + } + } + + for (const client of clientsWithoutRequestId) { client.send(payload) } }, @@ -1314,11 +1356,14 @@ export async function createHotReloaderTurbopack( }) }, close() { - for (const wsClient of clients) { + for (const wsClient of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { // it's okay to not cleanly close these websocket connections, this is dev wsClient.terminate() } - clients.clear() + clientsWithoutRequestId.clear() clientsByRequestId.clear() }, } @@ -1370,7 +1415,10 @@ export async function createHotReloaderTurbopack( const errors = new Map() addErrors(errors, currentEntryIssues) - for (const client of clients) { + for (const client of [ + ...clientsWithoutRequestId, + ...clientsByRequestId.values(), + ]) { const state = clientStates.get(client) if (!state) { continue diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index 9de7d17e4ac3e..c04d8d899ea04 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -135,7 +135,7 @@ export interface TurbopackConnectedMessage { export interface AppIsrManifestMessage { type: HMR_MESSAGE_SENT_TO_BROWSER.ISR_MANIFEST - data: Record + data: Record } export interface DevToolsConfigMessage { @@ -213,6 +213,11 @@ export interface NextJsHotReloaderInterface { clearHmrServerError(): void start(): Promise send(action: HmrMessageSentToBrowser): void + /** + * Send the given action only to legacy clients, i.e. Pages Router clients, + * and App Router clients that don't have Cache Components enabled. + */ + sendToLegacyClients(action: HmrMessageSentToBrowser): void setReactDebugChannel( debugChannel: ReactDebugChannelForBrowser, htmlRequestId: string, @@ -223,7 +228,10 @@ export interface NextJsHotReloaderInterface { req: IncomingMessage, _socket: Duplex, head: Buffer, - onUpgrade: (client: { send(data: string): void }) => void + onUpgrade: ( + client: { send(data: string): void }, + context: { isLegacyClient: boolean } + ) => void ): void invalidate({ reloadAfterInvalidation, diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 600342fd5af78..75a8ce084d36b 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -424,7 +424,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { req: IncomingMessage, _socket: Duplex, head: Buffer, - callback: (client: ws.WebSocket) => void + callback: ( + client: ws.WebSocket, + context: { isLegacyClient: boolean } + ) => void ) { wsServer.handleUpgrade(req, req.socket, head, (client) => { const requestId = req.url @@ -437,7 +440,16 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.webpackHotMiddleware.onHMR(client, requestId) this.onDemandEntries?.onHMR(client, () => this.hmrServerError) - callback(client) + + // Clients with a request ID are inferred App Router clients. If Cache + // Components is not enabled, we consider those legacy clients. Pages + // Router clients are also considered legacy clients. TODO: Maybe mark + // clients as App Router / Pages Router clients explicitly, instead of + // inferring it from the presence of a request ID. + const isLegacyClient = + !requestId || !this.config.experimental.cacheComponents + + callback(client, { isLegacyClient }) client.addEventListener('message', async ({ data }) => { data = typeof data !== 'string' ? data.toString() : data @@ -1562,6 +1574,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.multiCompiler.compilers, this.versionInfo, this.devtoolsFrontendUrl, + this.config, initialDevToolsConfig ) @@ -1704,6 +1717,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.webpackHotMiddleware!.publishToClient(client, message) } + public sendToLegacyClients(message: HmrMessageSentToBrowser): void { + this.webpackHotMiddleware!.publishToLegacyClients(message) + } + public setReactDebugChannel( debugChannel: ReactDebugChannelForBrowser, htmlRequestId: string, diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index 5cc1a0eadeaee..e0ec8857e83b9 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -565,7 +565,7 @@ type HandleEntrypointsHooks = { type HandleEntrypointsDevOpts = { assetMapper: AssetMapper changeSubscriptions: ChangeSubscriptions - clients: Set + clients: Array clientStates: ClientStateMap serverFields: ServerFields diff --git a/packages/next/src/server/lib/dev-bundler-service.ts b/packages/next/src/server/lib/dev-bundler-service.ts index 44cd3889963bf..722db07182698 100644 --- a/packages/next/src/server/lib/dev-bundler-service.ts +++ b/packages/next/src/server/lib/dev-bundler-service.ts @@ -12,7 +12,7 @@ import type { ReactDebugChannelForBrowser } from '../dev/debug-channel' * bundler while in development. */ export class DevBundlerService { - public appIsrManifestInner: InstanceType> + public appIsrManifestInner: InstanceType> constructor( private readonly bundler: DevBundler, @@ -24,7 +24,7 @@ export class DevBundlerService { function length() { return 16 } - ) as any + ) } public ensurePage: typeof this.bundler.hotReloader.ensurePage = async ( @@ -86,7 +86,7 @@ export class DevBundlerService { } public get appIsrManifest() { - const serializableManifest: Record = {} + const serializableManifest: Record = {} for (const [key, value] of this.appIsrManifestInner) { serializableManifest[key] = value @@ -95,13 +95,20 @@ export class DevBundlerService { return serializableManifest } - public setIsrStatus(key: string, value: boolean) { - if (value === false) { + public setIsrStatus(key: string, value: boolean | undefined) { + if (value === undefined) { this.appIsrManifestInner.remove(key) } else { this.appIsrManifestInner.set(key, value) } - this.bundler?.hotReloader?.send({ + + // Only send the ISR manifest to legacy clients, i.e. Pages Router clients, + // or App Router clients that have Cache Components disabled. The ISR + // manifest is only used to inform the static indicator, which currently + // does not provide useful information if Cache Components is enabled due to + // its binary nature (i.e. it does not support showing info for partially + // static pages). + this.bundler?.hotReloader?.sendToLegacyClients({ type: HMR_MESSAGE_SENT_TO_BROWSER.ISR_MANIFEST, data: this.appIsrManifest, }) diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 1672cbd50360e..a0e30a9c81a51 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -801,13 +801,22 @@ export async function initialize(opts: { req, socket, head, - (client) => { - client.send( - JSON.stringify({ - type: HMR_MESSAGE_SENT_TO_BROWSER.ISR_MANIFEST, - data: devBundlerService?.appIsrManifest || {}, - } satisfies AppIsrManifestMessage) - ) + (client, { isLegacyClient }) => { + if (isLegacyClient) { + // Only send the ISR manifest to legacy clients, i.e. Pages + // Router clients, or App Router clients that have Cache + // Components disabled. The ISR manifest is only used to inform + // the static indicator, which currently does not provide useful + // information if Cache Components is enabled due to its binary + // nature (i.e. it does not support showing info for partially + // static pages). + client.send( + JSON.stringify({ + type: HMR_MESSAGE_SENT_TO_BROWSER.ISR_MANIFEST, + data: devBundlerService?.appIsrManifest || {}, + } satisfies AppIsrManifestMessage) + ) + } } ) } diff --git a/packages/next/src/server/lib/router-utils/router-server-context.ts b/packages/next/src/server/lib/router-utils/router-server-context.ts index 568be2acae75f..8d73e4219406c 100644 --- a/packages/next/src/server/lib/router-utils/router-server-context.ts +++ b/packages/next/src/server/lib/router-utils/router-server-context.ts @@ -39,7 +39,7 @@ export type RouterServerContext = Record< // allow dev server to log with original stack logErrorWithOriginalStack?: (err: unknown, type: string) => void // allow setting ISR status in dev - setIsrStatus?: (key: string, value: boolean) => void + setIsrStatus?: (key: string, value: boolean | undefined) => void setReactDebugChannel?: ( debugChannel: { readable: ReadableStream }, htmlRequestId: string, diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 0df31c045d5a2..2c400090edae2 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -127,7 +127,7 @@ export type ServerFields = { interceptionRoutes?: ReturnType< typeof import('./filesystem').buildCustomRoute >[] - setIsrStatus?: (key: string, value: boolean) => void + setIsrStatus?: (key: string, value: boolean | undefined) => void resetFetch?: () => void } diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 4e6fee912ffb4..f8c4bb204650e 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -258,7 +258,7 @@ export type RenderOptsPartial = { assetQueryString?: string resolvedUrl?: string resolvedAsPath?: string - setIsrStatus?: (key: string, value: boolean) => void + setIsrStatus?: (key: string, value: boolean | undefined) => void clientReferenceManifest?: DeepReadonly nextFontManifest?: DeepReadonly distDir?: string diff --git a/test/development/app-dir/dev-indicator/route-type.test.ts b/test/development/app-dir/dev-indicator/route-type.test.ts index f4685e22c6c60..11126e4840a3d 100644 --- a/test/development/app-dir/dev-indicator/route-type.test.ts +++ b/test/development/app-dir/dev-indicator/route-type.test.ts @@ -1,5 +1,5 @@ import { nextTestSetup } from 'e2e-utils' -import { getRouteTypeFromDevToolsIndicator, retry } from 'next-test-utils' +import { assertStaticIndicator, retry } from 'next-test-utils' describe('app dir dev indicator - route type', () => { const { next } = nextTestSetup({ @@ -10,7 +10,7 @@ describe('app dir dev indicator - route type', () => { const browser = await next.browser('/') await retry(async () => { - expect(await getRouteTypeFromDevToolsIndicator(browser)).toBe('Static') + await assertStaticIndicator(browser, 'Static') }) }) @@ -25,7 +25,7 @@ describe('app dir dev indicator - route type', () => { try { await retry(async () => { - expect(await getRouteTypeFromDevToolsIndicator(browser)).toBe('Dynamic') + await assertStaticIndicator(browser, 'Dynamic') }) } finally { await next.patchFile('app/page.tsx', origContent) @@ -44,7 +44,7 @@ describe('app dir dev indicator - route type', () => { try { await retry(async () => { - expect(await getRouteTypeFromDevToolsIndicator(browser)).toBe('Dynamic') + await assertStaticIndicator(browser, 'Dynamic') }) } finally { await next.patchFile('app/page.tsx', origContent) @@ -56,6 +56,6 @@ describe('app dir dev indicator - route type', () => { await browser.waitForElementByCss('#ready') - expect(await getRouteTypeFromDevToolsIndicator(browser)).toBe('Dynamic') + await assertStaticIndicator(browser, 'Dynamic') }) }) diff --git a/test/development/dev-indicator/app/app/static-indicator/dynamic/page.tsx b/test/development/dev-indicator/app/app/static-indicator/dynamic/page.tsx new file mode 100644 index 0000000000000..ef6849bce549b --- /dev/null +++ b/test/development/dev-indicator/app/app/static-indicator/dynamic/page.tsx @@ -0,0 +1,9 @@ +import { connection } from 'next/server' +import { setTimeout } from 'timers/promises' + +export default async function Page() { + await connection() + await setTimeout(100) + + return

This is a dynamic app router page.

+} diff --git a/test/development/dev-indicator/app/app/static-indicator/static/page.tsx b/test/development/dev-indicator/app/app/static-indicator/static/page.tsx new file mode 100644 index 0000000000000..58bb180559156 --- /dev/null +++ b/test/development/dev-indicator/app/app/static-indicator/static/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

This is a static app router page.

+} diff --git a/test/development/dev-indicator/app/layout.js b/test/development/dev-indicator/app/layout.js deleted file mode 100644 index 4ee00a218505a..0000000000000 --- a/test/development/dev-indicator/app/layout.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function RootLayout({ children }) { - return ( - - {children} - - ) -} diff --git a/test/development/dev-indicator/app/layout.tsx b/test/development/dev-indicator/app/layout.tsx new file mode 100644 index 0000000000000..5f632238aabd0 --- /dev/null +++ b/test/development/dev-indicator/app/layout.tsx @@ -0,0 +1,15 @@ +import { Suspense } from 'react' +import { Nav } from '../components/nav' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + +