Trace cacheHandler and cacheHandlers when using adapters (#94197)

### What?

Add focused production adapter coverage for configured `cacheHandler`
and `cacheHandlers` module graphs in Node `output.assets`, including
transitive helper dependencies and Turbopack `assetsHashes` assertions.

### Why?

Adapter deployments dynamically load configured cache handlers at
runtime. This coverage demonstrates that Node adapter outputs currently
omit those handler files and their transitive dependencies.

### How?

Add an App Router Cache Components fixture using `adapterPath`, both
cache-handler APIs, and a `use cache: remote` route. Its adapter writes
`onBuildComplete` context to disk and the test inspects Node outputs for
each handler and uniquely named helper.

### Verification

- `NEXT_TEST_PREFER_OFFLINE=1 pnpm --filter=next build`
- Expected failure: `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-turbo
test/production/app-dir/adapter-cache-handlers/adapter-cache-handlers.test.ts`
(no Node output contains all four handler assets)
- Expected failure: `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-webpack
test/production/app-dir/adapter-cache-handlers/adapter-cache-handlers.test.ts`
(same adapter handoff gap)
- `git diff canary --check`
- Not run continuously: `pnpm --filter=next dev` (`EMFILE: too many open
files` occurred after startup in the local watcher)

<!-- NEXT_JS_LLM_PR -->

---------

Co-authored-by: Niklas Mischkulnig <[email protected]>
This commit is contained in:
Tim Neutkens
2026-06-03 12:24:54 +02:00
committed by GitHub
co-authored by Niklas Mischkulnig
parent 20ac9ce9cf
commit d342d0b91f
18 changed files with 259 additions and 74 deletions
+1 -1
View File
@@ -2002,7 +2002,7 @@ impl AppEndpoint {
this.app_project.project(),
Some(app_function_name(&app_entry.original_name).into()),
*module_graphs.full,
vec![*rsc_entry],
*rsc_entry,
))
}
}
+1 -1
View File
@@ -205,7 +205,7 @@ impl InstrumentationEndpoint {
*this.project,
None,
this.project.module_graph(userland_module),
vec![userland_module],
userland_module,
))
}
}
+1 -1
View File
@@ -347,7 +347,7 @@ impl MiddlewareEndpoint {
*this.project,
None,
this.project.module_graph(userland_module),
vec![userland_module],
userland_module,
))
}
}
+19 -52
View File
@@ -16,12 +16,10 @@ use turbo_tasks_hash::HashAlgorithm;
use turbopack::externals_tracing_module_context;
use turbopack_core::{
asset::{Asset, AssetContent},
context::AssetContext,
file_source::FileSource,
module::{Module, Modules},
module_graph::{ModuleGraph, SingleModuleGraph, chunk_group_info::ChunkGroupEntry},
output::{OutputAsset, OutputAssets, OutputAssetsReference},
reference_type::{CommonJsReferenceSubType, ReferenceType},
reference_type::CommonJsReferenceSubType,
resolve::{ResolveErrorMode, origin::PlainResolveOrigin, parse::Request},
};
use turbopack_resolve::ecmascript::cjs_resolve;
@@ -123,9 +121,9 @@ impl Asset for ServerNftJsonAsset {
let mut server_output_assets = traced_modules_for_entries(
module_graph,
Modules::empty(),
self.entries(),
Some(self.ignores()),
true,
None,
)
.await?
@@ -219,35 +217,9 @@ impl ServerNftJsonAsset {
get_next_package(project_path.clone()).await?.join("_")?,
));
let cache_handler = self
.project
.next_config()
.cache_handler(project_path.clone())
.await?;
let cache_handlers = self
.project
.next_config()
.cache_handlers(project_path.clone())
.await?;
// These are used by packages/next/src/server/require-hook.ts
let shared_entries = ["styled-jsx", "styled-jsx/style", "styled-jsx/style.js"];
let cache_handler_entries = cache_handler
.iter()
.chain(cache_handlers.iter())
.map(|f| {
asset_context
.process(
Vc::upcast(FileSource::new(f.clone())),
ReferenceType::CommonJs(CommonJsReferenceSubType::Undefined),
)
.module()
})
.map(|m| m.to_resolved())
.try_join()
.await?;
let entries = match self.ty {
ServerNftType::Full => Either::Left(
if is_standalone {
@@ -270,29 +242,24 @@ impl ServerNftJsonAsset {
};
Ok(Vc::cell(
cache_handler_entries
shared_entries
.into_iter()
.chain(
shared_entries
.into_iter()
.chain(entries)
.map(async |path| {
Ok(cjs_resolve(
next_resolve_origin,
Request::parse_string(path.into()),
CommonJsReferenceSubType::Undefined,
None,
ResolveErrorMode::Error,
)
.await?
.primary_modules()
.await?
.into_iter())
})
.try_flat_join()
.await?,
)
.collect(),
.chain(entries)
.map(async |path| {
Ok(cjs_resolve(
next_resolve_origin,
Request::parse_string(path.into()),
CommonJsReferenceSubType::Undefined,
None,
ResolveErrorMode::Error,
)
.await?
.primary_modules()
.await?
.into_iter())
})
.try_flat_join()
.await?,
))
}
+20 -10
View File
@@ -60,7 +60,7 @@ pub async fn trace_endpoint(
project: ResolvedVc<Project>,
page_name: Option<RcStr>,
module_graph: ResolvedVc<ModuleGraph>,
entry_modules: Vec<ResolvedVc<Box<dyn Module>>>,
entry_module: ResolvedVc<Box<dyn Module>>,
) -> Result<Vc<EndpointTraceResult>> {
let span = tracing::info_span!("trace endpoint", path = debug(&page_name));
async {
@@ -71,19 +71,21 @@ pub async fn trace_endpoint(
.output_file_tracing_includes(project_path.clone())
.await?;
let traced_entries = project.additional_traced_modules();
// Collect referenced assets and externals from module graph
let all_modules = traced_modules_for_entries(
*module_graph,
Vc::cell(entry_modules.clone()),
Vc::cell(vec![entry_module]),
traced_entries,
tracing_exclude_glob(page_name.clone(), project_path.clone(), next_config)
.await?
.map(|v| *v),
false,
Some(next_config.config_file_path(project_path.clone())),
)
.await?;
let module_data = traced_module_data_for_graph(*module_graph, false)
let module_data = traced_module_data_for_graph(*module_graph, traced_entries)
.to_resolved()
.await?;
let module_paths = module_data.await?.idents;
@@ -265,13 +267,13 @@ pub async fn tracing_exclude_glob(
pub async fn traced_modules_for_entries(
module_graph: Vc<ModuleGraph>,
entry_modules: Vc<Modules>,
traced_entries: Vc<Modules>,
exclude_glob: Option<Vc<Glob>>,
entries_are_traced: bool,
forbidden_path: Option<Vc<FileSystemPath>>,
) -> Result<Vc<Modules>> {
let exclude_glob_and_module_idents = if let Some(exclude_glob) = exclude_glob {
let exclude_glob = exclude_glob.await?;
let data = traced_module_data_for_graph(module_graph, entries_are_traced).await?;
let data = traced_module_data_for_graph(module_graph, traced_entries).await?;
Some((exclude_glob, data.idents.await?))
} else {
None
@@ -288,14 +290,20 @@ pub async fn traced_modules_for_entries(
};
let mut forbidden_issues = vec![];
let traced_entries = traced_entries.await?;
let traced_entries_set = traced_entries.iter().copied().collect::<FxHashSet<_>>();
let mut traced_modules = FxIndexSet::default();
module_graph.await?.traverse_edges_dfs(
entry_modules.await?.iter().copied(),
entry_modules
.await?
.iter()
.chain(traced_entries.iter())
.copied(),
&mut (),
|parent, target, _| {
let Some((parent, ref_data)) = parent else {
if entries_are_traced {
if traced_entries_set.contains(&target) {
traced_modules.insert(target);
}
return Ok(GraphTraversalAction::Continue);
@@ -368,20 +376,22 @@ pub struct TracedModuleData {
#[turbo_tasks::function]
pub async fn traced_module_data_for_graph(
module_graph: Vc<ModuleGraph>,
entries_are_traced: bool,
traced_entries: Vc<Modules>,
) -> Result<Vc<TracedModuleData>> {
// This function is very similar to traced_modules_for_entries, but doesn't apply the glob and
// is executed only once for the whole graph.
let module_graph = module_graph.await?;
let entries = module_graph.graphs.iter().flat_map(|g| g.entry_modules());
let traced_entries = traced_entries.await?.into_iter().collect::<FxHashSet<_>>();
let mut traced_modules = FxHashSet::default();
module_graph.traverse_edges_dfs(
entries,
&mut (),
|parent, target, _| {
let Some((parent, ref_data)) = parent else {
if entries_are_traced {
if traced_entries.contains(&target) {
traced_modules.insert(target);
}
return Ok(GraphTraversalAction::Continue);
+1 -1
View File
@@ -1569,7 +1569,7 @@ impl PageEndpoint {
this.pages_project.project(),
Some(pages_function_name(&this.original_name).into()),
ssr_module_graph,
vec![*ssr_module],
*ssr_module,
))
}
}
+45 -2
View File
@@ -23,6 +23,7 @@ use next_core::{
ServerChunkingContextOptions, ServerContextType, get_server_chunking_context,
get_server_chunking_context_with_client_assets, get_server_compile_time_info,
get_server_module_options_context, get_server_resolve_options_context,
get_tracing_compile_time_info,
},
next_telemetry::ProjectFeatureUsageSummary,
parse_segment_config_from_source,
@@ -44,7 +45,7 @@ use turbo_tasks_fs::{
};
use turbo_unix_path::{join_path, unix_to_sys};
use turbopack::{
ModuleAssetContext, evaluate_context::node_build_environment,
ModuleAssetContext, evaluate_context::node_build_environment, externals_tracing_module_context,
global_module_ids::get_global_module_id_strategy, transition::TransitionOptions,
};
use turbopack_core::{
@@ -62,7 +63,7 @@ use turbopack_core::{
issue::{
CollectibleIssuesExt, Issue, IssueExt, IssueFilter, IssueSeverity, IssueStage, StyledString,
},
module::Module,
module::{Module, Modules},
module_graph::{
GraphEntries, ModuleGraph, SingleModuleGraph, VisitedModules,
binding_usage_info::{
@@ -75,6 +76,7 @@ use turbopack_core::{
expand_output_assets,
},
reference::all_assets_from_entries,
reference_type::{CommonJsReferenceSubType, ReferenceType},
resolve::{FindContextFileResult, find_context_file},
version::{
NotFoundVersion, OptionVersionedContent, Update, Version, VersionState, VersionedContent,
@@ -1443,6 +1445,13 @@ impl Project {
.try_flat_join()
.await?;
modules.extend(self.client_main_modules().await?.iter().cloned());
modules.extend(
self.additional_traced_modules()
.await?
.iter()
.cloned()
.map(|m| ChunkGroupEntry::Entry(vec![m])),
);
Ok(Vc::cell(modules))
}
@@ -2591,6 +2600,40 @@ impl Project {
}
.cell())
}
/// Returns any modules specified as `nextConfig.cacheHandler` and/or `nextConfig.cacheHandlers`
#[turbo_tasks::function]
pub async fn additional_traced_modules(self: Vc<Self>) -> Result<Vc<Modules>> {
let project_path = self.project_path().owned().await?;
let cache_handler = self
.next_config()
.cache_handler(project_path.clone())
.await?;
let cache_handlers = self
.next_config()
.cache_handlers(project_path.clone())
.await?;
let asset_context =
externals_tracing_module_context(get_tracing_compile_time_info(), false);
Ok(Vc::cell(
cache_handler
.iter()
.chain(cache_handlers.iter())
.map(|f| {
asset_context
.process(
Vc::upcast(FileSource::new(f.clone())),
ReferenceType::CommonJs(CommonJsReferenceSubType::Undefined),
)
.module()
})
.map(|m| m.to_resolved())
.try_join()
.await?,
))
}
}
/// Scales down or shuts down the Node.js process pool after module graph computation.
@@ -53,6 +53,7 @@ import { sortSortableRoutes } from '../../shared/lib/router/utils/sortable-route
import { defaultOverrides } from '../../server/require-hook'
import { generateRoutesManifest } from '../generate-routes-manifest'
import { Bundler } from '../../lib/bundler'
import { resolveCacheHandlerPathToFilesystem } from '../../lib/format-dynamic-import-path'
interface SharedRouteFields {
/**
@@ -582,6 +583,7 @@ export async function handleBuildComplete({
tracingRoot,
bundler,
hasInstrumentationHook,
config,
})
async function handleTraceFiles(
@@ -2126,6 +2128,7 @@ async function getSharedNodeAssets({
tracingRoot,
requiredServerFiles,
hasInstrumentationHook,
config,
}: {
dir: string
bundler: Bundler
@@ -2133,6 +2136,7 @@ async function getSharedNodeAssets({
tracingRoot: string
requiredServerFiles: string[]
hasInstrumentationHook: boolean
config: NextConfigComplete
}) {
const sharedNodeAssets: Record<string, string> = {}
const sharedNodeAssetsHashes: Record<string, string> = {}
@@ -2199,6 +2203,7 @@ async function getSharedNodeAssets({
bundler
)
// Turbopack handles this automatically and these files are listed in the nft.json files.
if (bundler !== Bundler.Turbopack) {
const { nodeFileTrace } =
require('next/dist/compiled/@vercel/nft') as typeof import('next/dist/compiled/@vercel/nft')
@@ -2231,6 +2236,33 @@ async function getSharedNodeAssets({
...Object.values(defaultOverrides).filter((item) => path.extname(item)),
]
const { cacheHandler, cacheHandlers } = config
// ensure we trace any dependencies needed for a custom incremental cache handler
if (cacheHandler) {
const resolvedPath = resolveCacheHandlerPathToFilesystem(cacheHandler)
necessaryNodeDependencies.push(
require.resolve(
path.isAbsolute(resolvedPath)
? resolvedPath
: path.join(dir, resolvedPath)
)
)
}
if (cacheHandlers) {
for (const handlerPath of Object.values(cacheHandlers)) {
if (handlerPath) {
const resolvedPath = resolveCacheHandlerPathToFilesystem(handlerPath)
necessaryNodeDependencies.push(
require.resolve(
path.isAbsolute(resolvedPath)
? resolvedPath
: path.join(dir, resolvedPath)
)
)
}
}
}
const { fileList, esmFileList } = await nodeFileTrace(
necessaryNodeDependencies,
{
@@ -0,0 +1,51 @@
import { nextTestSetup } from 'e2e-utils'
import path from 'path'
import type { NextAdapter } from 'next'
describe('adapter-cache-handlers', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('includes configured cache handler dependencies in Node adapter outputs', async () => {
const { outputs }: Parameters<NextAdapter['onBuildComplete']>[0] =
await next.readJSON('build-complete.json')
const nodeOutputs = [
...outputs.pages,
...outputs.pagesApi,
...outputs.appPages,
...outputs.appRoutes,
].filter((output) => output.runtime === 'nodejs')
expect(nodeOutputs.length).toBeGreaterThan(0)
const expectedAssets = [
'incremental-cache-handler.js',
'incremental-cache-helper.js',
'use-cache-handler.js',
'use-cache-helper.js',
]
const output = nodeOutputs.find((candidate) =>
expectedAssets.every((filename) =>
Object.values(candidate.assets).some(
(assetPath) => path.basename(assetPath) === filename
)
)
)
expect(output).toBeDefined()
for (const filename of expectedAssets) {
const assetKey = Object.entries(output!.assets).find(
([, assetPath]) => path.basename(assetPath) === filename
)?.[0]
expect(assetKey).toBeString()
if (process.env.IS_TURBOPACK_TEST) {
expect(output!.assetsHashes[assetKey!]).toBeString()
}
}
})
})
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,9 @@
async function getMessage() {
'use cache: remote'
return 'hello world'
}
export default async function Page() {
return <p>{await getMessage()}</p>
}
@@ -0,0 +1,13 @@
const helper = require('./incremental-cache-helper.js')
module.exports = class IncrementalCacheHandler {
constructor() {
helper()
}
async get() {
return null
}
async set() {}
}
@@ -0,0 +1,3 @@
module.exports = function incrementalCacheHelper() {
return 'incremental-cache-helper'
}
@@ -0,0 +1,9 @@
import fs from 'fs/promises'
/** @type {import('next').NextAdapter } */
export default {
name: 'adapter-cache-handlers',
async onBuildComplete(ctx) {
await fs.writeFile('build-complete.json', JSON.stringify(ctx, null, 2))
},
}
@@ -0,0 +1,13 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
adapterPath: require.resolve('./my-adapter.mjs'),
cacheHandler: require.resolve('./incremental-cache-handler.js'),
cacheHandlers: {
remote: require.resolve('./use-cache-handler.js'),
},
cacheComponents: true,
}
module.exports = nextConfig
@@ -0,0 +1,21 @@
const helper = require('./use-cache-helper.js')
/** @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} */
module.exports = {
async get() {
helper()
return undefined
},
async set() {
helper()
},
async refreshTags() {},
async getExpiration() {
return Infinity
},
async updateTags() {},
}
@@ -0,0 +1,3 @@
module.exports = function useCacheHelper() {
return 'use-cache-helper'
}
@@ -83,20 +83,22 @@ async fn compute_side_effect_free_module_info_single(
// parent is a module that depends on it.
|child, parent, _s| {
Ok(if child.is_some() {
match module_side_effects
.get(&parent)
.expect("the map is populated for all modules in this graph")
{
ModuleSideEffects::SideEffectful | ModuleSideEffects::SideEffectFree => {
match module_side_effects.get(&parent) {
Some(ModuleSideEffects::SideEffectful | ModuleSideEffects::SideEffectFree) => {
// We have either already seen this or don't want to follow it
GraphTraversalAction::Exclude
}
ModuleSideEffects::ModuleEvaluationIsSideEffectFree => {
Some(ModuleSideEffects::ModuleEvaluationIsSideEffectFree) => {
// this module is side effect free locally but depends on `child` which is
// effectful so it too is effectful
locally_side_effect_free_modules_that_have_side_effects.insert(parent);
GraphTraversalAction::Continue
}
None => {
// The reverse traversal might end up visiting nodes that were excluded in
// the iter_reachable_nodes traversal above. So ignore them.
GraphTraversalAction::Exclude
}
}
} else {
// entry point, we already determined it was effectful, keep going
@@ -105,6 +107,7 @@ async fn compute_side_effect_free_module_info_single(
},
|_, _, _| Ok(()),
)?;
#[cfg(debug_assertions)]
{
use std::sync::LazyLock;