Repository: vercel/next.js
Pull request: Make `revalidate*` work when followed by a redirect in a route handler

When a route handler calls `revalidateTag` or `revalidatePath`, we need to make sure that these revalidations are executed before returning with the redirect response.

Note: When deployed, we currently don't have a strong guarantee that the revalidations are fully propagated before executing the redirect target route – as we do for redirecting server actions. This is because, for route handlers, the redirect occurs client-side, which prevents us from using the same technique as for server actions, which involves sending a revalidate token as a request header. This token must not leak to the client. However, eventually the revalidation will be propagated, and a refresh should show fresh data.

fixes #74757

Changed files:
filename: packages/next/src/server/route-modules/app-route/module.ts
patch: @@ -322,6 +322,23 @@ export class AppRouteRouteModule extends RouteModule<
         : undefined,
     }
 
+    const resolvePendingRevalidations = () => {
+      context.renderOpts.pendingWaitUntil = Promise.all([
+        workStore.incrementalCache?.revalidateTag(
+          workStore.pendingRevalidatedTags || []
+        ),
+        ...Object.values(workStore.pendingRevalidates || {}),
+        ...(workStore.pendingRevalidateWrites || []),
+      ]).finally(() => {
+        if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
+          console.log(
+            'pending revalidates promise finished for:',
+            requestStore.url
+          )
+        }
+      })
+    }
+
     let prerenderStore: null | PrerenderStore = null
 
     let res: unknown
@@ -575,6 +592,8 @@ export class AppRouteRouteModule extends RouteModule<
           appendMutableCookies(headers, requestStore.mutableCookies)
         }
 
+        resolvePendingRevalidations()
+
         // Return the redirect response.
         return new Response(null, {
           // If we're in an action, we want to use a 303 redirect as we don't
@@ -602,19 +621,7 @@ export class AppRouteRouteModule extends RouteModule<
 
     context.renderOpts.fetchMetrics = workStore.fetchMetrics
 
-    context.renderOpts.pendingWaitUntil = Promise.all([
-      workStore.incrementalCache?.revalidateTag(
-        workStore.pendingRevalidatedTags || []
-      ),
-      ...Object.values(workStore.pendingRevalidates || {}),
-    ]).finally(() => {
-      if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
-        console.log(
-          'pending revalidates promise finished for:',
-          requestStore.url
-        )
-      }
-    })
+    resolvePendingRevalidations()
 
     if (prerenderStore) {
       context.renderOpts.collectedTags = prerenderStore.tags?.join(',')

filename: test/e2e/app-dir/use-cache/app/api/revalidate-redirect/route.ts
patch: @@ -0,0 +1,7 @@
+import { revalidateTag } from 'next/cache'
+import { redirect } from 'next/navigation'
+
+export async function GET() {
+  revalidateTag('api')
+  redirect('/api')
+}

filename: test/e2e/app-dir/use-cache/app/api/route.ts
patch: @@ -1,15 +1,15 @@
+import { unstable_cacheTag } from 'next/cache'
+
 async function getCachedRandom() {
   'use cache'
+  unstable_cacheTag('api')
+
   return Math.random()
 }
 
 export async function GET() {
   const rand1 = await getCachedRandom()
   const rand2 = await getCachedRandom()
 
-  const response = JSON.stringify({ rand1, rand2 })
-
-  return new Response(response, {
-    headers: { 'content-type': 'application/json' },
-  })
+  return Response.json({ rand1, rand2 })
 }

filename: test/e2e/app-dir/use-cache/use-cache.test.ts
patch: @@ -158,12 +158,47 @@ describe('use-cache', () => {
     }
   })
 
-  it('should cache results in route handlers', async () => {
-    const response = await next.fetch('/api')
-    const { rand1, rand2 } = await response.json()
+  // TODO: Enable for deploy tests when upstream changes have been rolled out.
+  if (!isNextDeploy) {
+    it('should cache results in route handlers', async () => {
+      const response = await next.fetch('/api')
+      const { rand1, rand2 } = await response.json()
 
-    expect(rand1).toEqual(rand2)
-  })
+      expect(rand1).toEqual(rand2)
+    })
+
+    it('should revalidate before redirecting in a route handlers', async () => {
+      const initialValues = await next.fetch('/api').then((res) => res.json())
+
+      const values = await next
+        .fetch('/api/revalidate-redirect')
+        .then((res) => res.json())
+
+      if (isNextDeploy) {
+        try {
+          expect(values).not.toEqual(initialValues)
+        } catch {
+          // When deployed, we currently don't have a strong guarantee that the
+          // revalidations are propagated fully (as we do for redirecting server
+          // actions). This is because, for route handlers, the redirect occurs
+          // client-side, which prevents us from using the same technique as for
+          // server actions, which involves sending a revalidate token as a
+          // request header. This token must not leak to the client. However,
+          // eventually the revalidation will be propagated, and a refresh should
+          // show fresh data.
+          await retry(async () => {
+            const refreshedValues = await next
+              .fetch('/api')
+              .then((res) => res.json())
+
+            expect(refreshedValues).not.toEqual(initialValues)
+          })
+        }
+      } else {
+        expect(values).not.toEqual(initialValues)
+      }
+    })
+  }
 
   it('should cache results for cached functions imported from client components', async () => {
     const browser = await next.browser('/imported-from-client')