fix(cache-tags): add /index to implicit tags and adjust revalidatePath() (#84586)

This fixes an issue where the tag was `/` but the build output was
`/index` (and vice-versa) meaning you couldn't use `revalidatePath()` in
some cases because the tag didn't match.

---------

Co-authored-by: JJ Kasper <[email protected]>
This commit is contained in:
Steven
2025-10-07 16:03:51 +00:00
committed by GitHub
co-authored by JJ Kasper
parent 316c06295c
commit 75b1ceff2f
4 changed files with 117 additions and 11 deletions
@@ -0,0 +1,83 @@
import type { OpaqueFallbackRouteParams } from '../request/fallback-params'
import { getImplicitTags } from './implicit-tags'
describe('getImplicitTags()', () => {
it.each<{
page: string
url: { pathname: string; search: string }
fallbackRouteParams: null | OpaqueFallbackRouteParams
expectedTags: string[]
}>([
{
page: '/',
url: { pathname: '/', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/', '_N_T_/index'],
},
{
page: '',
url: { pathname: '/', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/', '_N_T_/index'],
},
{
page: '/',
url: { pathname: '', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout'],
},
{
page: '/page',
url: { pathname: '', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/page'],
},
{
page: '/page',
url: { pathname: '/', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/page', '_N_T_/', '_N_T_/index'],
},
{
page: '/page',
url: { pathname: '/page', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/page'],
},
{
page: '/index',
url: { pathname: '/', search: '' },
fallbackRouteParams: null,
expectedTags: [
'_N_T_/layout',
'_N_T_/index/layout',
'_N_T_/',
'_N_T_/index',
],
},
{
page: '/hello',
url: { pathname: '/hello', search: '' },
fallbackRouteParams: null,
expectedTags: ['_N_T_/layout', '_N_T_/hello/layout', '_N_T_/hello'],
},
{
page: '/foo/bar/baz',
url: { pathname: '/foo/bar/baz', search: '' },
fallbackRouteParams: null,
expectedTags: [
'_N_T_/layout',
'_N_T_/foo/layout',
'_N_T_/foo/bar/layout',
'_N_T_/foo/bar/baz/layout',
'_N_T_/foo/bar/baz',
],
},
])(
'for page $page with url $url and $fallback',
async ({ page, url, fallbackRouteParams, expectedTags }) => {
const result = await getImplicitTags(page, url, fallbackRouteParams)
expect(result.tags).toEqual(expectedTags)
}
)
})
+14 -5
View File
@@ -79,13 +79,13 @@ export async function getImplicitTags(
},
fallbackRouteParams: null | OpaqueFallbackRouteParams
): Promise<ImplicitTags> {
const tags: string[] = []
const tags = new Set<string>()
// Add the derived tags from the page.
const derivedTags = getDerivedTags(page)
for (let tag of derivedTags) {
tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`
tags.push(tag)
tags.add(tag)
}
// Add the tags from the pathname. If the route has unknown params, we don't
@@ -95,11 +95,20 @@ export async function getImplicitTags(
(!fallbackRouteParams || fallbackRouteParams.size === 0)
) {
const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${url.pathname}`
tags.push(tag)
tags.add(tag)
}
if (tags.has(`${NEXT_CACHE_IMPLICIT_TAG_ID}/`)) {
tags.add(`${NEXT_CACHE_IMPLICIT_TAG_ID}/index`)
}
if (tags.has(`${NEXT_CACHE_IMPLICIT_TAG_ID}/index`)) {
tags.add(`${NEXT_CACHE_IMPLICIT_TAG_ID}/`)
}
const tagsArray = Array.from(tags)
return {
tags,
expirationsByCacheKind: createTagsExpirationsByCacheKind(tags),
tags: tagsArray,
expirationsByCacheKind: createTagsExpirationsByCacheKind(tagsArray),
}
}
@@ -37,7 +37,7 @@ export function unstable_expirePath(
return
}
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath}`
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath || '/'}`
if (type) {
normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}`
@@ -46,7 +46,13 @@ export function unstable_expirePath(
`Warning: a dynamic page path "${originalPath}" was passed to "expirePath", but the "type" parameter is missing. This has no effect by default, see more info here https://nextjs.org/docs/app/api-reference/functions/unstable_expirePath`
)
}
return revalidate([normalizedPath], `unstable_expirePath ${originalPath}`)
const tags = [normalizedPath]
if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/`) {
tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/index`)
} else if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/index`) {
tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/`)
}
return revalidate(tags, `unstable_expirePath ${originalPath}`)
}
/**
@@ -71,7 +77,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
return
}
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath}`
let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath || '/'}`
if (type) {
normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}`
@@ -80,7 +86,15 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
`Warning: a dynamic page path "${originalPath}" was passed to "revalidatePath", but the "type" parameter is missing. This has no effect by default, see more info here https://nextjs.org/docs/app/api-reference/functions/revalidatePath`
)
}
return revalidate([normalizedPath], `revalidatePath ${originalPath}`)
const tags = [normalizedPath]
if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/`) {
tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/index`)
} else if (normalizedPath === `${NEXT_CACHE_IMPLICIT_TAG_ID}/index`) {
tags.push(`${NEXT_CACHE_IMPLICIT_TAG_ID}/`)
}
return revalidate(tags, `revalidatePath ${originalPath}`)
}
function revalidate(tags: string[], expression: string) {
@@ -28,7 +28,7 @@ describe('use-cache-custom-handler', () => {
expect(cliOutput).toContain('ModernCustomCacheHandler::refreshTags')
expect(next.cliOutput.slice(outputIndex)).toMatch(
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\] \[ '_N_T_\/layout', '_N_T_\/page', '_N_T_\/' \]/
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\] \[ '_N_T_\/layout', '_N_T_\/page', '_N_T_\/', '_N_T_\/index' \]/
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
@@ -52,7 +52,7 @@ describe('use-cache-custom-handler', () => {
// to compare the cache entries timestamp with the expiration of the
// implicit tags.
expect(next.cliOutput.slice(outputIndex)).toContain(
`ModernCustomCacheHandler::getExpiration ["_N_T_/layout","_N_T_/page","_N_T_/"]`
`ModernCustomCacheHandler::getExpiration ["_N_T_/layout","_N_T_/page","_N_T_/","_N_T_/index"]`
)
// Because we use a low `revalidate` value for the "use cache" function, new