Repository: vercel/next.js
Pull request: fix(cache): DCE to avoid pulling server internals into browser bundles (4/8)

## Summary

Fix dead-code elimination in `packages/next/cache.js` to prevent server-only internals from being pulled into browser bundles. Restructures the conditional exports so bundlers can properly tree-shake server paths.

## Test plan

- [ ] `pnpm --filter=next build` succeeds
- [ ] No runtime behavior changes

Changed files:
filename: packages/next/cache.js
patch: @@ -1,22 +1,53 @@
-const cacheExports = {
-  unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache')
-    .unstable_cache,
+let cacheExports
 
-  updateTag: require('next/dist/server/web/spec-extension/revalidate')
-    .updateTag,
+if (process.env.NEXT_RUNTIME === '') {
+  const notAvailableInClient = (name) => {
+    return function notAvailable() {
+      throw new Error(`\`${name}\` is only available in a Server Component.`)
+    }
+  }
+
+  cacheExports = {
+    unstable_cache: function unstable_cache(cb) {
+      // Legacy behavior: allow importing/using unstable_cache from client bundles
+      // without pulling in server internals.
+      if (typeof cb !== 'function') return cb
+      return function cached() {
+        return cb.apply(this, arguments)
+      }
+    },
+    unstable_noStore: function unstable_noStore() {},
+
+    updateTag: notAvailableInClient('updateTag'),
+    revalidateTag: notAvailableInClient('revalidateTag'),
+    revalidatePath: notAvailableInClient('revalidatePath'),
+    refresh: notAvailableInClient('refresh'),
+    cacheLife: notAvailableInClient('cacheLife'),
+    cacheTag: notAvailableInClient('cacheTag'),
+  }
+} else {
+  // Keep server requires in this branch so browser builds can DCE them.
+  cacheExports = {
+    unstable_cache:
+      require('next/dist/server/web/spec-extension/unstable-cache')
+        .unstable_cache,
+
+    updateTag: require('next/dist/server/web/spec-extension/revalidate')
+      .updateTag,
 
-  revalidateTag: require('next/dist/server/web/spec-extension/revalidate')
-    .revalidateTag,
-  revalidatePath: require('next/dist/server/web/spec-extension/revalidate')
-    .revalidatePath,
+    revalidateTag: require('next/dist/server/web/spec-extension/revalidate')
+      .revalidateTag,
+    revalidatePath: require('next/dist/server/web/spec-extension/revalidate')
+      .revalidatePath,
 
-  refresh: require('next/dist/server/web/spec-extension/revalidate').refresh,
+    refresh: require('next/dist/server/web/spec-extension/revalidate').refresh,
 
-  unstable_noStore:
-    require('next/dist/server/web/spec-extension/unstable-no-store')
-      .unstable_noStore,
-  cacheLife: require('next/dist/server/use-cache/cache-life').cacheLife,
-  cacheTag: require('next/dist/server/use-cache/cache-tag').cacheTag,
+    unstable_noStore:
+      require('next/dist/server/web/spec-extension/unstable-no-store')
+        .unstable_noStore,
+    cacheLife: require('next/dist/server/use-cache/cache-life').cacheLife,
+    cacheTag: require('next/dist/server/use-cache/cache-tag').cacheTag,
+  }
 }
 
 let didWarnCacheLife = false

filename: test/production/app-dir/browser-chunks/app/cache-client.tsx
patch: @@ -0,0 +1,14 @@
+'use client'
+
+import { unstable_cache } from 'next/cache'
+
+// Importing next/cache in a Client Component should not pull server internals
+// into browser chunks. The bundler sets NEXT_RUNTIME='' for client builds,
+// which allows cache.js to DCE the server require() branch.
+const getCachedData = unstable_cache(async () => {
+  return { data: 'hello' }
+})
+
+export function CacheClient() {
+  return <button onClick={() => getCachedData()}>Fetch cached</button>
+}

filename: test/production/app-dir/browser-chunks/app/page.tsx
patch: @@ -1,3 +1,10 @@
+import { CacheClient } from './cache-client'
+
 export default function Page() {
-  return <p>hello world</p>
+  return (
+    <div>
+      <p>hello world</p>
+      <CacheClient />
+    </div>
+  )
 }

filename: test/production/app-dir/browser-chunks/browser-chunks.test.ts
patch: @@ -1,19 +1,47 @@
 import { nextTestSetup } from 'e2e-utils'
+import { join } from 'path'
+import { readdir, readFile } from 'fs/promises'
+
+async function readFilesRecursive(
+  dir: string,
+  predicate: (filename: string) => boolean
+): Promise<string[]> {
+  const entries = await readdir(dir, { withFileTypes: true })
+  const results: string[] = []
+
+  for (const entry of entries) {
+    const fullPath = join(dir, entry.name)
+    if (entry.isDirectory()) {
+      results.push(...(await readFilesRecursive(fullPath, predicate)))
+    } else if (predicate(entry.name)) {
+      results.push(await readFile(fullPath, 'utf8'))
+    }
+  }
+
+  return results
+}
 
 describe('browser-chunks', () => {
   const { next } = nextTestSetup({
     files: __dirname,
     skipDeployment: true,
   })
 
-  let sources = []
+  let sources: string[] = []
+  let jsContents: string[] = []
   beforeAll(async () => {
-    const sourcemaps = await next.readFiles('.next/static/chunks', (filename) =>
+    const chunksDir = join(next.testDir, '.next/static/chunks')
+
+    const sourcemaps = await readFilesRecursive(chunksDir, (filename) =>
       filename.endsWith('.js.map')
     )
-
     sources = sourcemaps.flatMap((sourcemap) => JSON.parse(sourcemap).sources)
+
+    jsContents = await readFilesRecursive(chunksDir, (filename) =>
+      filename.endsWith('.js')
+    )
   })
+
   it('must not bundle any server modules into browser chunks', () => {
     const serverSources = sources.filter(
       (source) =>
@@ -62,4 +90,29 @@ describe('browser-chunks', () => {
       )
     }
   })
+
+  it('must not pull server internals from next/cache into browser chunks', () => {
+    // When a Client Component imports from next/cache, the bundler should
+    // DCE the server require() branch (via process.env.NEXT_RUNTIME === '')
+    // and only include lightweight client stubs. Pre-compiled dist/ modules
+    // don't appear in sourcemaps, so we check the actual JS content.
+    const serverOnlyPatterns = [
+      // IncrementalCache is a class from next/dist/server used by unstable_cache
+      'IncrementalCache',
+    ]
+
+    for (const pattern of serverOnlyPatterns) {
+      const chunksWithPattern = jsContents.filter((content) =>
+        content.includes(pattern)
+      )
+
+      if (chunksWithPattern.length > 0) {
+        throw new Error(
+          `Found server-only pattern "${pattern}" in ${chunksWithPattern.length} browser chunk(s). ` +
+            `This likely means next/cache is pulling server internals into the client bundle. ` +
+            `Ensure the server require() calls in packages/next/cache.js are behind a DCE-able branch.`
+        )
+      }
+    }
+  })
 })