From a3560dd104ebb29aa4cdec0cca703830e4c76b93 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 10:46:24 +0100 Subject: [PATCH 01/10] Include HMR refresh hash in `"use cache"` cache keys Today, when editing server components that use `"use cache"` functions, only updates to the uncached parts of the component will be reflected in the rendered result after the HMR refresh was applied. Any cached functions/components will show the same content as before the edit. This is problematic when a function with a `"use cache"` directive is edited. In this case, the edits won't be applied, and stale content is shown in the browser, until the dev server is restarted. With this PR, we are now including an HMR refresh hash in the cache key for all `"use cache"` functions. This ensures that those functions are revalidated when a server component is edited, which avoids showing stale content. For Webpack, a real content hash is used, which means that if an edit is reverted again, the previously cached data will be retrieved from the cache. For Turbopack, the HRM hash is currently just an incrementing counter, which may be improved in a follow-up PR. --- .../next/src/client/components/app-router.tsx | 3 +- .../app/hot-reloader-client.tsx | 5 ++- .../router-reducer/fetch-server-response.ts | 8 ++-- .../reducers/hmr-refresh-reducer.ts | 4 +- .../router-reducer/router-reducer-types.ts | 1 + .../next/src/server/app-render/app-render.tsx | 18 +++++--- .../work-unit-async-storage.external.ts | 3 +- .../src/server/async-storage/request-store.ts | 12 ++--- .../src/server/dev/hot-reloader-turbopack.ts | 7 ++- .../next/src/server/dev/hot-reloader-types.ts | 3 ++ .../src/server/dev/hot-reloader-webpack.ts | 9 ++-- .../next/src/server/dev/turbopack-utils.ts | 11 +++-- packages/next/src/server/lib/patch-fetch.ts | 4 +- .../lib/router-utils/setup-dev-bundler.ts | 2 + .../src/server/use-cache/use-cache-wrapper.ts | 44 +++++++++++++++---- .../lib/app-router-context.shared-runtime.ts | 2 +- 16 files changed, 95 insertions(+), 41 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 34728ebd2b357..9e136619a5ccd 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -324,7 +324,7 @@ function Router({ }) }) }, - hmrRefresh: () => { + hmrRefresh: (hash) => { if (process.env.NODE_ENV !== 'development') { throw new Error( 'hmrRefresh can only be used in development mode. Please use refresh instead.' @@ -334,6 +334,7 @@ function Router({ dispatch({ type: ACTION_HMR_REFRESH, origin: window.location.origin, + hash, }) }) } 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 082956ad6f319..4a87bfa2162b2 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 @@ -481,6 +481,7 @@ function processMessage( JSON.stringify({ event: 'server-component-reload-page', clientId: __nextDevClientId, + hash: obj.hash, }) ) if (RuntimeErrorHandler.hadRuntimeError) { @@ -489,7 +490,7 @@ function processMessage( return window.location.reload() } startTransition(() => { - router.hmrRefresh() + router.hmrRefresh(obj.hash) dispatcher.onRefresh() }) @@ -516,7 +517,7 @@ function processMessage( case HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE: case HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE: { // TODO-APP: potentially only refresh if the currently viewed page was added/removed. - return router.hmrRefresh() + return router.hmrRefresh(obj.hash) } case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: { const { errorJSON } = obj diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 97c4916d26029..200fa9dbd3326 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -42,7 +42,7 @@ export interface FetchServerResponseOptions { readonly flightRouterState: FlightRouterState readonly nextUrl: string | null readonly prefetchKind?: PrefetchKind - readonly isHmrRefresh?: boolean + readonly hmrRefreshHash?: string } export type FetchServerResponseResult = { @@ -61,7 +61,7 @@ export type RequestHeaders = { [NEXT_ROUTER_PREFETCH_HEADER]?: '1' [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string 'x-deployment-id'?: string - [NEXT_HMR_REFRESH_HEADER]?: '1' + [NEXT_HMR_REFRESH_HEADER]?: string // A header that is only added in test mode to assert on fetch priority 'Next-Test-Fetch-Priority'?: RequestInit['priority'] } @@ -141,8 +141,8 @@ export async function fetchServerResponse( headers[NEXT_ROUTER_PREFETCH_HEADER] = '1' } - if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) { - headers[NEXT_HMR_REFRESH_HEADER] = '1' + if (process.env.NODE_ENV === 'development' && options.hmrRefreshHash) { + headers[NEXT_HMR_REFRESH_HEADER] = options.hmrRefreshHash } if (nextUrl) { diff --git a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts index 533a9570a8293..9aafeaeabd94c 100644 --- a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts @@ -21,7 +21,7 @@ function hmrRefreshReducerImpl( state: ReadonlyReducerState, action: HmrRefreshAction ): ReducerState { - const { origin } = action + const { origin, hash } = action const mutable: Mutable = {} const href = state.canonicalUrl @@ -37,7 +37,7 @@ function hmrRefreshReducerImpl( cache.lazyData = fetchServerResponse(new URL(href, origin), { flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'], nextUrl: includeNextUrl ? state.nextUrl : null, - isHmrRefresh: true, + hmrRefreshHash: hash, }) return cache.lazyData.then( diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 7d8e50d314151..384ff2e0e5e6a 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -58,6 +58,7 @@ export interface RefreshAction { export interface HmrRefreshAction { type: typeof ACTION_HMR_REFRESH origin: Location['origin'] + hash: string } export type ServerActionDispatcher = ( diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 32aaed3bbb837..516cded839fd6 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -243,7 +243,7 @@ interface ParsedRequestHeaders { readonly isPrefetchRequest: boolean readonly isRouteTreePrefetchRequest: boolean readonly isDevWarmupRequest: boolean - readonly isHmrRefresh: boolean + readonly hmrRefreshHash: string | undefined readonly isRSCRequest: boolean readonly nonce: string | undefined } @@ -259,8 +259,14 @@ function parseRequestHeaders( isDevWarmupRequest || headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined - const isHmrRefresh = - headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] !== undefined + const hmrRefreshHeader = headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] + + const hmrRefreshHash = + typeof hmrRefreshHeader === 'string' + ? hmrRefreshHeader + : Array.isArray(hmrRefreshHeader) + ? hmrRefreshHeader.join('-') + : undefined // dev warmup requests are treated as prefetch RSC requests const isRSCRequest = @@ -290,7 +296,7 @@ function parseRequestHeaders( flightRouterState, isPrefetchRequest, isRouteTreePrefetchRequest, - isHmrRefresh, + hmrRefreshHash, isRSCRequest, isDevWarmupRequest, nonce, @@ -1252,7 +1258,7 @@ async function renderToHTMLOrFlightImpl( isPrefetchRequest, isRSCRequest, isDevWarmupRequest, - isHmrRefresh, + hmrRefreshHash, nonce, } = parsedRequestHeaders @@ -1432,7 +1438,7 @@ async function renderToHTMLOrFlightImpl( implicitTags, renderOpts.onUpdateCookies, renderOpts.previewProps, - isHmrRefresh, + hmrRefreshHash, serverComponentsHmrCache, renderResumeDataCache ) diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 7857e76786786..f26c63a57a819 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -48,7 +48,7 @@ export type RequestStore = { readonly mutableCookies: ResponseCookies readonly userspaceMutableCookies: ResponseCookies readonly draftMode: DraftModeProvider - readonly isHmrRefresh?: boolean + readonly hmrRefreshHash?: string readonly serverComponentsHmrCache?: ServerComponentsHmrCache readonly implicitTags: string[] @@ -160,6 +160,7 @@ export type UseCacheStore = { explicitExpire: undefined | number // server expiration time explicitStale: undefined | number // client expiration time tags: null | string[] + readonly hmrRefreshHash: string | undefined } & PhasePartial export type UnstableCacheStore = { diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 499d91889d016..53a71a0f3dd99 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -64,7 +64,7 @@ type RequestContext = RequestResponsePair & { } phase: RequestStore['phase'] renderOpts?: WrapperRenderOpts - isHmrRefresh?: boolean + hmrRefreshHash?: string serverComponentsHmrCache?: ServerComponentsHmrCache implicitTags?: string[] | undefined } @@ -109,7 +109,7 @@ export function createRequestStoreForRender( implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], previewProps: WrapperRenderOpts['previewProps'], - isHmrRefresh: RequestContext['isHmrRefresh'], + hmrRefreshHash: RequestContext['hmrRefreshHash'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'], renderResumeDataCache: RenderResumeDataCache | undefined ): RequestStore { @@ -123,7 +123,7 @@ export function createRequestStoreForRender( onUpdateCookies, renderResumeDataCache, previewProps, - isHmrRefresh, + hmrRefreshHash, serverComponentsHmrCache ) } @@ -145,7 +145,7 @@ export function createRequestStoreForAPI( onUpdateCookies, undefined, previewProps, - false, + undefined, undefined ) } @@ -159,7 +159,7 @@ function createRequestStoreImpl( onUpdateCookies: RenderOpts['onUpdateCookies'], renderResumeDataCache: RenderResumeDataCache | undefined, previewProps: WrapperRenderOpts['previewProps'], - isHmrRefresh: RequestContext['isHmrRefresh'], + hmrRefreshHash: RequestContext['hmrRefreshHash'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] ): RequestStore { function defaultOnUpdateCookies(cookies: string[]) { @@ -248,7 +248,7 @@ function createRequestStoreImpl( return cache.draftMode }, renderResumeDataCache: renderResumeDataCache ?? null, - isHmrRefresh, + hmrRefreshHash, serverComponentsHmrCache: serverComponentsHmrCache || (globalThis as any).__serverComponentsHmrCache, diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 721bb9d31627b..5ec795629856d 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -467,7 +467,8 @@ export async function createHotReloaderTurbopack( includeIssues: boolean, endpoint: Endpoint, makePayload: ( - change: TurbopackResult + change: TurbopackResult, + hash: string ) => Promise | HMR_ACTION_TYPES | void, onError?: ( error: Error @@ -486,7 +487,8 @@ export async function createHotReloaderTurbopack( for await (const change of changed) { processIssues(currentEntryIssues, key, change, false, true) - const payload = await makePayload(change) + // TODO: Get an actual content hash from Turbopack. + const payload = await makePayload(change, String(++hmrHash)) if (payload) { sendHmr(key, payload) } @@ -910,6 +912,7 @@ export async function createHotReloaderTurbopack( await clearAllModuleContexts() this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + hash: String(++hmrHash), }) } }, diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index e8e27661a3847..da6098096dbb5 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -67,11 +67,13 @@ interface BuiltAction { interface AddedPageAction { action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE data: [page: string | null] + hash: string } interface RemovedPageAction { action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE data: [page: string | null] + hash: string } export interface ReloadPageAction { @@ -81,6 +83,7 @@ export interface ReloadPageAction { interface ServerComponentChangesAction { action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES + hash: string } interface MiddlewareChangesAction { diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 7d0e16d04061a..9d0555d25e092 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -407,9 +407,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { } } - protected async refreshServerComponents(): Promise { + protected async refreshServerComponents(hash: string): Promise { this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + hash, // TODO: granular reloading of changes // entrypoints: serverComponentChanges, }) @@ -1359,7 +1360,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { } ) - this.multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => { + this.multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', (stats) => { const reloadAfterInvalidation = this.reloadAfterInvalidation this.reloadAfterInvalidation = false @@ -1401,7 +1402,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { reloadAfterInvalidation ) { this.resetFetch() - this.refreshServerComponents() + this.refreshServerComponents(stats.hash) } changedClientPages.clear() @@ -1443,6 +1444,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE, data: [page], + hash: stats.hash, }) } } @@ -1453,6 +1455,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE, data: [page], + hash: stats.hash, }) } } diff --git a/packages/next/src/server/dev/turbopack-utils.ts b/packages/next/src/server/dev/turbopack-utils.ts index 39ff45754e7f7..c9ff170824d17 100644 --- a/packages/next/src/server/dev/turbopack-utils.ts +++ b/packages/next/src/server/dev/turbopack-utils.ts @@ -106,7 +106,8 @@ export type StartChangeSubscription = ( includeIssues: boolean, endpoint: Endpoint, makePayload: ( - change: TurbopackResult + change: TurbopackResult, + hash: string ) => Promise | HMR_ACTION_TYPES | void, onError?: (e: Error) => Promise | HMR_ACTION_TYPES | void ) => Promise @@ -341,7 +342,7 @@ export async function handleRouteType({ key, true, route.rscEndpoint, - (change) => { + (change, hash) => { if (change.issues.some((issue) => issue.severity === 'error')) { // Ignore any updates that has errors // There will be another update without errors eventually @@ -351,11 +352,13 @@ export async function handleRouteType({ readyIds?.delete(pathname) return { action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + hash, } }, - () => { + (e) => { return { - action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + action: HMR_ACTIONS_SENT_TO_BROWSER.RELOAD_PAGE, + data: `error in ${page} app-page subscription: ${e}`, } } ) diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 9b1853fedc7cd..e74c6b6f29e13 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -752,8 +752,10 @@ export function createPatchedFetcher( if (cacheKey && incrementalCache) { let cachedFetchData: CachedFetchData | undefined + // TODO: The serverComponentsHmrCache should also be available and + // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.isHmrRefresh && + requestStore?.hmrRefreshHash !== undefined && requestStore.serverComponentsHmrCache ) { cachedFetchData = 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 9ad83a12dba38..33ce06e2a49e1 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 @@ -863,6 +863,7 @@ async function startWatcher(opts: SetupOpts) { hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE, data: [route], + hash: 'TODO', }) }) @@ -870,6 +871,7 @@ async function startWatcher(opts: SetupOpts) { hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE, data: [route], + hash: 'TODO', }) }) } diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 84015b7bef743..5066d17d92369 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -43,6 +43,13 @@ import { cacheHandlerGlobal, DYNAMIC_EXPIRE } from './constants' import { UseCacheTimeoutError } from './use-cache-errors' import { createHangingInputAbortSignal } from '../app-render/dynamic-rendering' +type CacheKeyParts = [ + buildId: string, + hmrRefreshHash: string | undefined, + id: string, + args: unknown[], +] + const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' const cacheHandlerMap: Map = new Map() @@ -123,6 +130,12 @@ function generateCacheEntryWithCacheContext( ) } + const hmrRefreshHash = + outerWorkUnitStore?.type === 'request' || + outerWorkUnitStore?.type === 'cache' + ? outerWorkUnitStore.hmrRefreshHash + : undefined + // Initialize the Store for this Cache entry. const cacheStore: UseCacheStore = { type: 'cache', @@ -139,7 +152,9 @@ function generateCacheEntryWithCacheContext( explicitExpire: undefined, explicitStale: undefined, tags: null, + hmrRefreshHash, } + return workUnitAsyncStorage.run( cacheStore, generateCacheEntryImpl, @@ -278,7 +293,7 @@ async function generateCacheEntryImpl( ): Promise<[ReadableStream, Promise]> { const temporaryReferences = createServerTemporaryReferenceSet() - const [, , args] = + const cacheKeyParts = typeof encodedArguments === 'string' ? await decodeReply(encodedArguments, getServerModuleMap(), { temporaryReferences, @@ -313,6 +328,8 @@ async function generateCacheEntryImpl( { temporaryReferences } ) + const [, , , args] = cacheKeyParts as CacheKeyParts + // Track the timestamp when we started computing the result. const startTime = performance.timeOrigin + performance.now() // Invoke the inner function to load a new result. @@ -507,6 +524,16 @@ export function cache( // the implementation. const buildId = workStore.buildId + // In dev mode, when the HMR refresh hash is set, we include it in the + // cache key. This ensures that cache entries are not reused when server + // components have been edited. This is a very coarse approach. Ideally + // we'd only change cache keys for the caches that are included in the + // edited module (or, even better, function), directly or transitively. + const hmrRefreshHash = + workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' + ? workUnitStore.hmrRefreshHash + : undefined + const hangingInputAbortSignal = workUnitStore?.type === 'prerender' ? createHangingInputAbortSignal(workUnitStore) @@ -538,17 +565,18 @@ export function cache( } const temporaryReferences = createClientTemporaryReferenceSet() - const encodedArguments: FormData | string = await encodeReply( - [buildId, id, args], + const cacheKeyParts: CacheKeyParts = [buildId, hmrRefreshHash, id, args] + const encodedCacheKeyParts: FormData | string = await encodeReply( + cacheKeyParts, { temporaryReferences, signal: hangingInputAbortSignal } ) const serializedCacheKey = - typeof encodedArguments === 'string' + typeof encodedCacheKeyParts === 'string' ? // Fast path for the simple case for simple inputs. We let the CacheHandler // Convert it to an ArrayBuffer if it wants to. - encodedArguments - : await encodeFormData(encodedArguments) + encodedCacheKeyParts + : await encodeFormData(encodedCacheKeyParts) let stream: undefined | ReadableStream = undefined @@ -669,7 +697,7 @@ export function cache( workStore, workUnitStore, clientReferenceManifest, - encodedArguments, + encodedCacheKeyParts, fn, timeoutError ) @@ -729,7 +757,7 @@ export function cache( workStore, undefined, // This is not running within the context of this unit. clientReferenceManifest, - encodedArguments, + encodedCacheKeyParts, fn, timeoutError ) diff --git a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts index ff273f669bbc4..b262335504a00 100644 --- a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts @@ -133,7 +133,7 @@ export interface AppRouterInstance { * Refresh the current page. Use in development only. * @internal */ - hmrRefresh(): void + hmrRefresh(hash: string): void /** * Navigate to the provided href. * Pushes a new history entry. From 8dd6009c4d83e3a93b0413c14f1638b8f9bbf7ae Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 11:35:55 +0100 Subject: [PATCH 02/10] Improve comment --- packages/next/src/server/use-cache/use-cache-wrapper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 5066d17d92369..befa2ee73b027 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -526,9 +526,9 @@ export function cache( // In dev mode, when the HMR refresh hash is set, we include it in the // cache key. This ensures that cache entries are not reused when server - // components have been edited. This is a very coarse approach. Ideally - // we'd only change cache keys for the caches that are included in the - // edited module (or, even better, function), directly or transitively. + // components have been edited. This is a very coarse approach. But it's + // also only a temporary solution until Action IDs are unique per + // implementation. Remove this once Action IDs hash the implementation. const hmrRefreshHash = workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' ? workUnitStore.hmrRefreshHash From d624a61a6488dd5d3d6b9d040373269ee9e540a6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 15:01:23 +0100 Subject: [PATCH 03/10] Send previous HMR refresh hash as header --- .../client/components/app-router-headers.ts | 8 +++- .../router-reducer/fetch-server-response.ts | 42 +++++++++++++++++-- .../next/src/server/app-render/app-render.tsx | 30 +++++++------ .../work-unit-async-storage.external.ts | 10 ++++- .../src/server/async-storage/request-store.ts | 13 +++--- packages/next/src/server/lib/patch-fetch.ts | 2 +- .../src/server/use-cache/use-cache-wrapper.ts | 25 ++++++++--- 7 files changed, 98 insertions(+), 32 deletions(-) diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index 1f99f5efe1ce3..812efe2464ded 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -11,7 +11,10 @@ export const NEXT_ROUTER_PREFETCH_HEADER = 'Next-Router-Prefetch' as const // be merged into a single enum. 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_CURRENT_HEADER = + 'Next-HMR-Refresh-Hash-Current' as const +export const NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER = + 'Next-HMR-Refresh-Hash-Previous' as const export const NEXT_URL = 'Next-Url' as const export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const @@ -19,7 +22,8 @@ export const FLIGHT_HEADERS = [ RSC_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, NEXT_ROUTER_PREFETCH_HEADER, - NEXT_HMR_REFRESH_HEADER, + NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, + NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, ] as const diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 200fa9dbd3326..020b683a64453 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -24,7 +24,8 @@ import { NEXT_URL, RSC_HEADER, RSC_CONTENT_TYPE_HEADER, - NEXT_HMR_REFRESH_HEADER, + NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, + NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, NEXT_DID_POSTPONE_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, } from '../app-router-headers' @@ -61,11 +62,14 @@ export type RequestHeaders = { [NEXT_ROUTER_PREFETCH_HEADER]?: '1' [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string 'x-deployment-id'?: string - [NEXT_HMR_REFRESH_HEADER]?: string + [NEXT_HMR_REFRESH_HASH_CURRENT_HEADER]?: string + [NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER]?: string // A header that is only added in test mode to assert on fetch priority 'Next-Test-Fetch-Priority'?: RequestInit['priority'] } +const HMR_REFRESH_HASH_SESSION_STORAGE_KEY = '__next_hmr_refresh_hash__' + export function urlToUrlWithoutFlightMarker(url: string): URL { const urlWithoutFlightParameters = new URL(url, location.origin) urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY) @@ -141,8 +145,38 @@ export async function fetchServerResponse( headers[NEXT_ROUTER_PREFETCH_HEADER] = '1' } - if (process.env.NODE_ENV === 'development' && options.hmrRefreshHash) { - headers[NEXT_HMR_REFRESH_HEADER] = options.hmrRefreshHash + if (process.env.NODE_ENV === 'development') { + if (options.hmrRefreshHash) { + // When the current HMR refresh hash is passed in, we send it as a header + // to the server so that + // 1) the request is identified by the server as an HMR refresh request + // (e.g. to enable the server components HMR cache), and + // 2) it can be included in cache keys for "use cache" cache entries. + headers[NEXT_HMR_REFRESH_HASH_CURRENT_HEADER] = options.hmrRefreshHash + + try { + sessionStorage.setItem( + HMR_REFRESH_HASH_SESSION_STORAGE_KEY, + options.hmrRefreshHash + ) + } catch {} + } else { + // Otherwise we send the previous HMR refresh hash, if present. This + // ensures that, when the page is reloaded, the server can retrieve cache + // entries that include the hash from the previous HMR refresh request in + // their cache key. This is a separate header than the one above, because + // we do not want to mark this request as an HMR refresh. + try { + const previousHmrRefreshHash = sessionStorage.getItem( + HMR_REFRESH_HASH_SESSION_STORAGE_KEY + ) + + if (previousHmrRefreshHash) { + headers[NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER] = + previousHmrRefreshHash + } + } catch {} + } } if (nextUrl) { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 516cded839fd6..019d2182a2d50 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -46,7 +46,8 @@ import { } from '../stream-utils/node-web-streams-helper' import { stripInternalQueries } from '../internal-utils' import { - NEXT_HMR_REFRESH_HEADER, + NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, + NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, @@ -243,7 +244,10 @@ interface ParsedRequestHeaders { readonly isPrefetchRequest: boolean readonly isRouteTreePrefetchRequest: boolean readonly isDevWarmupRequest: boolean - readonly hmrRefreshHash: string | undefined + readonly hmrRefreshHashes: readonly [ + current: string | undefined, + previous: string | undefined, + ] readonly isRSCRequest: boolean readonly nonce: string | undefined } @@ -259,14 +263,14 @@ function parseRequestHeaders( isDevWarmupRequest || headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined - const hmrRefreshHeader = headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] - - const hmrRefreshHash = - typeof hmrRefreshHeader === 'string' - ? hmrRefreshHeader - : Array.isArray(hmrRefreshHeader) - ? hmrRefreshHeader.join('-') - : undefined + const hmrRefreshHashes = [ + headers[NEXT_HMR_REFRESH_HASH_CURRENT_HEADER.toLowerCase()] as + | string + | undefined, + headers[NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER.toLowerCase()] as + | string + | undefined, + ] as const // dev warmup requests are treated as prefetch RSC requests const isRSCRequest = @@ -296,7 +300,7 @@ function parseRequestHeaders( flightRouterState, isPrefetchRequest, isRouteTreePrefetchRequest, - hmrRefreshHash, + hmrRefreshHashes, isRSCRequest, isDevWarmupRequest, nonce, @@ -1258,7 +1262,7 @@ async function renderToHTMLOrFlightImpl( isPrefetchRequest, isRSCRequest, isDevWarmupRequest, - hmrRefreshHash, + hmrRefreshHashes, nonce, } = parsedRequestHeaders @@ -1438,7 +1442,7 @@ async function renderToHTMLOrFlightImpl( implicitTags, renderOpts.onUpdateCookies, renderOpts.previewProps, - hmrRefreshHash, + hmrRefreshHashes, serverComponentsHmrCache, renderResumeDataCache ) diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index f26c63a57a819..02ffefbfd3918 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -48,7 +48,10 @@ export type RequestStore = { readonly mutableCookies: ResponseCookies readonly userspaceMutableCookies: ResponseCookies readonly draftMode: DraftModeProvider - readonly hmrRefreshHash?: string + readonly hmrRefreshHashes?: readonly [ + current: string | undefined, + previous: string | undefined, + ] readonly serverComponentsHmrCache?: ServerComponentsHmrCache readonly implicitTags: string[] @@ -160,7 +163,10 @@ export type UseCacheStore = { explicitExpire: undefined | number // server expiration time explicitStale: undefined | number // client expiration time tags: null | string[] - readonly hmrRefreshHash: string | undefined + readonly hmrRefreshHashes?: readonly [ + current: string | undefined, + previous: string | undefined, + ] } & PhasePartial export type UnstableCacheStore = { diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 53a71a0f3dd99..9b1d9fcaceffe 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -64,7 +64,10 @@ type RequestContext = RequestResponsePair & { } phase: RequestStore['phase'] renderOpts?: WrapperRenderOpts - hmrRefreshHash?: string + hmrRefreshHashes?: readonly [ + current: string | undefined, + previous: string | undefined, + ] serverComponentsHmrCache?: ServerComponentsHmrCache implicitTags?: string[] | undefined } @@ -109,7 +112,7 @@ export function createRequestStoreForRender( implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHash: RequestContext['hmrRefreshHash'], + hmrRefreshHashes: RequestContext['hmrRefreshHashes'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'], renderResumeDataCache: RenderResumeDataCache | undefined ): RequestStore { @@ -123,7 +126,7 @@ export function createRequestStoreForRender( onUpdateCookies, renderResumeDataCache, previewProps, - hmrRefreshHash, + hmrRefreshHashes, serverComponentsHmrCache ) } @@ -159,7 +162,7 @@ function createRequestStoreImpl( onUpdateCookies: RenderOpts['onUpdateCookies'], renderResumeDataCache: RenderResumeDataCache | undefined, previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHash: RequestContext['hmrRefreshHash'], + hmrRefreshHashes: RequestContext['hmrRefreshHashes'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] ): RequestStore { function defaultOnUpdateCookies(cookies: string[]) { @@ -248,7 +251,7 @@ function createRequestStoreImpl( return cache.draftMode }, renderResumeDataCache: renderResumeDataCache ?? null, - hmrRefreshHash, + hmrRefreshHashes, serverComponentsHmrCache: serverComponentsHmrCache || (globalThis as any).__serverComponentsHmrCache, diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index e74c6b6f29e13..a8a23b38b8a36 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -755,7 +755,7 @@ export function createPatchedFetcher( // TODO: The serverComponentsHmrCache should also be available and // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.hmrRefreshHash !== undefined && + requestStore?.hmrRefreshHashes?.[0] !== undefined && requestStore.serverComponentsHmrCache ) { cachedFetchData = diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index befa2ee73b027..1ee1080ba1815 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -130,10 +130,10 @@ function generateCacheEntryWithCacheContext( ) } - const hmrRefreshHash = + const hmrRefreshHashes = outerWorkUnitStore?.type === 'request' || outerWorkUnitStore?.type === 'cache' - ? outerWorkUnitStore.hmrRefreshHash + ? outerWorkUnitStore.hmrRefreshHashes : undefined // Initialize the Store for this Cache entry. @@ -152,7 +152,7 @@ function generateCacheEntryWithCacheContext( explicitExpire: undefined, explicitStale: undefined, tags: null, - hmrRefreshHash, + hmrRefreshHashes, } return workUnitAsyncStorage.run( @@ -529,11 +529,26 @@ export function cache( // components have been edited. This is a very coarse approach. But it's // also only a temporary solution until Action IDs are unique per // implementation. Remove this once Action IDs hash the implementation. - const hmrRefreshHash = + let hmrRefreshHash: string | undefined + + if (workUnitStore?.type === 'request') { + console.log('COOKIE!', workUnitStore.cookies.get('test')) + } + const hmrRefreshHashes = workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' - ? workUnitStore.hmrRefreshHash + ? workUnitStore.hmrRefreshHashes : undefined + if (hmrRefreshHashes) { + const [current, previous] = hmrRefreshHashes + // If the current hash is present, it means that this is an HMR refresh + // request, so we want to ignore previous cache entries and create new + // ones, to avoid showing stale content. Otherwise, if the previous hash + // is present, we want to use that one to ensure existing cache entries + // are retrieved when the page is reloaded after an HMR refresh. + hmrRefreshHash = current ?? previous + } + const hangingInputAbortSignal = workUnitStore?.type === 'prerender' ? createHangingInputAbortSignal(workUnitStore) From a683d5abc2a21472cffc348ce10f08412a96753b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 15:01:50 +0100 Subject: [PATCH 04/10] Revert "Send previous HMR refresh hash as header" This reverts commit ff9a4c574ba22dc44d7b8b151c68278ef30f362f. --- .../client/components/app-router-headers.ts | 8 +--- .../router-reducer/fetch-server-response.ts | 42 ++----------------- .../next/src/server/app-render/app-render.tsx | 30 ++++++------- .../work-unit-async-storage.external.ts | 10 +---- .../src/server/async-storage/request-store.ts | 13 +++--- packages/next/src/server/lib/patch-fetch.ts | 2 +- .../src/server/use-cache/use-cache-wrapper.ts | 25 +++-------- 7 files changed, 32 insertions(+), 98 deletions(-) diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index 812efe2464ded..1f99f5efe1ce3 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -11,10 +11,7 @@ export const NEXT_ROUTER_PREFETCH_HEADER = 'Next-Router-Prefetch' as const // be merged into a single enum. export const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER = 'Next-Router-Segment-Prefetch' as const -export const NEXT_HMR_REFRESH_HASH_CURRENT_HEADER = - 'Next-HMR-Refresh-Hash-Current' as const -export const NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER = - 'Next-HMR-Refresh-Hash-Previous' as const +export const NEXT_HMR_REFRESH_HEADER = 'Next-HMR-Refresh' as const export const NEXT_URL = 'Next-Url' as const export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const @@ -22,8 +19,7 @@ export const FLIGHT_HEADERS = [ RSC_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, NEXT_ROUTER_PREFETCH_HEADER, - NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, - NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, + NEXT_HMR_REFRESH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, ] as const diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 020b683a64453..200fa9dbd3326 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -24,8 +24,7 @@ import { NEXT_URL, RSC_HEADER, RSC_CONTENT_TYPE_HEADER, - NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, - NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, + NEXT_HMR_REFRESH_HEADER, NEXT_DID_POSTPONE_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, } from '../app-router-headers' @@ -62,14 +61,11 @@ export type RequestHeaders = { [NEXT_ROUTER_PREFETCH_HEADER]?: '1' [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string 'x-deployment-id'?: string - [NEXT_HMR_REFRESH_HASH_CURRENT_HEADER]?: string - [NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER]?: string + [NEXT_HMR_REFRESH_HEADER]?: string // A header that is only added in test mode to assert on fetch priority 'Next-Test-Fetch-Priority'?: RequestInit['priority'] } -const HMR_REFRESH_HASH_SESSION_STORAGE_KEY = '__next_hmr_refresh_hash__' - export function urlToUrlWithoutFlightMarker(url: string): URL { const urlWithoutFlightParameters = new URL(url, location.origin) urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY) @@ -145,38 +141,8 @@ export async function fetchServerResponse( headers[NEXT_ROUTER_PREFETCH_HEADER] = '1' } - if (process.env.NODE_ENV === 'development') { - if (options.hmrRefreshHash) { - // When the current HMR refresh hash is passed in, we send it as a header - // to the server so that - // 1) the request is identified by the server as an HMR refresh request - // (e.g. to enable the server components HMR cache), and - // 2) it can be included in cache keys for "use cache" cache entries. - headers[NEXT_HMR_REFRESH_HASH_CURRENT_HEADER] = options.hmrRefreshHash - - try { - sessionStorage.setItem( - HMR_REFRESH_HASH_SESSION_STORAGE_KEY, - options.hmrRefreshHash - ) - } catch {} - } else { - // Otherwise we send the previous HMR refresh hash, if present. This - // ensures that, when the page is reloaded, the server can retrieve cache - // entries that include the hash from the previous HMR refresh request in - // their cache key. This is a separate header than the one above, because - // we do not want to mark this request as an HMR refresh. - try { - const previousHmrRefreshHash = sessionStorage.getItem( - HMR_REFRESH_HASH_SESSION_STORAGE_KEY - ) - - if (previousHmrRefreshHash) { - headers[NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER] = - previousHmrRefreshHash - } - } catch {} - } + if (process.env.NODE_ENV === 'development' && options.hmrRefreshHash) { + headers[NEXT_HMR_REFRESH_HEADER] = options.hmrRefreshHash } if (nextUrl) { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 019d2182a2d50..516cded839fd6 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -46,8 +46,7 @@ import { } from '../stream-utils/node-web-streams-helper' import { stripInternalQueries } from '../internal-utils' import { - NEXT_HMR_REFRESH_HASH_CURRENT_HEADER, - NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER, + NEXT_HMR_REFRESH_HEADER, NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_STATE_TREE_HEADER, NEXT_ROUTER_STALE_TIME_HEADER, @@ -244,10 +243,7 @@ interface ParsedRequestHeaders { readonly isPrefetchRequest: boolean readonly isRouteTreePrefetchRequest: boolean readonly isDevWarmupRequest: boolean - readonly hmrRefreshHashes: readonly [ - current: string | undefined, - previous: string | undefined, - ] + readonly hmrRefreshHash: string | undefined readonly isRSCRequest: boolean readonly nonce: string | undefined } @@ -263,14 +259,14 @@ function parseRequestHeaders( isDevWarmupRequest || headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined - const hmrRefreshHashes = [ - headers[NEXT_HMR_REFRESH_HASH_CURRENT_HEADER.toLowerCase()] as - | string - | undefined, - headers[NEXT_HMR_REFRESH_HASH_PREVIOUS_HEADER.toLowerCase()] as - | string - | undefined, - ] as const + const hmrRefreshHeader = headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] + + const hmrRefreshHash = + typeof hmrRefreshHeader === 'string' + ? hmrRefreshHeader + : Array.isArray(hmrRefreshHeader) + ? hmrRefreshHeader.join('-') + : undefined // dev warmup requests are treated as prefetch RSC requests const isRSCRequest = @@ -300,7 +296,7 @@ function parseRequestHeaders( flightRouterState, isPrefetchRequest, isRouteTreePrefetchRequest, - hmrRefreshHashes, + hmrRefreshHash, isRSCRequest, isDevWarmupRequest, nonce, @@ -1262,7 +1258,7 @@ async function renderToHTMLOrFlightImpl( isPrefetchRequest, isRSCRequest, isDevWarmupRequest, - hmrRefreshHashes, + hmrRefreshHash, nonce, } = parsedRequestHeaders @@ -1442,7 +1438,7 @@ async function renderToHTMLOrFlightImpl( implicitTags, renderOpts.onUpdateCookies, renderOpts.previewProps, - hmrRefreshHashes, + hmrRefreshHash, serverComponentsHmrCache, renderResumeDataCache ) diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 02ffefbfd3918..f26c63a57a819 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -48,10 +48,7 @@ export type RequestStore = { readonly mutableCookies: ResponseCookies readonly userspaceMutableCookies: ResponseCookies readonly draftMode: DraftModeProvider - readonly hmrRefreshHashes?: readonly [ - current: string | undefined, - previous: string | undefined, - ] + readonly hmrRefreshHash?: string readonly serverComponentsHmrCache?: ServerComponentsHmrCache readonly implicitTags: string[] @@ -163,10 +160,7 @@ export type UseCacheStore = { explicitExpire: undefined | number // server expiration time explicitStale: undefined | number // client expiration time tags: null | string[] - readonly hmrRefreshHashes?: readonly [ - current: string | undefined, - previous: string | undefined, - ] + readonly hmrRefreshHash: string | undefined } & PhasePartial export type UnstableCacheStore = { diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 9b1d9fcaceffe..53a71a0f3dd99 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -64,10 +64,7 @@ type RequestContext = RequestResponsePair & { } phase: RequestStore['phase'] renderOpts?: WrapperRenderOpts - hmrRefreshHashes?: readonly [ - current: string | undefined, - previous: string | undefined, - ] + hmrRefreshHash?: string serverComponentsHmrCache?: ServerComponentsHmrCache implicitTags?: string[] | undefined } @@ -112,7 +109,7 @@ export function createRequestStoreForRender( implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHashes: RequestContext['hmrRefreshHashes'], + hmrRefreshHash: RequestContext['hmrRefreshHash'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'], renderResumeDataCache: RenderResumeDataCache | undefined ): RequestStore { @@ -126,7 +123,7 @@ export function createRequestStoreForRender( onUpdateCookies, renderResumeDataCache, previewProps, - hmrRefreshHashes, + hmrRefreshHash, serverComponentsHmrCache ) } @@ -162,7 +159,7 @@ function createRequestStoreImpl( onUpdateCookies: RenderOpts['onUpdateCookies'], renderResumeDataCache: RenderResumeDataCache | undefined, previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHashes: RequestContext['hmrRefreshHashes'], + hmrRefreshHash: RequestContext['hmrRefreshHash'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] ): RequestStore { function defaultOnUpdateCookies(cookies: string[]) { @@ -251,7 +248,7 @@ function createRequestStoreImpl( return cache.draftMode }, renderResumeDataCache: renderResumeDataCache ?? null, - hmrRefreshHashes, + hmrRefreshHash, serverComponentsHmrCache: serverComponentsHmrCache || (globalThis as any).__serverComponentsHmrCache, diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index a8a23b38b8a36..e74c6b6f29e13 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -755,7 +755,7 @@ export function createPatchedFetcher( // TODO: The serverComponentsHmrCache should also be available and // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.hmrRefreshHashes?.[0] !== undefined && + requestStore?.hmrRefreshHash !== undefined && requestStore.serverComponentsHmrCache ) { cachedFetchData = diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 1ee1080ba1815..befa2ee73b027 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -130,10 +130,10 @@ function generateCacheEntryWithCacheContext( ) } - const hmrRefreshHashes = + const hmrRefreshHash = outerWorkUnitStore?.type === 'request' || outerWorkUnitStore?.type === 'cache' - ? outerWorkUnitStore.hmrRefreshHashes + ? outerWorkUnitStore.hmrRefreshHash : undefined // Initialize the Store for this Cache entry. @@ -152,7 +152,7 @@ function generateCacheEntryWithCacheContext( explicitExpire: undefined, explicitStale: undefined, tags: null, - hmrRefreshHashes, + hmrRefreshHash, } return workUnitAsyncStorage.run( @@ -529,26 +529,11 @@ export function cache( // components have been edited. This is a very coarse approach. But it's // also only a temporary solution until Action IDs are unique per // implementation. Remove this once Action IDs hash the implementation. - let hmrRefreshHash: string | undefined - - if (workUnitStore?.type === 'request') { - console.log('COOKIE!', workUnitStore.cookies.get('test')) - } - const hmrRefreshHashes = + const hmrRefreshHash = workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' - ? workUnitStore.hmrRefreshHashes + ? workUnitStore.hmrRefreshHash : undefined - if (hmrRefreshHashes) { - const [current, previous] = hmrRefreshHashes - // If the current hash is present, it means that this is an HMR refresh - // request, so we want to ignore previous cache entries and create new - // ones, to avoid showing stale content. Otherwise, if the previous hash - // is present, we want to use that one to ensure existing cache entries - // are retrieved when the page is reloaded after an HMR refresh. - hmrRefreshHash = current ?? previous - } - const hangingInputAbortSignal = workUnitStore?.type === 'prerender' ? createHangingInputAbortSignal(workUnitStore) From 0c5d69d9737e6a141c2959b65e04bd3d945a3937 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 15:36:32 +0100 Subject: [PATCH 05/10] Store HMR refresh hash in a session cookie --- .../next/src/client/components/app-router.tsx | 3 +-- .../app/hot-reloader-client.tsx | 10 ++++++++-- .../router-reducer/fetch-server-response.ts | 8 ++++---- .../reducers/hmr-refresh-reducer.ts | 4 ++-- .../router-reducer/router-reducer-types.ts | 1 - .../next/src/server/app-render/app-render.tsx | 18 ++++++------------ .../work-unit-async-storage.external.ts | 12 +++++++++++- .../src/server/async-storage/request-store.ts | 12 ++++++------ packages/next/src/server/lib/patch-fetch.ts | 2 +- .../src/server/use-cache/use-cache-wrapper.ts | 14 +++----------- .../lib/app-router-context.shared-runtime.ts | 2 +- 11 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 9e136619a5ccd..34728ebd2b357 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -324,7 +324,7 @@ function Router({ }) }) }, - hmrRefresh: (hash) => { + hmrRefresh: () => { if (process.env.NODE_ENV !== 'development') { throw new Error( 'hmrRefresh can only be used in development mode. Please use refresh instead.' @@ -334,7 +334,6 @@ function Router({ dispatch({ type: ACTION_HMR_REFRESH, origin: window.location.origin, - hash, }) }) } 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 4a87bfa2162b2..01aee726578b0 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 @@ -484,13 +484,19 @@ function processMessage( hash: obj.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__=${obj.hash}` + if (RuntimeErrorHandler.hadRuntimeError) { if (reloading) return reloading = true return window.location.reload() } + startTransition(() => { - router.hmrRefresh(obj.hash) + router.hmrRefresh() dispatcher.onRefresh() }) @@ -517,7 +523,7 @@ function processMessage( case HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE: case HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE: { // TODO-APP: potentially only refresh if the currently viewed page was added/removed. - return router.hmrRefresh(obj.hash) + return router.hmrRefresh() } case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: { const { errorJSON } = obj diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 200fa9dbd3326..97c4916d26029 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -42,7 +42,7 @@ export interface FetchServerResponseOptions { readonly flightRouterState: FlightRouterState readonly nextUrl: string | null readonly prefetchKind?: PrefetchKind - readonly hmrRefreshHash?: string + readonly isHmrRefresh?: boolean } export type FetchServerResponseResult = { @@ -61,7 +61,7 @@ export type RequestHeaders = { [NEXT_ROUTER_PREFETCH_HEADER]?: '1' [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string 'x-deployment-id'?: string - [NEXT_HMR_REFRESH_HEADER]?: string + [NEXT_HMR_REFRESH_HEADER]?: '1' // A header that is only added in test mode to assert on fetch priority 'Next-Test-Fetch-Priority'?: RequestInit['priority'] } @@ -141,8 +141,8 @@ export async function fetchServerResponse( headers[NEXT_ROUTER_PREFETCH_HEADER] = '1' } - if (process.env.NODE_ENV === 'development' && options.hmrRefreshHash) { - headers[NEXT_HMR_REFRESH_HEADER] = options.hmrRefreshHash + if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) { + headers[NEXT_HMR_REFRESH_HEADER] = '1' } if (nextUrl) { diff --git a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts index 9aafeaeabd94c..533a9570a8293 100644 --- a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts @@ -21,7 +21,7 @@ function hmrRefreshReducerImpl( state: ReadonlyReducerState, action: HmrRefreshAction ): ReducerState { - const { origin, hash } = action + const { origin } = action const mutable: Mutable = {} const href = state.canonicalUrl @@ -37,7 +37,7 @@ function hmrRefreshReducerImpl( cache.lazyData = fetchServerResponse(new URL(href, origin), { flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'], nextUrl: includeNextUrl ? state.nextUrl : null, - hmrRefreshHash: hash, + isHmrRefresh: true, }) return cache.lazyData.then( diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 384ff2e0e5e6a..7d8e50d314151 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -58,7 +58,6 @@ export interface RefreshAction { export interface HmrRefreshAction { type: typeof ACTION_HMR_REFRESH origin: Location['origin'] - hash: string } export type ServerActionDispatcher = ( diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 516cded839fd6..32aaed3bbb837 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -243,7 +243,7 @@ interface ParsedRequestHeaders { readonly isPrefetchRequest: boolean readonly isRouteTreePrefetchRequest: boolean readonly isDevWarmupRequest: boolean - readonly hmrRefreshHash: string | undefined + readonly isHmrRefresh: boolean readonly isRSCRequest: boolean readonly nonce: string | undefined } @@ -259,14 +259,8 @@ function parseRequestHeaders( isDevWarmupRequest || headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined - const hmrRefreshHeader = headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] - - const hmrRefreshHash = - typeof hmrRefreshHeader === 'string' - ? hmrRefreshHeader - : Array.isArray(hmrRefreshHeader) - ? hmrRefreshHeader.join('-') - : undefined + const isHmrRefresh = + headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] !== undefined // dev warmup requests are treated as prefetch RSC requests const isRSCRequest = @@ -296,7 +290,7 @@ function parseRequestHeaders( flightRouterState, isPrefetchRequest, isRouteTreePrefetchRequest, - hmrRefreshHash, + isHmrRefresh, isRSCRequest, isDevWarmupRequest, nonce, @@ -1258,7 +1252,7 @@ async function renderToHTMLOrFlightImpl( isPrefetchRequest, isRSCRequest, isDevWarmupRequest, - hmrRefreshHash, + isHmrRefresh, nonce, } = parsedRequestHeaders @@ -1438,7 +1432,7 @@ async function renderToHTMLOrFlightImpl( implicitTags, renderOpts.onUpdateCookies, renderOpts.previewProps, - hmrRefreshHash, + isHmrRefresh, serverComponentsHmrCache, renderResumeDataCache ) diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index f26c63a57a819..03b80d428cd79 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -48,7 +48,7 @@ export type RequestStore = { readonly mutableCookies: ResponseCookies readonly userspaceMutableCookies: ResponseCookies readonly draftMode: DraftModeProvider - readonly hmrRefreshHash?: string + readonly isHmrRefresh?: boolean readonly serverComponentsHmrCache?: ServerComponentsHmrCache readonly implicitTags: string[] @@ -244,3 +244,13 @@ export function getRenderResumeDataCache( return null } + +export function getHmrRefreshHash( + workUnitStore: WorkUnitStore +): string | undefined { + return workUnitStore.type === 'cache' + ? workUnitStore.hmrRefreshHash + : workUnitStore.type === 'request' + ? workUnitStore.cookies.get('__next_hmr_refresh_hash__')?.value + : undefined +} diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 53a71a0f3dd99..499d91889d016 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -64,7 +64,7 @@ type RequestContext = RequestResponsePair & { } phase: RequestStore['phase'] renderOpts?: WrapperRenderOpts - hmrRefreshHash?: string + isHmrRefresh?: boolean serverComponentsHmrCache?: ServerComponentsHmrCache implicitTags?: string[] | undefined } @@ -109,7 +109,7 @@ export function createRequestStoreForRender( implicitTags: RequestContext['implicitTags'], onUpdateCookies: RenderOpts['onUpdateCookies'], previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHash: RequestContext['hmrRefreshHash'], + isHmrRefresh: RequestContext['isHmrRefresh'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'], renderResumeDataCache: RenderResumeDataCache | undefined ): RequestStore { @@ -123,7 +123,7 @@ export function createRequestStoreForRender( onUpdateCookies, renderResumeDataCache, previewProps, - hmrRefreshHash, + isHmrRefresh, serverComponentsHmrCache ) } @@ -145,7 +145,7 @@ export function createRequestStoreForAPI( onUpdateCookies, undefined, previewProps, - undefined, + false, undefined ) } @@ -159,7 +159,7 @@ function createRequestStoreImpl( onUpdateCookies: RenderOpts['onUpdateCookies'], renderResumeDataCache: RenderResumeDataCache | undefined, previewProps: WrapperRenderOpts['previewProps'], - hmrRefreshHash: RequestContext['hmrRefreshHash'], + isHmrRefresh: RequestContext['isHmrRefresh'], serverComponentsHmrCache: RequestContext['serverComponentsHmrCache'] ): RequestStore { function defaultOnUpdateCookies(cookies: string[]) { @@ -248,7 +248,7 @@ function createRequestStoreImpl( return cache.draftMode }, renderResumeDataCache: renderResumeDataCache ?? null, - hmrRefreshHash, + isHmrRefresh, serverComponentsHmrCache: serverComponentsHmrCache || (globalThis as any).__serverComponentsHmrCache, diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index e74c6b6f29e13..b1169729c90c2 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -755,7 +755,7 @@ export function createPatchedFetcher( // TODO: The serverComponentsHmrCache should also be available and // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.hmrRefreshHash !== undefined && + requestStore?.isHmrRefresh !== undefined && requestStore.serverComponentsHmrCache ) { cachedFetchData = diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index befa2ee73b027..4762c7e3b9e6d 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -20,6 +20,7 @@ import type { WorkUnitStore, } from '../app-render/work-unit-async-storage.external' import { + getHmrRefreshHash, getRenderResumeDataCache, getPrerenderResumeDataCache, workUnitAsyncStorage, @@ -130,12 +131,6 @@ function generateCacheEntryWithCacheContext( ) } - const hmrRefreshHash = - outerWorkUnitStore?.type === 'request' || - outerWorkUnitStore?.type === 'cache' - ? outerWorkUnitStore.hmrRefreshHash - : undefined - // Initialize the Store for this Cache entry. const cacheStore: UseCacheStore = { type: 'cache', @@ -152,7 +147,7 @@ function generateCacheEntryWithCacheContext( explicitExpire: undefined, explicitStale: undefined, tags: null, - hmrRefreshHash, + hmrRefreshHash: outerWorkUnitStore && getHmrRefreshHash(outerWorkUnitStore), } return workUnitAsyncStorage.run( @@ -529,10 +524,7 @@ export function cache( // components have been edited. This is a very coarse approach. But it's // also only a temporary solution until Action IDs are unique per // implementation. Remove this once Action IDs hash the implementation. - const hmrRefreshHash = - workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' - ? workUnitStore.hmrRefreshHash - : undefined + const hmrRefreshHash = workUnitStore && getHmrRefreshHash(workUnitStore) const hangingInputAbortSignal = workUnitStore?.type === 'prerender' diff --git a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts index b262335504a00..ff273f669bbc4 100644 --- a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts @@ -133,7 +133,7 @@ export interface AppRouterInstance { * Refresh the current page. Use in development only. * @internal */ - hmrRefresh(hash: string): void + hmrRefresh(): void /** * Navigate to the provided href. * Pushes a new history entry. From 6ee6522bde561f6a49190ca79d05f619012ee21b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 15:42:54 +0100 Subject: [PATCH 06/10] Revert accidental change in `patch-fetch` --- packages/next/src/server/lib/patch-fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index b1169729c90c2..cd556e0326aae 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -755,7 +755,7 @@ export function createPatchedFetcher( // TODO: The serverComponentsHmrCache should also be available and // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.isHmrRefresh !== undefined && + requestStore?.isHmrRefresh && requestStore.serverComponentsHmrCache ) { cachedFetchData = From 5e3778abb5ee7c48930c414941537bc0e206af20 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 16:05:42 +0100 Subject: [PATCH 07/10] Revert changes to `ADDED_PAGE`/`REMOVED_PAGE` --- packages/next/src/server/dev/hot-reloader-types.ts | 2 -- packages/next/src/server/dev/hot-reloader-webpack.ts | 2 -- packages/next/src/server/lib/router-utils/setup-dev-bundler.ts | 2 -- 3 files changed, 6 deletions(-) diff --git a/packages/next/src/server/dev/hot-reloader-types.ts b/packages/next/src/server/dev/hot-reloader-types.ts index da6098096dbb5..61f05a17db97d 100644 --- a/packages/next/src/server/dev/hot-reloader-types.ts +++ b/packages/next/src/server/dev/hot-reloader-types.ts @@ -67,13 +67,11 @@ interface BuiltAction { interface AddedPageAction { action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE data: [page: string | null] - hash: string } interface RemovedPageAction { action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE data: [page: string | null] - hash: string } export interface ReloadPageAction { diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 9d0555d25e092..0f821d0bc7fe3 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -1444,7 +1444,6 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE, data: [page], - hash: stats.hash, }) } } @@ -1455,7 +1454,6 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE, data: [page], - hash: stats.hash, }) } } 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 33ce06e2a49e1..9ad83a12dba38 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 @@ -863,7 +863,6 @@ async function startWatcher(opts: SetupOpts) { hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE, data: [route], - hash: 'TODO', }) }) @@ -871,7 +870,6 @@ async function startWatcher(opts: SetupOpts) { hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE, data: [route], - hash: 'TODO', }) }) } From c82a642fea81c24e52c0001c451a833a138d2169 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 17:38:42 +0100 Subject: [PATCH 08/10] Make server component HMR cache work inside `"use cache"` --- .../work-unit-async-storage.external.ts | 2 ++ packages/next/src/server/lib/patch-fetch.ts | 24 ++++++++----------- .../src/server/use-cache/use-cache-wrapper.ts | 8 +++++++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 03b80d428cd79..027c6582dc6e6 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -161,6 +161,8 @@ export type UseCacheStore = { explicitStale: undefined | number // client expiration time tags: null | string[] readonly hmrRefreshHash: string | undefined + readonly isHmrRefresh: boolean + readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined } & PhasePartial export type UnstableCacheStore = { diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index cd556e0326aae..37c899a936276 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -15,10 +15,7 @@ import { markCurrentScopeAsDynamic } from '../app-render/dynamic-rendering' import { makeHangingPromise } from '../dynamic-rendering-utils' import type { FetchMetric } from '../base-http' import { createDedupeFetch } from './dedupe-fetch' -import type { - WorkUnitAsyncStorage, - RequestStore, -} from '../app-render/work-unit-async-storage.external' +import type { WorkUnitAsyncStorage } from '../app-render/work-unit-async-storage.external' import { CachedRouteKind, IncrementalCacheKind, @@ -537,14 +534,15 @@ export function createPatchedFetcher( let cacheKey: string | undefined const { incrementalCache } = workStore - const requestStore: undefined | RequestStore = - workUnitStore !== undefined && workUnitStore.type === 'request' + const useCacheOrRequestStore = + workUnitStore?.type === 'request' || workUnitStore?.type === 'cache' ? workUnitStore : undefined if ( incrementalCache && - (isCacheableRevalidate || requestStore?.serverComponentsHmrCache) + (isCacheableRevalidate || + useCacheOrRequestStore?.serverComponentsHmrCache) ) { try { cacheKey = await incrementalCache.generateCacheKey( @@ -631,7 +629,7 @@ export function createPatchedFetcher( incrementalCache && cacheKey && (isCacheableRevalidate || - requestStore?.serverComponentsHmrCache) + useCacheOrRequestStore?.serverComponentsHmrCache) ) { const normalizedRevalidate = finalRevalidate >= INFINITE_CACHE @@ -701,7 +699,7 @@ export function createPatchedFetcher( url: cloned1.url, } - requestStore?.serverComponentsHmrCache?.set( + useCacheOrRequestStore?.serverComponentsHmrCache?.set( cacheKey, fetchedData ) @@ -752,14 +750,12 @@ export function createPatchedFetcher( if (cacheKey && incrementalCache) { let cachedFetchData: CachedFetchData | undefined - // TODO: The serverComponentsHmrCache should also be available and - // utilized if the workUnitStore is a UseCacheStore. if ( - requestStore?.isHmrRefresh && - requestStore.serverComponentsHmrCache + useCacheOrRequestStore?.isHmrRefresh && + useCacheOrRequestStore.serverComponentsHmrCache ) { cachedFetchData = - requestStore.serverComponentsHmrCache.get(cacheKey) + useCacheOrRequestStore.serverComponentsHmrCache.get(cacheKey) isHmrRefreshCache = true } diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 4762c7e3b9e6d..3adb8b6915d85 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -131,6 +131,12 @@ function generateCacheEntryWithCacheContext( ) } + const useCacheOrRequestStore = + outerWorkUnitStore?.type === 'request' || + outerWorkUnitStore?.type === 'cache' + ? outerWorkUnitStore + : undefined + // Initialize the Store for this Cache entry. const cacheStore: UseCacheStore = { type: 'cache', @@ -148,6 +154,8 @@ function generateCacheEntryWithCacheContext( explicitStale: undefined, tags: null, hmrRefreshHash: outerWorkUnitStore && getHmrRefreshHash(outerWorkUnitStore), + isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false, + serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache, } return workUnitAsyncStorage.run( From 4774ba2aed59ee1e56509a5476bbf67cee1fa1e6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 30 Jan 2025 18:28:02 +0100 Subject: [PATCH 09/10] Add a dev test --- .../app-dir/use-cache-hmr/app/layout.tsx | 8 ++ .../app-dir/use-cache-hmr/app/page.tsx | 19 ++++ .../app-dir/use-cache-hmr/next.config.js | 15 +++ .../use-cache-hmr/use-cache-hmr.test.ts | 104 ++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 test/development/app-dir/use-cache-hmr/app/layout.tsx create mode 100644 test/development/app-dir/use-cache-hmr/app/page.tsx create mode 100644 test/development/app-dir/use-cache-hmr/next.config.js create mode 100644 test/development/app-dir/use-cache-hmr/use-cache-hmr.test.ts diff --git a/test/development/app-dir/use-cache-hmr/app/layout.tsx b/test/development/app-dir/use-cache-hmr/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/development/app-dir/use-cache-hmr/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/development/app-dir/use-cache-hmr/app/page.tsx b/test/development/app-dir/use-cache-hmr/app/page.tsx new file mode 100644 index 0000000000000..647857f5494fb --- /dev/null +++ b/test/development/app-dir/use-cache-hmr/app/page.tsx @@ -0,0 +1,19 @@ +async function getData() { + 'use cache' + + return fetch('https://next-data-api-endpoint.vercel.app/api/random').then( + (res) => res.text().then((text) => [text, 'foo', Math.random()] as const) + ) +} + +export default async function Page() { + const [fetchedRandom, text, mathRandom] = await getData() + + return ( + <> +

{fetchedRandom}

+

{text}

+

{mathRandom}

+ + ) +} diff --git a/test/development/app-dir/use-cache-hmr/next.config.js b/test/development/app-dir/use-cache-hmr/next.config.js new file mode 100644 index 0000000000000..441c4c002a6b3 --- /dev/null +++ b/test/development/app-dir/use-cache-hmr/next.config.js @@ -0,0 +1,15 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + useCache: true, + }, + logging: { + fetches: { + hmrRefreshes: true, + }, + }, +} + +module.exports = nextConfig diff --git a/test/development/app-dir/use-cache-hmr/use-cache-hmr.test.ts b/test/development/app-dir/use-cache-hmr/use-cache-hmr.test.ts new file mode 100644 index 0000000000000..7d01c51bce992 --- /dev/null +++ b/test/development/app-dir/use-cache-hmr/use-cache-hmr.test.ts @@ -0,0 +1,104 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('use-cache-hmr', () => { + const { next, isTurbopack } = nextTestSetup({ + files: __dirname, + }) + + it('should update cached data after editing a file', async () => { + const browser = await next.browser('/') + + const [initialFetchedRandom, initialText, initialMathRandom] = + await Promise.all([ + browser.elementById('fetchedRandom').text(), + browser.elementById('text').text(), + browser.elementById('mathRandom').text(), + ]) + + expect(initialFetchedRandom).toMatch(/[0,1]\.\d+/) + expect(initialText).toBe('foo') + expect(initialMathRandom).toMatch(/[0,1]\.\d+/) + + // Edit something inside of "use cache" in the page.tsx file. + await next.patchFile('app/page.tsx', (content) => + content.replace('foo', 'bar') + ) + + let newFetchedRandom: string + let newText: string + let newMathRandom: string + + await retry(async () => { + ;[newFetchedRandom, newText, newMathRandom] = await Promise.all([ + browser.elementById('fetchedRandom').text(), + browser.elementById('text').text(), + browser.elementById('mathRandom').text(), + ]) + + // Cached via server components HMR cache: + expect(newFetchedRandom).toBe(initialFetchedRandom) + + // Edited value: + expect(newText).toBe('bar') + + // Newly computed value due to cache miss. + expect(newMathRandom).not.toBe(initialMathRandom) + expect(newMathRandom).toMatch(/[0,1]\.\d+/) + }) + + // Now revert the edit. + await next.patchFile('app/page.tsx', (content) => + content.replace('bar', 'foo') + ) + + await retry(async () => { + const [fetchedRandom, text, mathRandom] = await Promise.all([ + browser.elementById('fetchedRandom').text(), + browser.elementById('text').text(), + browser.elementById('mathRandom').text(), + ]) + + // Cached via server components HMR cache: + expect(fetchedRandom).toBe(initialFetchedRandom) + + // Edited value: + expect(text).toBe(initialText) + + // Newly computed value due to cache miss, because the initial request did + // not use an HMR hash for the cache key. + // TODO: Can we get a cache hit here? It's a micro optimization though. + expect(mathRandom).not.toBe(initialFetchedRandom) + expect(mathRandom).not.toBe(newMathRandom) + expect(mathRandom).toMatch(/[0,1]\.\d+/) + }) + + // Apply the initial edit again. + await next.patchFile( + 'app/page.tsx', + (content) => content.replace('foo', 'bar'), + async () => + retry(async () => { + const [fetchedRandom, text, mathRandom] = await Promise.all([ + browser.elementById('fetchedRandom').text(), + browser.elementById('text').text(), + browser.elementById('mathRandom').text(), + ]) + + // This should be a full cache hit now: + expect(fetchedRandom).toBe(newFetchedRandom) + expect(text).toBe(newText) + + if (isTurbopack) { + // TODO: Turbopack does not provide content hashes during HMR, so we + // actually get a cache miss. However, fetchedRandom is still cached + // because of the server components HMR cache. + expect(mathRandom).not.toBe(newMathRandom) + expect(mathRandom).toMatch(/[0,1]\.\d+/) + } else { + expect(mathRandom).toBe(newMathRandom) + } + }) + ) + }) +}) From 7ca6eb906b5073bc9a09b10127d1ffe2aa6c9ff9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 31 Jan 2025 09:13:56 +0100 Subject: [PATCH 10/10] Use type param instead of type cast --- .../next/src/server/use-cache/use-cache-wrapper.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 3adb8b6915d85..f30d809ce0697 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -296,12 +296,14 @@ async function generateCacheEntryImpl( ): Promise<[ReadableStream, Promise]> { const temporaryReferences = createServerTemporaryReferenceSet() - const cacheKeyParts = + const [, , , args] = typeof encodedArguments === 'string' - ? await decodeReply(encodedArguments, getServerModuleMap(), { - temporaryReferences, - }) - : await decodeReplyFromAsyncIterable( + ? await decodeReply( + encodedArguments, + getServerModuleMap(), + { temporaryReferences } + ) + : await decodeReplyFromAsyncIterable( { async *[Symbol.asyncIterator]() { for (const entry of encodedArguments) { @@ -331,8 +333,6 @@ async function generateCacheEntryImpl( { temporaryReferences } ) - const [, , , args] = cacheKeyParts as CacheKeyParts - // Track the timestamp when we started computing the result. const startTime = performance.timeOrigin + performance.now() // Invoke the inner function to load a new result.