From f0c9ffe9b1acebcdb7cfc911a023d7622d710080 Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Wed, 11 Feb 2026 13:49:10 -0800 Subject: [PATCH 1/2] fix(cache): DCE to avoid pulling server internals into browser bundles --- packages/next/cache.js | 61 +++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/next/cache.js b/packages/next/cache.js index d78dd4238f31..6df9e6531d5f 100644 --- a/packages/next/cache.js +++ b/packages/next/cache.js @@ -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 From 0e0ead68e8c882ab63cce4bc43e24569d126ee73 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 19 Feb 2026 13:11:41 +0100 Subject: [PATCH 2/2] Add test for dce behavior of next/cache. --- .../browser-chunks/app/cache-client.tsx | 14 +++++ .../app-dir/browser-chunks/app/page.tsx | 9 ++- .../browser-chunks/browser-chunks.test.ts | 59 ++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 test/production/app-dir/browser-chunks/app/cache-client.tsx diff --git a/test/production/app-dir/browser-chunks/app/cache-client.tsx b/test/production/app-dir/browser-chunks/app/cache-client.tsx new file mode 100644 index 000000000000..eb975b477254 --- /dev/null +++ b/test/production/app-dir/browser-chunks/app/cache-client.tsx @@ -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 +} diff --git a/test/production/app-dir/browser-chunks/app/page.tsx b/test/production/app-dir/browser-chunks/app/page.tsx index ff7159d9149f..334edb3a114a 100644 --- a/test/production/app-dir/browser-chunks/app/page.tsx +++ b/test/production/app-dir/browser-chunks/app/page.tsx @@ -1,3 +1,10 @@ +import { CacheClient } from './cache-client' + export default function Page() { - return

hello world

+ return ( +
+

hello world

+ +
+ ) } diff --git a/test/production/app-dir/browser-chunks/browser-chunks.test.ts b/test/production/app-dir/browser-chunks/browser-chunks.test.ts index 74b0d7a90317..44f1ba533694 100644 --- a/test/production/app-dir/browser-chunks/browser-chunks.test.ts +++ b/test/production/app-dir/browser-chunks/browser-chunks.test.ts @@ -1,4 +1,25 @@ 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 { + 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({ @@ -6,14 +27,21 @@ describe('browser-chunks', () => { 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.` + ) + } + } + }) })