mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Ignore an existing HMR refresh hash cookie with next start (#77714)
This is a follow-up fix for #75474. When switching from `next dev` to `next start` locally, a user might have an existing HMR refresh hash session cookie. We need to make sure that the hash value is excluded from any `"use cache"` cache keys, when running with `next start`. Otherwise, the cache keys will be different when prerendering vs. handling a dynamic request, e.g. when executing a server action. In production, we now also omit the serialized `"$undefined"` value for the HMR refresh hash from the cache keys to keep them shorter.
This commit is contained in:
@@ -269,8 +269,13 @@ export function getRenderResumeDataCache(
|
||||
}
|
||||
|
||||
export function getHmrRefreshHash(
|
||||
workStore: WorkStore,
|
||||
workUnitStore: WorkUnitStore
|
||||
): string | undefined {
|
||||
if (!workStore.dev) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return workUnitStore.type === 'cache'
|
||||
? workUnitStore.hmrRefreshHash
|
||||
: workUnitStore.type === 'request'
|
||||
|
||||
@@ -53,12 +53,9 @@ import type { Params } from '../request/params'
|
||||
import React from 'react'
|
||||
import type { ImplicitTags } from '../lib/implicit-tags'
|
||||
|
||||
type CacheKeyParts = [
|
||||
buildId: string,
|
||||
hmrRefreshHash: string | undefined,
|
||||
id: string,
|
||||
args: unknown[],
|
||||
]
|
||||
type CacheKeyParts =
|
||||
| [buildId: string, id: string, args: unknown[]]
|
||||
| [buildId: string, id: string, args: unknown[], hmrRefreshHash: string]
|
||||
|
||||
export interface UseCachePageComponentProps {
|
||||
params: Promise<Params>
|
||||
@@ -162,7 +159,8 @@ function generateCacheEntryWithCacheContext(
|
||||
explicitExpire: undefined,
|
||||
explicitStale: undefined,
|
||||
tags: null,
|
||||
hmrRefreshHash: outerWorkUnitStore && getHmrRefreshHash(outerWorkUnitStore),
|
||||
hmrRefreshHash:
|
||||
outerWorkUnitStore && getHmrRefreshHash(workStore, outerWorkUnitStore),
|
||||
isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false,
|
||||
serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache,
|
||||
forceRevalidate: shouldForceRevalidate(workStore, outerWorkUnitStore),
|
||||
@@ -309,7 +307,7 @@ async function generateCacheEntryImpl(
|
||||
): Promise<[ReadableStream, Promise<CacheEntry>]> {
|
||||
const temporaryReferences = createServerTemporaryReferenceSet()
|
||||
|
||||
const [, , , args] =
|
||||
const [, , args] =
|
||||
typeof encodedArguments === 'string'
|
||||
? await decodeReply<CacheKeyParts>(
|
||||
encodedArguments,
|
||||
@@ -543,7 +541,8 @@ 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 && getHmrRefreshHash(workUnitStore)
|
||||
const hmrRefreshHash =
|
||||
workUnitStore && getHmrRefreshHash(workStore, workUnitStore)
|
||||
|
||||
const hangingInputAbortSignal =
|
||||
workUnitStore?.type === 'prerender'
|
||||
@@ -605,7 +604,11 @@ export function cache(
|
||||
}
|
||||
|
||||
const temporaryReferences = createClientTemporaryReferenceSet()
|
||||
const cacheKeyParts: CacheKeyParts = [buildId, hmrRefreshHash, id, args]
|
||||
|
||||
const cacheKeyParts: CacheKeyParts = hmrRefreshHash
|
||||
? [buildId, id, args, hmrRefreshHash]
|
||||
: [buildId, id, args]
|
||||
|
||||
const encodedCacheKeyParts: FormData | string = await encodeReply(
|
||||
cacheKeyParts,
|
||||
{ temporaryReferences, signal: hangingInputAbortSignal }
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
async function getRandomValue() {
|
||||
'use cache'
|
||||
|
||||
return Math.random()
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
'use cache'
|
||||
|
||||
return fetch('https://next-data-api-endpoint.vercel.app/api/random').then(
|
||||
(res) =>
|
||||
res
|
||||
.text()
|
||||
.then(async (text) => [text, 'foo', await getRandomValue()] as const)
|
||||
)
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const [fetchedRandom, text, mathRandom] = await getData()
|
||||
|
||||
return (
|
||||
<div id="container">
|
||||
<p id="fetchedRandom">{fetchedRandom}</p>
|
||||
<p id="text">{text}</p>
|
||||
<p id="mathRandom">{mathRandom}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { nextTestSetup } from 'e2e-utils'
|
||||
import { retry } from 'next-test-utils'
|
||||
|
||||
describe('use-cache-dev', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return cached data after reload', async () => {
|
||||
let $ = await next.render$('/')
|
||||
const initialContent = $('#container').text()
|
||||
$ = await next.render$('/')
|
||||
const content = $('#container').text()
|
||||
|
||||
expect(content).toEqual(initialContent)
|
||||
})
|
||||
|
||||
it('should return fresh data after hard reload', async () => {
|
||||
let $ = await next.render$('/')
|
||||
const initialContent = $('#container').text()
|
||||
|
||||
$ = await next.render$(
|
||||
'/',
|
||||
{},
|
||||
{ headers: { 'cache-control': 'no-cache' } }
|
||||
)
|
||||
|
||||
const hardReloadContent = $('#container').text()
|
||||
|
||||
expect(hardReloadContent).not.toEqual(initialContent)
|
||||
|
||||
// After a subsequent soft reload, cached data from the hard reload should
|
||||
// be returned.
|
||||
|
||||
const softReloadContent = $('#container').text()
|
||||
|
||||
expect(softReloadContent).toEqual(hardReloadContent)
|
||||
})
|
||||
|
||||
it('should successfully finish compilation when "use cache" directive is added/removed', async () => {
|
||||
await next.browser('/')
|
||||
let cliOutputLength = next.cliOutput.length
|
||||
|
||||
// Disable "use cache" directive
|
||||
await next.patchFile('app/page.tsx', (content) =>
|
||||
content.replace(`'use cache'`, `// 'use cache'`)
|
||||
)
|
||||
|
||||
await retry(async () => {
|
||||
expect(next.cliOutput.slice(cliOutputLength)).toInclude('✓ Compiled')
|
||||
}, 10_000)
|
||||
|
||||
cliOutputLength = next.cliOutput.length
|
||||
|
||||
// Re-enable "use cache" directive
|
||||
await next.patchFile('app/page.tsx', (content) =>
|
||||
content.replace(`// 'use cache'`, `'use cache'`)
|
||||
)
|
||||
|
||||
await retry(async () => {
|
||||
expect(next.cliOutput.slice(cliOutputLength)).toInclude('✓ Compiled')
|
||||
}, 10_000)
|
||||
})
|
||||
})
|
||||
@@ -32,11 +32,11 @@ describe('use-cache-custom-handler', () => {
|
||||
)
|
||||
|
||||
expect(next.cliOutput.slice(outputIndex)).toMatch(
|
||||
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","\$undefined","([0-9a-f]{2})+",\[\]\]/
|
||||
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/
|
||||
)
|
||||
|
||||
expect(next.cliOutput.slice(outputIndex)).toMatch(
|
||||
/ModernCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","\$undefined","([0-9a-f]{2})+",\[\]\]/
|
||||
/ModernCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/
|
||||
)
|
||||
|
||||
// The data should be cached initially.
|
||||
@@ -67,11 +67,11 @@ describe('use-cache-custom-handler', () => {
|
||||
)
|
||||
|
||||
expect(next.cliOutput.slice(outputIndex)).toMatch(
|
||||
/LegacyCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","\$undefined","([0-9a-f]{2})+",\[\]\] \["_N_T_\/layout","_N_T_\/legacy\/layout","_N_T_\/legacy\/page","_N_T_\/legacy"\]/
|
||||
/LegacyCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\] \["_N_T_\/layout","_N_T_\/legacy\/layout","_N_T_\/legacy\/page","_N_T_\/legacy"\]/
|
||||
)
|
||||
|
||||
expect(next.cliOutput.slice(outputIndex)).toMatch(
|
||||
/LegacyCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","\$undefined","([0-9a-f]{2})+",\[\]\]/
|
||||
/LegacyCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/
|
||||
)
|
||||
|
||||
// The data should be cached initially.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
async function getRandomValue() {
|
||||
'use cache'
|
||||
|
||||
return Math.random()
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
'use cache'
|
||||
|
||||
return fetch('https://next-data-api-endpoint.vercel.app/api/random').then(
|
||||
(res) =>
|
||||
res
|
||||
.text()
|
||||
.then(async (text) => [text, 'foo', await getRandomValue()] as const)
|
||||
)
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const [fetchedRandom, text, mathRandom] = await getData()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="container">
|
||||
<p id="fetchedRandom">{fetchedRandom}</p>
|
||||
<p id="text">{text}</p>
|
||||
<p id="mathRandom">{mathRandom}</p>
|
||||
</div>
|
||||
<p id="uncached">{new Date().toISOString()}</p>
|
||||
<form
|
||||
action={async () => {
|
||||
'use server'
|
||||
revalidatePath('/')
|
||||
}}
|
||||
>
|
||||
<button id="revalidate">Revalidate</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { nextTestSetup } from 'e2e-utils'
|
||||
import { retry } from 'next-test-utils'
|
||||
|
||||
describe('use-cache-dev', () => {
|
||||
const { next, skipped, isNextDev, isTurbopack } = nextTestSetup({
|
||||
files: __dirname,
|
||||
skipDeployment: true,
|
||||
})
|
||||
|
||||
if (skipped) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isNextDev) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return cached data after reload', async () => {
|
||||
let $ = await next.render$('/')
|
||||
const initialContent = $('#container').text()
|
||||
$ = await next.render$('/')
|
||||
const content = $('#container').text()
|
||||
|
||||
expect(content).toEqual(initialContent)
|
||||
})
|
||||
|
||||
it('should return fresh data after hard reload', async () => {
|
||||
let $ = await next.render$('/')
|
||||
const initialContent = $('#container').text()
|
||||
|
||||
$ = await next.render$(
|
||||
'/',
|
||||
{},
|
||||
{ headers: { 'cache-control': 'no-cache' } }
|
||||
)
|
||||
|
||||
const hardReloadContent = $('#container').text()
|
||||
|
||||
expect(hardReloadContent).not.toEqual(initialContent)
|
||||
|
||||
// After a subsequent soft reload, cached data from the hard reload should
|
||||
// be returned.
|
||||
|
||||
const softReloadContent = $('#container').text()
|
||||
|
||||
expect(softReloadContent).toEqual(hardReloadContent)
|
||||
})
|
||||
|
||||
it('should successfully finish compilation when "use cache" directive is added/removed', async () => {
|
||||
await next.browser('/')
|
||||
let cliOutputLength = next.cliOutput.length
|
||||
|
||||
// Disable "use cache" directive
|
||||
await next.patchFile('app/page.tsx', (content) =>
|
||||
content.replace(`'use cache'`, `// 'use cache'`)
|
||||
)
|
||||
|
||||
await retry(async () => {
|
||||
expect(next.cliOutput.slice(cliOutputLength)).toInclude('✓ Compiled')
|
||||
}, 10_000)
|
||||
|
||||
cliOutputLength = next.cliOutput.length
|
||||
|
||||
// Re-enable "use cache" directive
|
||||
await next.patchFile('app/page.tsx', (content) =>
|
||||
content.replace(`// 'use cache'`, `'use cache'`)
|
||||
)
|
||||
|
||||
await retry(async () => {
|
||||
expect(next.cliOutput.slice(cliOutputLength)).toInclude('✓ Compiled')
|
||||
}, 10_000)
|
||||
})
|
||||
} else {
|
||||
it('should ignore an existing HMR refresh hash cookie with "next start"', async () => {
|
||||
const browser = await next.browser('/')
|
||||
|
||||
const [initialFetchedRandom, initialMathRandom] = await Promise.all([
|
||||
browser.elementById('fetchedRandom').text(),
|
||||
browser.elementById('mathRandom').text(),
|
||||
])
|
||||
|
||||
await browser.addCookie({
|
||||
name: '__next_hmr_refresh_hash__',
|
||||
value: 'test',
|
||||
})
|
||||
|
||||
// First, revalidate the prerendered page with a server action. This uses
|
||||
// a request work unit store, so the HMR refresh cookie is available.
|
||||
|
||||
await browser.elementById('revalidate').click()
|
||||
|
||||
let revalidatedFetchedRandom: string
|
||||
let revalidatedMathRandom: string
|
||||
|
||||
await retry(async () => {
|
||||
;[revalidatedFetchedRandom, revalidatedMathRandom] = await Promise.all([
|
||||
browser.elementById('fetchedRandom').text(),
|
||||
browser.elementById('mathRandom').text(),
|
||||
])
|
||||
|
||||
expect(revalidatedFetchedRandom).not.toBe(initialFetchedRandom)
|
||||
expect(revalidatedMathRandom).not.toBe(initialMathRandom)
|
||||
})
|
||||
|
||||
let initialUncached = await browser.elementById('uncached').text()
|
||||
|
||||
// Now refresh the page. Due to the prior revalidation it will be a cache
|
||||
// miss, and the page will be prerendered. This uses a prerender work unit
|
||||
// store, so the HMR refresh cookie is not available.
|
||||
|
||||
await browser.refresh()
|
||||
|
||||
await retry(async () => {
|
||||
const [fetchedRandom, mathRandom, uncached] = await Promise.all([
|
||||
browser.elementById('fetchedRandom').text(),
|
||||
browser.elementById('mathRandom').text(),
|
||||
browser.elementById('uncached').text(),
|
||||
])
|
||||
|
||||
expect(uncached).not.toBe(initialUncached)
|
||||
expect(fetchedRandom).toBe(revalidatedFetchedRandom)
|
||||
expect(mathRandom).toBe(revalidatedMathRandom)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user