Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1445,5 +1445,6 @@
"1444": "Invalid target hostname \"%s\" for proxy request, expected \"%s\"",
"1445": "Invariant: Missing `__NEXT_PRIVATE_ORIGIN` environment variable.",
"1446": "Missing initURL",
"1447": "Could not determine origin for forwarded Server Actions request. This can happen if port or hostname are not configured for this server."
"1447": "Could not determine origin for forwarded Server Actions request. This can happen if port or hostname are not configured for this server.",
"1448": "Error in the \"%s\" app-route HMR subscription"
}
1 change: 1 addition & 0 deletions packages/next/src/build/templates/app-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export async function handler(
validationLevel: nextConfig.experimental.instantInsights.validationLevel,
supportsDynamicResponse,
incrementalCache,
hmrRefreshHash: getRequestMeta(req, 'hmrRefreshHash'),
cacheLifeProfiles: nextConfig.cacheLife,
staticPageGenerationTimeout: nextConfig.staticPageGenerationTimeout,
waitUntil: ctx.waitUntil,
Expand Down
1 change: 0 additions & 1 deletion packages/next/src/client/components/app-router-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export const NEXT_ROUTER_PREFETCH_HEADER = 'next-router-prefetch' as const
export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER =
'next-router-segment-prefetch' as const
export const NEXT_HMR_REFRESH_HEADER = 'next-hmr-refresh' as const
export const NEXT_HMR_REFRESH_HASH_COOKIE = '__next_hmr_refresh_hash__' as const
export const NEXT_URL = 'next-url' as const
export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import type { McpPageMetadataResponse } from '../../../../shared/lib/mcp-page-me
import { useUntrackedPathname } from '../../../components/navigation-untracked'
import reportHmrLatency from '../../report-hmr-latency'
import { TurbopackHmr } from '../turbopack-hot-reloader-common'
import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../../components/app-router-headers'
import {
publicAppRouterInstance,
type GlobalErrorState,
Expand Down Expand Up @@ -412,14 +411,9 @@ export function processMessage(
JSON.stringify({
event: 'server-component-reload-page',
clientId: __nextDevClientId,
hash: message.hash,
})
)

// Store the latest hash in a session cookie so that it's sent back to the
// server with any subsequent requests.
document.cookie = `${NEXT_HMR_REFRESH_HASH_COOKIE}=${message.hash};path=/`

if (
RuntimeErrorHandler.hadRuntimeError ||
document.documentElement.id === '__next_error__'
Expand Down
4 changes: 3 additions & 1 deletion packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2856,6 +2856,7 @@ async function renderToHTMLOrFlightImpl(

const rootParams = getRootParams(loaderTree, ctx.getDynamicParamFromSegment)
const fallbackParams = getRequestMeta(req, 'fallbackParams') || null
const hmrRefreshHash = getRequestMeta(req, 'hmrRefreshHash')

const createRequestStore = createRequestStoreForRender.bind(
null,
Expand All @@ -2869,7 +2870,8 @@ async function renderToHTMLOrFlightImpl(
isHmrRefresh,
serverComponentsHmrCache,
renderResumeDataCache,
fallbackParams
fallbackParams,
hmrRefreshHash
)
const requestStore = createRequestStore()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type {
import type { Params } from '../request/params'
import type { ImplicitTags } from '../lib/implicit-tags'
import type { WorkStore } from './work-async-storage.external'
import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../client/components/app-router-headers'
import { InvariantError } from '../../shared/lib/invariant-error'
import type { StagedRenderingController } from './staged-rendering'
import type { ValidationBoundaryTracking } from './instant-validation/boundary-tracking'
Expand Down Expand Up @@ -61,6 +60,7 @@ export interface RequestStore extends CommonWorkUnitStore {
readonly draftMode: DraftModeProvider
readonly isHmrRefresh?: boolean
readonly serverComponentsHmrCache?: ServerComponentsHmrCache
readonly hmrRefreshHash?: string

readonly rootParams: Params

Expand Down Expand Up @@ -438,9 +438,8 @@ export function getHmrRefreshHash(
case 'private-cache':
case 'prerender':
case 'prerender-runtime':
return workUnitStore.hmrRefreshHash
case 'request':
return workUnitStore.cookies.get(NEXT_HMR_REFRESH_HASH_COOKIE)?.value
return workUnitStore.hmrRefreshHash
case 'prerender-client':
case 'validation-client':
case 'prerender-ppr':
Expand Down
16 changes: 14 additions & 2 deletions packages/next/src/server/async-storage/request-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export type RequestStoreInputs = {
previewProps: WrapperRenderOpts['previewProps']
isHmrRefresh: boolean | undefined
serverComponentsHmrCache: ServerComponentsHmrCache | undefined
/**
* The hash of the most recent server component change (dev only). Included in
* `"use cache"` cache keys so that cached entries are revalidated after an
* edit, for every client, regardless of whether it runs the HMR client.
*/
hmrRefreshHash: string | undefined
fallbackParams: OpaqueFallbackRouteParams | null | undefined
}

Expand Down Expand Up @@ -173,7 +179,8 @@ export function createRequestStoreForRender(
isHmrRefresh: RequestContext['isHmrRefresh'],
serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'],
resumeDataCache: ResumeDataCache | null,
fallbackParams: OpaqueFallbackRouteParams | null
fallbackParams: OpaqueFallbackRouteParams | null,
hmrRefreshHash: string | undefined
): RequestStore {
return createRequestStore({
// Pages start in render phase by default
Expand All @@ -193,6 +200,7 @@ export function createRequestStoreForRender(
previewProps,
isHmrRefresh,
serverComponentsHmrCache,
hmrRefreshHash,
fallbackParams,
})
}
Expand All @@ -202,7 +210,8 @@ export function createRequestStoreForAPI(
url: RequestContext['url'],
implicitTags: RequestContext['implicitTags'],
onUpdateCookies: RenderOpts['onUpdateCookies'],
previewProps: WrapperRenderOpts['previewProps']
previewProps: WrapperRenderOpts['previewProps'],
hmrRefreshHash: string | undefined
): RequestStore {
return createRequestStore({
// API routes start in action phase by default
Expand All @@ -216,6 +225,7 @@ export function createRequestStoreForAPI(
previewProps,
isHmrRefresh: false,
serverComponentsHmrCache: undefined,
hmrRefreshHash,
fallbackParams: null,
})
}
Expand All @@ -239,6 +249,7 @@ export function createRequestStore(inputs: RequestStoreInputs): RequestStore {
previewProps,
isHmrRefresh,
serverComponentsHmrCache,
hmrRefreshHash,
fallbackParams,
} = inputs

Expand Down Expand Up @@ -321,6 +332,7 @@ export function createRequestStore(inputs: RequestStoreInputs): RequestStore {
serverComponentsHmrCache:
serverComponentsHmrCache ||
(globalThis as any).__serverComponentsHmrCache,
hmrRefreshHash,
fallbackParams,
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/server/async-storage/work-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export type WorkStoreContext = {
cacheLifeProfiles: ResolvedCacheLifeProfiles
staticPageGenerationTimeout: number
incrementalCache?: IncrementalCache
/**
* The hash of the most recent server component change (dev only). Included
* in `"use cache"` cache keys so that cached entries are revalidated after
* an edit, for every client, regardless of whether it runs the HMR client.
*/
hmrRefreshHash?: string
isOnDemandRevalidate?: boolean
cacheComponents: boolean
validationLevel: ValidationLevel
Expand Down
23 changes: 23 additions & 0 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,15 @@ export default abstract class Server<
: undefined
}

/**
* The hash of the most recent server component change (dev only), used to
* revalidate `"use cache"` entries after an edit. Overridden by the dev
* server; returns `undefined` otherwise.
*/
protected getServerComponentsHmrRefreshHash(): string | undefined {
return undefined
}

protected abstract loadEnvConfig(params: {
dev: boolean
forceReload: boolean
Expand Down Expand Up @@ -1550,6 +1559,20 @@ export default abstract class Server<
)
}

// Attach the server components HMR refresh hash here, alongside the HMR
// cache, so it reaches every render that passes through this request
// handling, including internal renders (e.g. dev validation/warmup) that
// don't re-enter the top-level request handler. It's included in `"use
// cache"` keys so cached entries revalidate after an edit, for every
// client.
if (!getRequestMeta(req, 'hmrRefreshHash')) {
addRequestMeta(
req,
'hmrRefreshHash',
this.getServerComponentsHmrRefreshHash()
)
}

// when invokePath is specified we can short short circuit resolving
// we only honor this header if we are inside of a render worker to
// prevent external users coercing the routing path
Expand Down
25 changes: 22 additions & 3 deletions packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,14 @@ export async function createHotReloaderTurbopack(
let serverHmrSubscriptions: ServerHmrSubscriptions | undefined

let hmrEventHappened = false
// A counter identifying the current version of the compiled output, included
// by `"use cache"` in dev cache keys so that cached entries revalidate after
// an edit. It advances once per HMR change event (for App Router pages that
// is an RSC change, which is what a cached render depends on), independent of
// how many clients are connected. It deliberately does not advance on `BUILT`
// messages: those are sent per connected client on every compilation, so
// advancing there would both churn the hash without an edit and fail to
// advance it at all when no client is connected.
let hmrHash = 0

const clientsWithoutHtmlRequestId = new Set<ws>()
Expand Down Expand Up @@ -1495,6 +1503,16 @@ export async function createHotReloaderTurbopack(
}
},

getServerComponentsHmrRefreshHash() {
// The current server-components generation. Only the change subscription
// (an actual recompile) advances `hmrHash`; reloads and config
// invalidations don't, so the value stays stable across requests until a
// real edit. Returned unconditionally (`"0"` before the first edit) so
// `"use cache"` keys are present and consistent for every request,
// mirroring webpack's always-present `stats.hash`.
return String(hmrHash)
},

sendToLegacyClients(action) {
const payload = JSON.stringify(action)

Expand Down Expand Up @@ -1641,7 +1659,6 @@ export async function createHotReloaderTurbopack(
await clearAllModuleContexts()
this.send({
type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,
hash: String(++hmrHash),
})
}
},
Expand Down Expand Up @@ -1924,7 +1941,10 @@ export async function createHotReloaderTurbopack(

sendToClient(client, {
type: HMR_MESSAGE_SENT_TO_BROWSER.BUILT,
hash: String(++hmrHash),
// Report the current version without advancing it: a completed
// compilation is not itself an edit, and this hash is not
// consumed by the Turbopack client.
hash: String(hmrHash),
errors: [...clientErrors.values()],
warnings: [],
})
Expand Down Expand Up @@ -1983,7 +2003,6 @@ export async function createHotReloaderTurbopack(
// Tell browsers to refetch RSC (soft refresh, not full page reload)
hotReloader.send({
type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,
hash: String(++hmrHash),
})
},
})
Expand Down
8 changes: 7 additions & 1 deletion packages/next/src/server/dev/hot-reloader-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export interface ReloadPageMessage {

export interface ServerComponentChangesMessage {
type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES
hash: string
}

/**
Expand Down Expand Up @@ -259,6 +258,13 @@ export interface NextJsHotReloaderInterface {
* and App Router clients that don't have Cache Components enabled.
*/
sendToLegacyClients(action: HmrMessageSentToBrowser): void
/**
* The hash of the most recent server component change, or `undefined` if no
* server component change has occurred yet. In dev, this is included in `"use
* cache"` cache keys so that cached entries are revalidated after an edit,
* for every client, regardless of whether it runs the HMR client.
*/
getServerComponentsHmrRefreshHash(): string | undefined
setCacheStatus(status: ServerCacheStatus, htmlRequestId: string): void
setReactDebugChannel(
debugChannel: ReactDebugChannelForBrowser,
Expand Down
7 changes: 6 additions & 1 deletion packages/next/src/server/dev/hot-reloader-webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
private serverError: Error | null = null
private hmrServerError: Error | null = null
private serverPrevDocumentHash: string | null
private serverComponentsHmrRefreshHash: string | undefined
private serverChunkNames?: Set<string>
private prevChunkNames?: Set<any>
private onDemandEntries?: ReturnType<typeof onDemandEntryHandler>
Expand Down Expand Up @@ -431,14 +432,18 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
}

protected async refreshServerComponents(hash: string): Promise<void> {
this.serverComponentsHmrRefreshHash = hash
this.send({
type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,
hash,
// TODO: granular reloading of changes
// entrypoints: serverComponentChanges,
})
}

public getServerComponentsHmrRefreshHash(): string | undefined {
Comment thread
vercel[bot] marked this conversation as resolved.
return this.serverComponentsHmrRefreshHash
}

public onHMR(
req: IncomingMessage,
_socket: Duplex,
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/server/dev/next-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ export default class DevServer extends Server {
return this.serverComponentsHmrCache
}

protected override getServerComponentsHmrRefreshHash(): string | undefined {
return this.bundlerService.getServerComponentsHmrRefreshHash()
}

protected getRouteMatchers(): RouteMatcherManager {
const { pagesDir, appDir } = findPagesDir(this.dir)

Expand Down
28 changes: 28 additions & 0 deletions packages/next/src/server/dev/turbopack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,34 @@ export async function handleRouteType({
const writtenEndpoint = await route.endpoint.writeToDisk()
hooks?.handleWrittenEndpoint(key, writtenEndpoint, false)

if (dev) {
// Advance the hot-reloader's HMR refresh hash whenever this route
// handler is recompiled, so its `"use cache"` entries are invalidated
// after an edit. Subscribing runs `subscribeToClientChanges`, which
// bumps the `hmrHash` counter on each change; that counter is returned
// by `getServerComponentsHmrRefreshHash` and folded into cache keys by
// `getHmrRefreshHash`. Unlike app pages there is no RSC for a connected
// browser to refetch, so `createMessage` returns nothing; the
// subscription exists only to advance the hash.
hooks?.subscribeToChanges(
key,
/** includeIssues= */ true,
route.endpoint,
() => undefined,
(error) => {
// This subscription only advances the refresh hash, so there is
// nothing to send the browser when it fails. `subscribeToChanges`
// drops the subscription on error and re-creates it the next time
// this route is ensured, so just log it.
console.error(
new Error(`Error in the "${page}" app-route HMR subscription`, {
cause: error,
})
)
}
)
}

const type = writtenEndpoint.type

manifestLoader.loadAppPathsManifest(page)
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/dev/use-cache-probe-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export async function probeUseCache(msg: ProbeMessage): Promise<boolean> {
previewProps: undefined,
isHmrRefresh: msg.request.isHmrRefresh,
serverComponentsHmrCache: undefined,
hmrRefreshHash: msg.request.hmrRefreshHash,
fallbackParams: null,
})

Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/server/lib/dev-bundler-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export class DevBundlerService {
return await this.bundler.hotReloader.ensurePage(definition)
}

public getServerComponentsHmrRefreshHash(): string | undefined {
return this.bundler.hotReloader.getServerComponentsHmrRefreshHash()
}

public logErrorWithOriginalStack =
this.bundler.logErrorWithOriginalStack.bind(this.bundler)

Expand Down
8 changes: 8 additions & 0 deletions packages/next/src/server/request-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export interface RequestMeta {
*/
serverComponentsHmrCache?: ServerComponentsHmrCache

/**
* The hash of the most recent server component change (dev only), set by the
* router-server from the hot-reloader. Included in `"use cache"` cache keys
* so that cached entries are revalidated after an edit, for every client,
* regardless of whether it runs the HMR client.
*/
hmrRefreshHash?: string

/**
* Equals the segment path that was used for the prefetch RSC request.
*/
Expand Down
Loading
Loading