Repository: vercel/next.js
Pull request: fix(cache-tags): add `/index` to implicit tags and adjust `revalidatePath()`

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.

Changed files:
filename: packages/next/src/server/lib/implicit-tags.test.ts
patch: @@ -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)
+    }
+  )
+})

filename: packages/next/src/server/lib/implicit-tags.ts
patch: @@ -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),
   }
 }

filename: packages/next/src/server/web/spec-extension/revalidate.ts
patch: @@ -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) {

filename: test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts
patch: @@ -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