Repository: vercel/next.js
Pull request: [Cache Components] Fix revalidation of prerendered route handlers

When a prerendered and deployed route handler is revalidated, we can not rely on having a cache handler that does in-memory caching. So we need to provide a prerender resume data cache (RDC) instead, to ensure that the revalidation doesn't fail with the following error:

```
Error: Dynamic server usage: Route / couldn't be rendered statically because it used IO that was not cached.
```

Route handlers are never resumed, so it's counter-intuitive to use an RDC for this scenario. However, we need the data cache to store cached results in memory during the prospective prerender, so that they can be retrieved during the final prerender within microtasks.

In a follow-up, we should replace the `prerenderResumeDataCache` and `renderResumeDataCache` with a single `dataCache` property that is conceptually not tied to resuming, and also avoids the unnecessary complexity of using a mutable and an immutable resume data cache.

closes NAR-350

Changed files:
filename: packages/next/src/server/route-modules/app-route/module.ts
patch: @@ -84,6 +84,7 @@ import { INFINITE_CACHE } from '../../../lib/constants'
 import { executeRevalidates } from '../../revalidation-utils'
 import { trackPendingModules } from '../../app-render/module-loading/track-module-loading.external'
 import { InvariantError } from '../../../shared/lib/invariant-error'
+import { createPrerenderResumeDataCache } from '../../resume-data-cache/resume-data-cache'
 
 export class WrappedNextRouterError {
   constructor(
@@ -379,6 +380,18 @@ export class AppRouteRouteModule extends RouteModule<
           const cacheSignal = new CacheSignal()
           let dynamicTracking = createDynamicTrackingState(undefined)
 
+          // TODO: Route handlers are never resumed, so it's counter-intuitive
+          // to use an RDC here. However, we need the data cache to store cached
+          // results in memory during the prospective prerender, so that they
+          // can be retrieved during the final prerender within microtasks. This
+          // is crucial when doing revalidations of a deployed route handler,
+          // where the default cache handler does not do any in-memory caching.
+          // We should replace the `prerenderResumeDataCache` and
+          // `renderResumeDataCache` with a single `dataCache` property that is
+          // conceptually not tied to resuming, and also avoids the unnecessary
+          // complexity of using a mutable and an immutable resume data cache.
+          const prerenderResumeDataCache = createPrerenderResumeDataCache()
+
           const prospectiveRoutePrerenderStore: PrerenderStore =
             (prerenderStore = {
               type: 'prerender',
@@ -399,8 +412,7 @@ export class AppRouteRouteModule extends RouteModule<
               expire: INFINITE_CACHE,
               stale: INFINITE_CACHE,
               tags: [...implicitTags.tags],
-              // TODO: Shouldn't we provide an RDC here?
-              prerenderResumeDataCache: null,
+              prerenderResumeDataCache,
               renderResumeDataCache: null,
               hmrRefreshHash: undefined,
               captureOwnerStack: undefined,
@@ -492,8 +504,7 @@ export class AppRouteRouteModule extends RouteModule<
             expire: INFINITE_CACHE,
             stale: INFINITE_CACHE,
             tags: [...implicitTags.tags],
-            // TODO: Shouldn't we provide an RDC here?
-            prerenderResumeDataCache: null,
+            prerenderResumeDataCache,
             renderResumeDataCache: null,
             hmrRefreshHash: undefined,
             captureOwnerStack: undefined,

filename: test/e2e/app-dir/use-cache-route-handler-only/app/node/route.ts
patch: @@ -1,13 +1,19 @@
-async function getCachedRandom() {
+import { setTimeout } from 'timers/promises'
+
+async function getCachedDate() {
   'use cache'
-  return Math.random()
+
+  // Simulate I/O latency.
+  await setTimeout(200)
+
+  return new Date().toISOString()
 }
 
 export async function GET() {
-  const rand1 = await getCachedRandom()
-  const rand2 = await getCachedRandom()
+  const date1 = await getCachedDate()
+  const date2 = await getCachedDate()
 
-  const response = JSON.stringify({ rand1, rand2 })
+  const response = JSON.stringify({ date1, date2 })
 
   return new Response(response, {
     headers: { 'content-type': 'application/json' },

filename: test/e2e/app-dir/use-cache-route-handler-only/app/revalidate/route.ts
patch: @@ -0,0 +1,7 @@
+import { revalidatePath } from 'next/cache'
+
+export async function POST() {
+  revalidatePath('/node')
+
+  return new Response(null, { status: 204 })
+}

filename: test/e2e/app-dir/use-cache-route-handler-only/use-cache-route-handler-only.test.ts
patch: @@ -9,8 +9,23 @@ describe('use-cache-route-handler-only', () => {
 
   it('should cache results in node route handlers', async () => {
     const response = await next.fetch('/node')
-    const { rand1, rand2 } = await response.json()
+    const { date1, date2 } = await response.json()
 
-    expect(rand1).toEqual(rand2)
+    expect(date1).toBe(date2)
+  })
+
+  it('should be able to revalidate prerendered route handlers', async () => {
+    const response1 = await next.fetch('/node')
+    const { date1: date1a } = await response1.json()
+
+    // Revalidate the prerendered response.
+    await next.fetch('/revalidate', { method: 'POST' })
+
+    // Fetch the response again. This should trigger a blocking revalidation.
+    const response2 = await next.fetch('/node')
+    expect(response2.status).toBe(200)
+
+    const { date1: date1b } = await response2.json()
+    expect(date1a).not.toBe(date1b)
   })
 })