Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include HMR refresh hash in "use cache" cache keys #75474

Open
wants to merge 10 commits into
base: canary
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -481,13 +481,20 @@ function processMessage(
JSON.stringify({
event: 'server-component-reload-page',
clientId: __nextDevClientId,
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()
dispatcher.onRefresh()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ export type UseCacheStore = {
explicitExpire: undefined | number // server expiration time
explicitStale: undefined | number // client expiration time
tags: null | string[]
readonly hmrRefreshHash: string | undefined
readonly isHmrRefresh: boolean
readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined
} & PhasePartial

export type UnstableCacheStore = {
Expand Down Expand Up @@ -243,3 +246,13 @@ export function getRenderResumeDataCache(

return null
}

unstubbable marked this conversation as resolved.
Show resolved Hide resolved
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
}
7 changes: 5 additions & 2 deletions packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,8 @@ export async function createHotReloaderTurbopack(
includeIssues: boolean,
endpoint: Endpoint,
makePayload: (
change: TurbopackResult
change: TurbopackResult,
hash: string
) => Promise<HMR_ACTION_TYPES> | HMR_ACTION_TYPES | void,
onError?: (
error: Error
Expand All @@ -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)
}
Expand Down Expand Up @@ -910,6 +912,7 @@ export async function createHotReloaderTurbopack(
await clearAllModuleContexts()
this.send({
action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,
hash: String(++hmrHash),
})
}
},
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/dev/hot-reloader-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface ReloadPageAction {

interface ServerComponentChangesAction {
action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES
hash: string
}

interface MiddlewareChangesAction {
Expand Down
7 changes: 4 additions & 3 deletions packages/next/src/server/dev/hot-reloader-webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,9 +407,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
}
}

protected async refreshServerComponents(): Promise<void> {
protected async refreshServerComponents(hash: string): Promise<void> {
this.send({
action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,
hash,
// TODO: granular reloading of changes
// entrypoints: serverComponentChanges,
})
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1401,7 +1402,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
reloadAfterInvalidation
) {
this.resetFetch()
this.refreshServerComponents()
this.refreshServerComponents(stats.hash)
}

changedClientPages.clear()
Expand Down
11 changes: 7 additions & 4 deletions packages/next/src/server/dev/turbopack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export type StartChangeSubscription = (
includeIssues: boolean,
endpoint: Endpoint,
makePayload: (
change: TurbopackResult
change: TurbopackResult,
hash: string
) => Promise<HMR_ACTION_TYPES> | HMR_ACTION_TYPES | void,
onError?: (e: Error) => Promise<HMR_ACTION_TYPES> | HMR_ACTION_TYPES | void
) => Promise<void>
Expand Down Expand Up @@ -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
Expand All @@ -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,
unstubbable marked this conversation as resolved.
Show resolved Hide resolved
data: `error in ${page} app-page subscription: ${e}`,
}
}
)
Expand Down
22 changes: 10 additions & 12 deletions packages/next/src/server/lib/patch-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -631,7 +629,7 @@ export function createPatchedFetcher(
incrementalCache &&
cacheKey &&
(isCacheableRevalidate ||
requestStore?.serverComponentsHmrCache)
useCacheOrRequestStore?.serverComponentsHmrCache)
) {
const normalizedRevalidate =
finalRevalidate >= INFINITE_CACHE
Expand Down Expand Up @@ -701,7 +699,7 @@ export function createPatchedFetcher(
url: cloned1.url,
}

requestStore?.serverComponentsHmrCache?.set(
useCacheOrRequestStore?.serverComponentsHmrCache?.set(
cacheKey,
fetchedData
)
Expand Down Expand Up @@ -753,11 +751,11 @@ export function createPatchedFetcher(
let cachedFetchData: CachedFetchData | undefined

if (
requestStore?.isHmrRefresh &&
requestStore.serverComponentsHmrCache
useCacheOrRequestStore?.isHmrRefresh &&
useCacheOrRequestStore.serverComponentsHmrCache
) {
cachedFetchData =
requestStore.serverComponentsHmrCache.get(cacheKey)
useCacheOrRequestStore.serverComponentsHmrCache.get(cacheKey)

isHmrRefreshCache = true
}
Expand Down
52 changes: 40 additions & 12 deletions packages/next/src/server/use-cache/use-cache-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
WorkUnitStore,
} from '../app-render/work-unit-async-storage.external'
import {
getHmrRefreshHash,
getRenderResumeDataCache,
getPrerenderResumeDataCache,
workUnitAsyncStorage,
Expand All @@ -43,6 +44,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<string, CacheHandler> = new Map()
Expand Down Expand Up @@ -123,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',
Expand All @@ -139,7 +153,11 @@ function generateCacheEntryWithCacheContext(
explicitExpire: undefined,
explicitStale: undefined,
tags: null,
hmrRefreshHash: outerWorkUnitStore && getHmrRefreshHash(outerWorkUnitStore),
isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false,
serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache,
}

return workUnitAsyncStorage.run(
cacheStore,
generateCacheEntryImpl,
Expand Down Expand Up @@ -278,12 +296,14 @@ async function generateCacheEntryImpl(
): Promise<[ReadableStream, Promise<CacheEntry>]> {
const temporaryReferences = createServerTemporaryReferenceSet()

const [, , args] =
const [, , , args] =
typeof encodedArguments === 'string'
? await decodeReply<any[]>(encodedArguments, getServerModuleMap(), {
temporaryReferences,
})
: await decodeReplyFromAsyncIterable<any[]>(
? await decodeReply<CacheKeyParts>(
encodedArguments,
getServerModuleMap(),
{ temporaryReferences }
)
: await decodeReplyFromAsyncIterable<CacheKeyParts>(
{
async *[Symbol.asyncIterator]() {
for (const entry of encodedArguments) {
Expand Down Expand Up @@ -507,6 +527,13 @@ 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. 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 && getHmrRefreshHash(workUnitStore)

const hangingInputAbortSignal =
workUnitStore?.type === 'prerender'
? createHangingInputAbortSignal(workUnitStore)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -669,7 +697,7 @@ export function cache(
workStore,
workUnitStore,
clientReferenceManifest,
encodedArguments,
encodedCacheKeyParts,
fn,
timeoutError
)
Expand Down Expand Up @@ -729,7 +757,7 @@ export function cache(
workStore,
undefined, // This is not running within the context of this unit.
clientReferenceManifest,
encodedArguments,
encodedCacheKeyParts,
fn,
timeoutError
)
Expand Down
8 changes: 8 additions & 0 deletions test/development/app-dir/use-cache-hmr/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
19 changes: 19 additions & 0 deletions test/development/app-dir/use-cache-hmr/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p id="fetchedRandom">{fetchedRandom}</p>
<p id="text">{text}</p>
<p id="mathRandom">{mathRandom}</p>
</>
)
}
15 changes: 15 additions & 0 deletions test/development/app-dir/use-cache-hmr/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
},
logging: {
fetches: {
hmrRefreshes: true,
},
},
}

module.exports = nextConfig
Loading