From 944e45484e169d5da96cfe13ff44a874d822c890 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 19 May 2026 17:58:36 +0200 Subject: [PATCH 1/6] Add nextConfig.instrumentationClientInject (#93785) This allows injecting client bootstrap code using only next.config.js (without writing to any user source files). --- crates/next-core/src/next_config.rs | 10 ++ crates/next-core/src/next_import_map.rs | 87 +++++++++++++++--- .../next/src/build/create-compiler-aliases.ts | 23 ++++- packages/next/src/build/webpack-config.ts | 15 +++ .../loaders/instrumentation-client-stub.js | 5 + .../next-instrumentation-client-loader.ts | 73 +++++++++++++++ packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 8 ++ .../inject/app/layout.tsx | 19 ++++ .../inject/app/page.tsx | 3 + .../inject/app/some-page/page.tsx | 3 + .../inject/inject-a.js | 8 ++ .../inject/inject-b.js | 8 ++ .../inject/instrumentation-client.ts | 8 ++ .../inject/next.config.js | 4 + .../instrumentation-client-hook.test.ts | 91 ++++++++++++++----- 16 files changed, 327 insertions(+), 39 deletions(-) create mode 100644 packages/next/src/build/webpack/loaders/instrumentation-client-stub.js create mode 100644 packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts create mode 100644 test/e2e/instrumentation-client-hook/inject/app/layout.tsx create mode 100644 test/e2e/instrumentation-client-hook/inject/app/page.tsx create mode 100644 test/e2e/instrumentation-client-hook/inject/app/some-page/page.tsx create mode 100644 test/e2e/instrumentation-client-hook/inject/inject-a.js create mode 100644 test/e2e/instrumentation-client-hook/inject/inject-b.js create mode 100644 test/e2e/instrumentation-client-hook/inject/instrumentation-client.ts create mode 100644 test/e2e/instrumentation-client-hook/inject/next.config.js diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index dba9ce2f5558..bbe29f316410 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -96,6 +96,7 @@ pub struct NextConfig { experimental: ExperimentalConfig, images: ImageConfig, page_extensions: Vec, + instrumentation_client_inject: Option>, react_compiler: Option, react_production_profiling: Option, react_strict_mode: Option, @@ -1796,6 +1797,15 @@ impl NextConfig { Vc::cell(extensions) } + #[turbo_tasks::function] + pub fn instrumentation_client_inject(&self) -> Vc> { + Vc::cell( + self.instrumentation_client_inject + .clone() + .unwrap_or_default(), + ) + } + #[turbo_tasks::function] pub fn is_global_not_found_enabled(&self) -> Vc { Vc::cell(self.experimental.global_not_found.unwrap_or_default()) diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 0579532e00e7..0666a9ff31df 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -7,12 +7,13 @@ use next_taskless::{EDGE_NODE_EXTERNALS, NODE_EXTERNALS}; use rustc_hash::FxHashMap; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{FxIndexMap, ResolvedVc, Vc, fxindexmap}; -use turbo_tasks_fs::{FileSystem, FileSystemPath, to_sys_path}; +use turbo_tasks_fs::{FileContent, FileSystem, FileSystemPath, to_sys_path}; use turbopack_core::{ + asset::AssetContent, issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString}, reference_type::{CommonJsReferenceSubType, ReferenceType}, resolve::{ - AliasPattern, ExternalTraced, ExternalType, ResolveAliasMap, SubpathValue, + AliasPattern, ExternalTraced, ExternalType, ResolveAliasMap, ResolveResult, SubpathValue, node::node_cjs_resolve_options, options::{ConditionValue, ImportMap, ImportMapping, ResolvedMap}, parse::Request, @@ -20,6 +21,7 @@ use turbopack_core::{ resolve, }, source::Source, + virtual_source::VirtualSource, }; use turbopack_node::execution_context::ExecutionContext; @@ -231,7 +233,7 @@ pub async fn get_next_client_import_map( ClientContextType::Other => {} } - insert_instrumentation_client_alias(&mut import_map, project_path).await?; + insert_instrumentation_client_alias(&mut import_map, project_path, next_config).await?; insert_server_only_error_alias(&mut import_map); @@ -1376,24 +1378,81 @@ fn insert_package_alias(import_map: &mut ImportMap, prefix: &str, package_root: ); } -/// Handles instrumentation-client.ts bundling logic +/// Handles instrumentation-client.ts bundling logic. +/// +/// Resolves the `private-next-instrumentation-client` alias to a virtual module +/// that first requires each entry of `instrumentationClientInject` for side +/// effects (in array order) and then re-exports the user's +/// `instrumentation-client.{pageExt}` file via the +/// `private-next-instrumentation-client-user` alias. async fn insert_instrumentation_client_alias( import_map: &mut ImportMap, project_path: FileSystemPath, + next_config: Vc, ) -> Result<()> { + let user_file_alternatives = vec![ + request_to_import_mapping(project_path.clone(), rcstr!("./src/instrumentation-client")), + request_to_import_mapping( + project_path.clone(), + rcstr!("./src/instrumentation-client.ts"), + ), + request_to_import_mapping(project_path.clone(), rcstr!("./instrumentation-client")), + request_to_import_mapping(project_path.clone(), rcstr!("./instrumentation-client.ts")), + ImportMapping::Ignore.resolved_cell(), + ]; + + let injects = next_config.instrumentation_client_inject().await?; + + if injects.is_empty() { + insert_alias_to_alternatives( + import_map, + rcstr!("private-next-instrumentation-client"), + user_file_alternatives, + ); + return Ok(()); + } + + // The user file is reached through a separate alias so the existing + // alternative resolution stays unchanged. insert_alias_to_alternatives( import_map, + rcstr!("private-next-instrumentation-client-user"), + user_file_alternatives, + ); + + let injects = injects + .iter() + .map(|s| s.as_str()) + .chain(std::iter::once("private-next-instrumentation-client-user")); + + let mut body = String::new(); + for (i, spec) in injects.clone().enumerate() { + body.push_str(&format!( + "var mod_{i} = require({});\n", + serde_json::to_string(spec)? + )); + } + body.push_str("module.exports = { onRouterTransitionStart(url, type) {\n"); + for (i, _) in injects.enumerate() { + body.push_str(&format!( + " mod_{i}?.onRouterTransitionStart?.(url, type);\n" + )); + } + body.push_str("}};\n"); + + let virtual_source = VirtualSource::new( + project_path.join("__next_instrumentation_client.js")?, + AssetContent::file(FileContent::Content(body.into()).cell()), + ) + .to_resolved() + .await?; + + import_map.insert_exact_alias( rcstr!("private-next-instrumentation-client"), - vec![ - request_to_import_mapping(project_path.clone(), rcstr!("./src/instrumentation-client")), - request_to_import_mapping( - project_path.clone(), - rcstr!("./src/instrumentation-client.ts"), - ), - request_to_import_mapping(project_path.clone(), rcstr!("./instrumentation-client")), - request_to_import_mapping(project_path.clone(), rcstr!("./instrumentation-client.ts")), - ImportMapping::Ignore.resolved_cell(), - ], + ImportMapping::Direct( + ResolveResult::source(ResolvedVc::upcast(virtual_source)).resolved_cell(), + ) + .resolved_cell(), ); Ok(()) diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index a258469fc5f7..c9ef02997af2 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -25,6 +25,17 @@ interface CompilerAliases { const isReact19 = typeof React.use === 'function' +/** + * Absolute path to the placeholder file that `private-next-instrumentation-client` + * resolves to. Its contents are replaced at build time by + * `next-instrumentation-client-loader` via a `module.rules` entry in + * `webpack-config.ts`. + */ +const INSTRUMENTATION_CLIENT_STUB_PATH = path.join( + NEXT_PROJECT_ROOT, + 'dist/build/webpack/loaders/instrumentation-client-stub.js' +) + export function createWebpackAliases({ distDir, isClient, @@ -138,7 +149,17 @@ export function createWebpackAliases({ [ROOT_DIR_ALIAS]: dir, ...(isClient ? { - 'private-next-instrumentation-client': [ + // `private-next-instrumentation-client` resolves to a placeholder + // file whose contents are replaced at build time by + // `next-instrumentation-client-loader` (registered via a + // `module.rules` entry in webpack-config.ts). The emitted module + // requires each `instrumentationClientInject` entry for side effects, + // then re-exports the user's `instrumentation-client.{pageExt}` file + // (resolved through the `private-next-instrumentation-client-user` + // alias below). + 'private-next-instrumentation-client': + INSTRUMENTATION_CLIENT_STUB_PATH, + 'private-next-instrumentation-client-user': [ path.join(dir, 'src', 'instrumentation-client'), path.join(dir, 'instrumentation-client'), 'private-next-empty-module', diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 87ca8879d491..e7bed6304611 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1360,6 +1360,7 @@ export default async function getBaseWebpackConfig( 'error-loader', 'next-swc-loader', 'next-client-pages-loader', + 'next-instrumentation-client-loader', 'next-image-loader', 'next-metadata-image-loader', 'next-style-loader', @@ -1933,6 +1934,20 @@ export default async function getBaseWebpackConfig( test: /[\\/]next[\\/]dist[\\/](esm[\\/])?build[\\/]webpack[\\/]loaders[\\/]next-flight-loader[\\/]action-client-wrapper\.js/, sideEffects: false, }, + // The placeholder file aliased from `private-next-instrumentation-client`. + // The loader replaces its contents with a synthetic module that + // requires each `instrumentationClientInject` entry, then re-exports + // the user's `instrumentation-client.{pageExt}` (composing + // `onRouterTransitionStart` hooks across all of them). + { + test: /[\\/]next[\\/]dist[\\/](esm[\\/])?build[\\/]webpack[\\/]loaders[\\/]instrumentation-client-stub\.js$/, + use: { + loader: 'next-instrumentation-client-loader', + options: { + injects: JSON.stringify(config.instrumentationClientInject), + }, + }, + }, { // This loader rule should be before other rules, as it can output code // that still contains `"use client"` or `"use server"` statements that diff --git a/packages/next/src/build/webpack/loaders/instrumentation-client-stub.js b/packages/next/src/build/webpack/loaders/instrumentation-client-stub.js new file mode 100644 index 000000000000..e61ff82110d4 --- /dev/null +++ b/packages/next/src/build/webpack/loaders/instrumentation-client-stub.js @@ -0,0 +1,5 @@ +// Placeholder module. The `private-next-instrumentation-client` webpack alias +// resolves to this file so the resolver has a real path to find; its contents +// are then replaced by `next-instrumentation-client-loader` via a `module.rules` +// entry in webpack-config.ts. +module.exports = {} diff --git a/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts b/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts new file mode 100644 index 000000000000..2c9efe1ab16d --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts @@ -0,0 +1,73 @@ +import { promisify } from 'util' +import type { webpack } from 'next/dist/compiled/webpack/webpack' + +/** + * Loader options for `next-instrumentation-client-loader`. The list of inject + * specifiers is JSON-stringified so it can travel through the loader query + * string. + */ +export type InstrumentationClientLoaderOptions = { + /** JSON-stringified `string[]` of module specifiers. */ + injects: string +} + +const NextInstrumentationClientLoader: webpack.LoaderDefinitionFunction = + function () { + const callback = this.async() + const { injects: injectsStringified } = this.getOptions() + const injects = JSON.parse(injectsStringified || '[]') as string[] + + // No injects: the alias is a transparent passthrough to the user's + // `instrumentation-client.{pageExt}` (or the empty module fallback). + if (injects.length === 0) { + callback( + null, + `module.exports = require('private-next-instrumentation-client-user');\n` + ) + return + } + + // Resolve each inject specifier against the project root so the emitted + // `require()` calls don't get resolved relative to the stub's location + // inside `node_modules/next/`. Bare specifiers (npm package names) are + // resolved against the project's `node_modules`. + const resolve = promisify(this.resolve) + const rootContext = this.rootContext + + Promise.all(injects.map((spec) => resolve(rootContext, spec))) + .then((resolvedInjects) => { + const allModules = [ + ...resolvedInjects, + 'private-next-instrumentation-client-user', + ] + + const lines: string[] = [] + allModules.forEach((spec, i) => { + lines.push(`var mod_${i} = require(${JSON.stringify(spec)});`) + }) + + // Compose a single `onRouterTransitionStart` that fans out to every + // module's hook (when exported), in array order, with the user file's + // hook running last. + const hookCalls = allModules + .map( + // Webpack doesn't transpile this, so use a manual version of optional chaining. + (_, i) => + ` mod_${i} && mod_${i}.onRouterTransitionStart && mod_${i}.onRouterTransitionStart(url, type);` + ) + .join('\n') + + lines.push( + `module.exports = {`, + ` onRouterTransitionStart: function (url, type) {`, + hookCalls, + ` },`, + `};` + ) + + callback(null, lines.join('\n') + '\n') + }) + .catch((err) => callback(err)) + } + +export default NextInstrumentationClientLoader diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 72cf0849feae..dcda307a385c 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -762,6 +762,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .record(z.string(), z.array(z.string())) .optional(), pageExtensions: z.array(z.string()).min(1).optional(), + instrumentationClientInject: z.array(z.string()).optional(), poweredByHeader: z.boolean().optional(), productionBrowserSourceMaps: z.boolean().optional(), reactCompiler: z.union([ diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 72c7610e105e..b8f11833fa34 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1527,6 +1527,13 @@ export interface NextConfig { /** @see [Including non-page files in the pages directory](https://nextjs.org/docs/app/api-reference/config/next-config-js/pageExtensions) */ pageExtensions?: string[] + /** + * Module specifiers that are required for side effects on the client before + * hydration, in array order, ahead of the user's `instrumentation-client.{ts,js}`. + * Each entry may be a bare npm package name or a path relative to the project root. + */ + instrumentationClientInject?: string[] + /** @see [Compression documentation](https://nextjs.org/docs/app/api-reference/config/next-config-js/compress) */ compress?: boolean @@ -1874,6 +1881,7 @@ export const defaultConfig = Object.freeze({ generateBuildId: () => null, generateEtags: true, pageExtensions: ['tsx', 'ts', 'jsx', 'js'], + instrumentationClientInject: [], poweredByHeader: true, compress: true, images: imageConfigDefault, diff --git a/test/e2e/instrumentation-client-hook/inject/app/layout.tsx b/test/e2e/instrumentation-client-hook/inject/app/layout.tsx new file mode 100644 index 000000000000..568b8766b221 --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/app/layout.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export default function RootLayout({ children }) { + return ( + + +
    +
  • + Home +
  • +
  • + Some Page +
  • +
+ {children} + + + ) +} diff --git a/test/e2e/instrumentation-client-hook/inject/app/page.tsx b/test/e2e/instrumentation-client-hook/inject/app/page.tsx new file mode 100644 index 000000000000..113324caa66e --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Home

+} diff --git a/test/e2e/instrumentation-client-hook/inject/app/some-page/page.tsx b/test/e2e/instrumentation-client-hook/inject/app/some-page/page.tsx new file mode 100644 index 000000000000..8c6bb6df3ca6 --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/app/some-page/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Some page

+} diff --git a/test/e2e/instrumentation-client-hook/inject/inject-a.js b/test/e2e/instrumentation-client-hook/inject/inject-a.js new file mode 100644 index 000000000000..ec97b40782f5 --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/inject-a.js @@ -0,0 +1,8 @@ +window.__INJECT_ORDER = window.__INJECT_ORDER || [] +window.__INJECT_ORDER.push('a') +window.__INJECT_A_EXECUTED_AT = performance.now() + +export function onRouterTransitionStart(href, navigateType) { + const pathname = new URL(href, window.location.href).pathname + console.log(`[Router Transition Start] [${navigateType}] ${pathname} a`) +} diff --git a/test/e2e/instrumentation-client-hook/inject/inject-b.js b/test/e2e/instrumentation-client-hook/inject/inject-b.js new file mode 100644 index 000000000000..e19537614c5e --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/inject-b.js @@ -0,0 +1,8 @@ +window.__INJECT_ORDER = window.__INJECT_ORDER || [] +window.__INJECT_ORDER.push('b') +window.__INJECT_B_EXECUTED_AT = performance.now() + +export function onRouterTransitionStart(href, navigateType) { + const pathname = new URL(href, window.location.href).pathname + console.log(`[Router Transition Start] [${navigateType}] ${pathname} b`) +} diff --git a/test/e2e/instrumentation-client-hook/inject/instrumentation-client.ts b/test/e2e/instrumentation-client-hook/inject/instrumentation-client.ts new file mode 100644 index 000000000000..6a82ba52b6e6 --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/instrumentation-client.ts @@ -0,0 +1,8 @@ +;(window as any).__INJECT_ORDER = (window as any).__INJECT_ORDER || [] +;(window as any).__INJECT_ORDER.push('user') +;(window as any).__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now() + +export function onRouterTransitionStart(href: string, navigateType: string) { + const pathname = new URL(href, window.location.href).pathname + console.log(`[Router Transition Start] [${navigateType}] ${pathname} user`) +} diff --git a/test/e2e/instrumentation-client-hook/inject/next.config.js b/test/e2e/instrumentation-client-hook/inject/next.config.js new file mode 100644 index 000000000000..ee32e6ca3fed --- /dev/null +++ b/test/e2e/instrumentation-client-hook/inject/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + instrumentationClientInject: ['./inject-a.js', './inject-b.js'], +} diff --git a/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts b/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts index b47db90edecb..2a03659e432c 100644 --- a/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts +++ b/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts @@ -1,4 +1,4 @@ -import { nextTestSetup } from 'e2e-utils' +import { isNextDev, nextTestSetup } from 'e2e-utils' import { retry } from 'next-test-utils' import path from 'path' @@ -49,21 +49,21 @@ describe('Instrumentation Client Hook', () => { }) }) + function filterNavigationStartLogs(logs: Array<{ message: string }>) { + const result = [] + for (const log of logs) { + if (log.message.startsWith('[Router Transition Start]')) { + result.push(log.message) + } + } + return result + } + describe('onRouterTransitionStart', () => { const { next } = nextTestSetup({ files: path.join(__dirname, 'app-router'), }) - function filterNavigationStartLogs(logs: Array<{ message: string }>) { - const result = [] - for (const log of logs) { - if (log.message.startsWith('[Router Transition Start]')) { - result.push(log.message) - } - } - return result - } - it('onRouterTransitionStart fires at the start of a navigation', async () => { const browser = await next.browser('/') @@ -102,12 +102,62 @@ describe('Instrumentation Client Hook', () => { }) }) - describe('HMR in development mode', () => { - const { next, isNextDev } = nextTestSetup({ - files: path.join(__dirname, 'app-router'), + describe('instrumentationClientInject', () => { + const { next } = nextTestSetup({ + files: path.join(__dirname, 'inject'), }) - if (isNextDev) { + it('runs each injected entry before the user instrumentation-client and before hydration, in array order', async () => { + const browser = await next.browser('/') + + const order = await browser.eval(`window.__INJECT_ORDER`) + expect(order).toEqual(['a', 'b', 'user']) + + const injectA = await browser.eval(`window.__INJECT_A_EXECUTED_AT`) + const injectB = await browser.eval(`window.__INJECT_B_EXECUTED_AT`) + const userTime = await browser.eval( + `window.__INSTRUMENTATION_CLIENT_EXECUTED_AT` + ) + const hydrationTime = await browser.eval(`window.__NEXT_HYDRATED_AT`) + + expect(injectA).toBeDefined() + expect(injectB).toBeDefined() + expect(userTime).toBeDefined() + expect(hydrationTime).toBeDefined() + + expect(injectA).toBeLessThanOrEqual(injectB) + expect(injectB).toBeLessThanOrEqual(userTime) + expect(userTime).toBeLessThan(hydrationTime) + }) + + it('still surfaces onRouterTransitionStart from the user instrumentation-client when injects are configured', async () => { + const browser = await next.browser('/') + + const linkToSomePage = await browser.elementByCss('a[href="/some-page"]') + await linkToSomePage.click() + await browser.elementById('some-page') + + const linkToHome = await browser.elementByCss('a[href="/"]') + await linkToHome.click() + await browser.elementById('home') + + expect(filterNavigationStartLogs(await browser.log())).toEqual([ + '[Router Transition Start] [push] /some-page a', + '[Router Transition Start] [push] /some-page b', + '[Router Transition Start] [push] /some-page user', + '[Router Transition Start] [push] / a', + '[Router Transition Start] [push] / b', + '[Router Transition Start] [push] / user', + ]) + }) + }) + + if (isNextDev) { + describe('HMR in development mode', () => { + const { next } = nextTestSetup({ + files: path.join(__dirname, 'app-router'), + }) + it('should reload instrumentation-client when modified', async () => { const browser = await next.browser('/') const initialTime = await browser.eval( @@ -146,13 +196,6 @@ describe('Instrumentation Client Hook', () => { // Restore the original file await next.patchFile(instrumentationPath, originalContent) }) - } else { - // Add a dummy test when not in dev mode - it('skips tests in non-dev mode', () => { - console.log( - 'Skipping instrumentation-client-hook tests in non-dev mode' - ) - }) - } - }) + }) + } }) From 4aeea6226962ff51ef91e50d3417c57c31a1faf6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 19 May 2026 09:05:03 -0700 Subject: [PATCH 2/6] Remove vercel approvers (#93921) Outdated. we don't really use these --- packages/next/src/client/.vercel.approvers | 3 --- packages/next/src/server/.vercel.approvers | 8 -------- 2 files changed, 11 deletions(-) delete mode 100644 packages/next/src/client/.vercel.approvers delete mode 100644 packages/next/src/server/.vercel.approvers diff --git a/packages/next/src/client/.vercel.approvers b/packages/next/src/client/.vercel.approvers deleted file mode 100644 index 278e04f7809c..000000000000 --- a/packages/next/src/client/.vercel.approvers +++ /dev/null @@ -1,3 +0,0 @@ -use-intersection.tsx @timneutkens:notify -use-intersection.tsx @ijjk:notify -use-intersection.tsx @shuding:notify diff --git a/packages/next/src/server/.vercel.approvers b/packages/next/src/server/.vercel.approvers deleted file mode 100644 index f22d7dcea72b..000000000000 --- a/packages/next/src/server/.vercel.approvers +++ /dev/null @@ -1,8 +0,0 @@ -config.ts @timneutkens:notify -config.ts @ijjk:notify -config.ts @shuding:notify -config.ts @huozhi:notify -serve-static.ts @timneutkens:notify -serve-static.ts @ijjk:notify -serve-static.ts @shuding:notify -serve-static.ts @huozhi:notify From a722ae9f8f4de7146889089bb0ef808db96f63cc Mon Sep 17 00:00:00 2001 From: Darpan Kakadia Date: Tue, 19 May 2026 18:14:26 +0200 Subject: [PATCH 3/6] [otel] honor traceparent for verbose mode spans (#93874) ## Problem Fixes the trace propagation issue reported in https://github.com/vercel/otel/issues/107. `NEXT_OTEL_VERBOSE=1` opened the `NextServer.getRequestHandler` span before request header propagation, so incoming `traceparent` was ignored and framework spans started a new trace. ## Solution Extract propagated request context before wrapper request-handler spans. Add focused e2e coverage in the existing OpenTelemetry suite for verbose spans inheriting the incoming `traceparent`, including the parent span id. Validated with `pnpm --filter=next build` and `pnpm test-dev-turbo test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts`. --- packages/next/src/server/lib/trace/tracer.ts | 8 +++ packages/next/src/server/next.ts | 19 +++--- .../instrumentation/opentelemetry.test.ts | 68 +++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/next/src/server/lib/trace/tracer.ts b/packages/next/src/server/lib/trace/tracer.ts index b7ffe39cf32b..8866835fd73e 100644 --- a/packages/next/src/server/lib/trace/tracer.ts +++ b/packages/next/src/server/lib/trace/tracer.ts @@ -250,6 +250,14 @@ class NextTracerImpl implements NextTracer { ): T { const activeContext = context.active() + if ( + !NEXT_OTEL_PERFORMANCE_PREFIX && + !this.isTracingEnabled() && + !trace.getSpanContext(activeContext) + ) { + return fn() + } + if (force) { const remoteContext = propagation.extract(ROOT_CONTEXT, carrier, getter) diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index c9e2a0af88f8..adf3cc25b4d1 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -149,10 +149,13 @@ export class NextServer implements NextWrapperServer { res: ServerResponse, parsedUrl?: NextUrlWithParsedQuery ) => { - return getTracer().trace(NextServerSpan.getRequestHandler, async () => { - const requestHandler = await this.getServerRequestHandler() - return requestHandler(req, res, parsedUrl) - }) + const tracer = getTracer() + return tracer.withPropagatedContext(req.headers, () => + tracer.trace(NextServerSpan.getRequestHandler, async () => { + const requestHandler = await this.getServerRequestHandler() + return requestHandler(req, res, parsedUrl) + }) + ) } } @@ -166,13 +169,13 @@ export class NextServer implements NextWrapperServer { res: ServerResponse, parsedUrl?: NextUrlWithParsedQuery ) => { - return getTracer().trace( - NextServerSpan.getRequestHandlerWithMetadata, - async () => { + const tracer = getTracer() + return tracer.withPropagatedContext(req.headers, () => + tracer.trace(NextServerSpan.getRequestHandlerWithMetadata, async () => { const server = await this.getServer() const handler = server.getRequestHandlerWithMetadata(meta) return handler(req, res, parsedUrl) - } + }) ) } } diff --git a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts index fa07de919651..525eda27ea6b 100644 --- a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts @@ -1159,6 +1159,74 @@ describe('opentelemetry', () => { ) } }) +;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)( + 'opentelemetry NEXT_OTEL_VERBOSE=1', + () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + dependencies: require('./package.json').dependencies, + env: { + TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT), + NEXT_TELEMETRY_DISABLED: '1', + NEXT_OTEL_VERBOSE: '1', + }, + }) + + if (skipped) { + return + } + + let collector: Collector | undefined + + beforeEach(async () => { + collector = await connectCollector({ port: COLLECTOR_PORT }) + }) + + afterEach(async () => { + await collector?.shutdown() + collector = undefined + }) + + // Regression for https://github.com/vercel/otel/issues/107. + it('all spans (including verbose) inherit traceId from incoming traceparent header', async () => { + const pathname = '/app/param/rsc-fetch' + await next.fetch(pathname, { + headers: { + traceparent: `00-${EXTERNAL.traceId}-${EXTERNAL.spanId}-01`, + }, + }) + + let spans: SavedSpan[] = [] + await retry(async () => { + const all = collector?.getSpans() ?? [] + const root = all.find( + (s) => + s.attributes?.['next.span_type'] === 'BaseServer.handleRequest' && + s.attributes?.['http.target'] === pathname + ) + expect(root).toBeDefined() + expect(root!.traceId).toBe(EXTERNAL.traceId) + + spans = all.filter((s) => s.traceId === root!.traceId) + expect(spans.length).toBeGreaterThan(1) + + const verbose = spans.find( + (s) => + s.attributes?.['next.span_type'] === 'NextServer.getRequestHandler' + ) + expect(verbose).toBeDefined() + expect(verbose!.traceId).toBe(EXTERNAL.traceId) + const parentSpanId = verbose!.parentId + expect(parentSpanId).toBe(EXTERNAL.spanId) + }) + + for (const span of spans) { + expect(span.traceId).toBe(EXTERNAL.traceId) + } + }) + } +) describe('opentelemetry with disabled fetch tracing', () => { const { next, skipped } = nextTestSetup({ From 085311e3b629f43ebb84b50b726a2a304fb94a23 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 19 May 2026 18:21:08 +0200 Subject: [PATCH 4/6] Fix cross-param leak with `prefetch={true}` and `cachedNavigations` (#93940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both `cachedNavigations` and `varyParams` are enabled and a `` points to a page with fallback params, the resulting Full prefetch could leak param-specific content into the cache entry of a different param value. As of today, `varyParams` tracking only works within the static stage portion of a response. A Full prefetch response covers all stages, so the server-reported set is incomplete and can't be trusted — it may arrive empty. The client then re-keys the entry under a vary path where every path param is replaced with ``, so a subsequent lookup for a different param value collides on that entry and reads the previously prefetched page's content. This change makes `fulfillEntrySpawnedByRuntimePrefetch` skip the re-keying when `fetchStrategy === FetchStrategy.Full` and keep the entry pinned to its concrete vary path. That aligns Full prefetches with how they're already keyed when `cachedNavigations` is disabled, where the server sends no `varyParams` and the client never re-keys. The trade-off is that we miss out on cross-param sharing for Full prefetches even when a segment genuinely doesn't depend on a given param. Ideally we'd track `varyParams` through the dynamic stage too, but that's not possible today: the `varyParams` thenable would need to resolve before the render completes, while React waits for the thenable to resolve before completing the render — a deadlock in the Flight response. This limitation should naturally go away as `prefetch={true}` evolves under Cache Components, where it will likely become a runtime prefetch — a render aborted at a known stage — at which point we can track `varyParams` properly across stages for these requests, likely switching from a thenable to an async iterable. --- .../client/components/segment-cache/cache.ts | 34 ++++++++---- .../with-fallback-params/[slug]/layout.tsx | 22 -------- .../app/with-fallback-params/layout.tsx | 51 ++++++++++++++++++ .../app/with-fallback-params/page.tsx | 3 ++ .../cached-navigations.test.ts | 54 +++++++++++++++++++ .../components/link-accordion.tsx | 33 ++++++++++++ 6 files changed, 165 insertions(+), 32 deletions(-) delete mode 100644 test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index d0ecf46e1cdb..3430c3ac7ffc 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -2594,6 +2594,24 @@ function fulfillEntrySpawnedByRuntimePrefetch( PendingSegmentCacheEntry > | null ) { + // Decide whether to re-key the entry under a more generic vary path based on + // which params the segment actually depends on. + // + // Skip re-keying for Full prefetches: as of today, `varyParams` tracking only + // works within the static stage portion of a response. A Full prefetch + // response covers all stages, and we can't track params during the dynamic + // stage without dead-locking the Flight stream, so the server-reported set is + // incomplete and can't be trusted for the full response. Re-keying with an + // untrustworthy set could replace concrete params with Fallback and let + // unrelated URLs read each other's content from the cache. + // + // When non-null, this is the param set to re-key by; when null, the entry + // stays keyed by the request's concrete vary path. + const fulfilledVaryParams = + process.env.__NEXT_VARY_PARAMS && fetchStrategy !== FetchStrategy.Full + ? segmentVaryParams + : null + // We should only write into cache entries that are owned by us. Or create // a new one and write into that. We must never write over an entry that was // created by a different task, because that causes data races. @@ -2608,11 +2626,10 @@ function fulfillEntrySpawnedByRuntimePrefetch( staleAt, isPartial ) - // Re-key the entry based on which params the segment actually depends on. - if (process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null) { + if (fulfilledVaryParams !== null) { const fulfilledVaryPath = getFulfilledSegmentVaryPath( tree.varyPath, - segmentVaryParams + fulfilledVaryParams ) const isRevalidation = false setInCacheMap( @@ -2638,11 +2655,10 @@ function fulfillEntrySpawnedByRuntimePrefetch( staleAt, isPartial ) - // Re-key the entry based on which params the segment actually depends on. - if (process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null) { + if (fulfilledVaryParams !== null) { const fulfilledVaryPath = getFulfilledSegmentVaryPath( tree.varyPath, - segmentVaryParams + fulfilledVaryParams ) const isRevalidation = false setInCacheMap( @@ -2664,11 +2680,9 @@ function fulfillEntrySpawnedByRuntimePrefetch( staleAt, isPartial ) - // Use the fulfilled vary path if available, otherwise fall back to - // the request vary path. const varyPath = - process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null - ? getFulfilledSegmentVaryPath(tree.varyPath, segmentVaryParams) + fulfilledVaryParams !== null + ? getFulfilledSegmentVaryPath(tree.varyPath, fulfilledVaryParams) : getSegmentVaryPathForRequest(fetchStrategy, tree) upsertSegmentEntry(now, varyPath, newEntry) } diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx deleted file mode 100644 index 836805e6038e..000000000000 --- a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Link from 'next/link' -import { ReactNode } from 'react' - -export default function ParamsLayout({ children }: { children: ReactNode }) { - return ( - <> -
    -
  • - - /with-fallback-params/foo - -
  • -
  • - - /with-fallback-params/bar - -
  • -
- {children} - - ) -} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx new file mode 100644 index 000000000000..fcc69c74f16d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx @@ -0,0 +1,51 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { ReactNode } from 'react' +import { LinkAccordion } from '../../components/link-accordion' + +export default function ParamsLayout({ children }: { children: ReactNode }) { + const { bfcacheId } = useRouter() + + return ( + <> +
    +
  • + + /with-fallback-params + +
  • +
  • + + /with-fallback-params/foo + +
  • +
  • + + /with-fallback-params/bar + +
  • +
  • + + /with-fallback-params/foo (prefetch=true) + +
  • +
  • + + /with-fallback-params/bar (prefetch=true) + +
  • +
+ {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx new file mode 100644 index 000000000000..2dcc5a7d10d4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx @@ -0,0 +1,3 @@ +export default function FallbackParamsHub() { + return

Fallback Params Hub

+} diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts index 95c7e234bceb..fdfb8197e8e1 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts +++ b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts @@ -935,4 +935,58 @@ describe('cached navigations', () => { 'Dynamic content' ) }) + + it('does not leak resolved param-specific content across params when using prefetch={true}', async () => { + let page: Playwright.Page + const browser = await next.browser('/with-fallback-params', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page) + + // 1. Unveil the foo link. With prefetch={true} this triggers a Full + // dynamic prefetch that returns the fully rendered page. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/with-fallback-params/foo"]') + .click() + }) + + // 2. Navigate to /with-fallback-params/foo using the prefetched data. + await act(async () => { + await browser.elementByCss('a[href="/with-fallback-params/foo"]').click() + }, 'no-requests') + await retry(async () => { + expect(await browser.elementById('params-boundary').text()).toBe( + 'Param: foo' + ) + }) + + // 3. Return to the hub via the layout's back link. + await browser.elementByCss('a[href="/with-fallback-params"]')?.click() + await retry(async () => { + expect(await browser.elementByCss('h1').text()).toBe( + 'Fallback Params Hub' + ) + }) + + // 4. Unveil the bar link. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/with-fallback-params/bar"]') + .click() + }) + + // 5. Navigate to /with-fallback-params/bar; should render bar's content + // (sourced from the bar prefetch in step 4), not foo's. + await act(async () => { + await browser.elementByCss('a[href="/with-fallback-params/bar"]').click() + }, 'no-requests') + await retry(async () => { + const barContent = await browser.elementById('params-boundary').text() + expect(barContent).toBe('Param: bar') + expect(barContent).not.toContain('foo') + }) + }) }) diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx new file mode 100644 index 000000000000..fd8f6781732e --- /dev/null +++ b/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx @@ -0,0 +1,33 @@ +'use client' + +import Link, { LinkProps } from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + children, + prefetch, +}: { + href: string + children: React.ReactNode + prefetch?: LinkProps['prefetch'] +}) { + const [isVisible, setIsVisible] = useState(false) + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + + {children} + + ) : ( + `${children} (link is hidden)` + )} + + ) +} From 079df0f6f4bb242cd92b2c64c4a76d10986373ac Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 19 May 2026 09:45:55 -0700 Subject: [PATCH 5/6] Enable rootParams by default (#93863) rootParams is now available by default. the flag is removed. There are still intentional limitations. For instance rootParams cannot be used in route handlers and Server Actions. The feature will be expanded with some support for this in the future. --- crates/next-core/src/next_config.rs | 11 -- crates/next-core/src/next_import_map.rs | 16 +-- crates/next-core/src/next_root_params/mod.rs | 107 +++++++----------- packages/next/src/build/index.ts | 3 +- packages/next/src/build/type-check.ts | 7 +- packages/next/src/build/webpack-config.ts | 16 --- packages/next/src/cli/next-test.ts | 2 - packages/next/src/cli/next-typegen.ts | 5 +- .../typescript/writeAppTypeDeclarations.ts | 14 +-- .../next/src/lib/verify-typescript-setup.ts | 4 - packages/next/src/server/config-shared.ts | 5 - packages/next/src/server/config.ts | 7 ++ .../router-utils/root-params-type-utils.ts | 16 +-- .../lib/router-utils/setup-dev-bundler.ts | 6 +- .../acceptance-app/rsc-build-errors.test.ts | 39 ++----- ...component-compiler-errors-in-pages.test.ts | 7 -- .../typescript-app-type-declarations.test.ts | 2 + .../next.config.ts | 6 +- .../generate-static-params/next.config.ts | 2 +- .../fixtures/multiple-roots/next.config.ts | 6 +- .../fixtures/simple/next.config.ts | 6 +- .../fixtures/use-cache-build/next.config.ts | 1 - .../fixtures/use-cache-dedup/next.config.ts | 1 - .../fixtures/use-cache-private/next.config.ts | 1 - .../fixtures/use-cache-runtime/next.config.ts | 1 - .../next.config.js | 3 - .../next.config.ts | 4 +- test/rspack-dev-tests-manifest.json | 2 +- test/unit/write-app-declarations.test.ts | 11 +- 29 files changed, 85 insertions(+), 226 deletions(-) diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index bbe29f316410..c409d0331a6a 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1220,7 +1220,6 @@ pub struct ExperimentalConfig { /// This field is kept for backwards compatibility during migration. cache_components: Option, use_cache: Option, - root_params: Option, runtime_server_deployment_id: Option, supports_immutable_assets: Option, @@ -2179,16 +2178,6 @@ impl NextConfig { ) } - #[turbo_tasks::function] - pub fn enable_root_params(&self) -> Vc { - Vc::cell( - self.experimental - .root_params - // rootParams should be enabled implicitly in cacheComponents. - .unwrap_or(self.cache_components.unwrap_or(false)), - ) - } - #[turbo_tasks::function] pub fn is_using_adapter(&self) -> Vc { Vc::cell(self.adapter_path.is_some()) diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 0666a9ff31df..31da89706419 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -211,13 +211,7 @@ pub async fn get_next_client_import_map( rcstr!("next/dist/compiled/server-only") => rcstr!("next/dist/compiled/server-only/index"), rcstr!("next/dist/compiled/client-only") => rcstr!("next/dist/compiled/client-only/index"),}, ); - insert_next_root_params_mapping( - &mut import_map, - next_config.enable_root_params(), - Either::Right(ty.clone()), - None, - ) - .await?; + insert_next_root_params_mapping(&mut import_map, Either::Right(ty.clone()), None).await?; match ty { ClientContextType::Pages { .. } @@ -747,13 +741,7 @@ async fn insert_next_server_special_aliases( } } - insert_next_root_params_mapping( - import_map, - next_config.enable_root_params(), - Either::Left(ty), - collected_root_params, - ) - .await?; + insert_next_root_params_mapping(import_map, Either::Left(ty), collected_root_params).await?; import_map.insert_exact_alias( rcstr!("@vercel/og"), diff --git a/crates/next-core/src/next_root_params/mod.rs b/crates/next-core/src/next_root_params/mod.rs index c9a3acdb6818..0e291da2deda 100644 --- a/crates/next-core/src/next_root_params/mod.rs +++ b/crates/next-core/src/next_root_params/mod.rs @@ -30,26 +30,20 @@ use crate::{ pub async fn insert_next_root_params_mapping( import_map: &mut ImportMap, - is_root_params_enabled: Vc, ty: Either, collected_root_params: Option>, ) -> Result<()> { import_map.insert_exact_alias( "next/root-params", - get_next_root_params_mapping( - is_root_params_enabled, - EitherTaskInput(ty), - collected_root_params, - ) - .to_resolved() - .await?, + get_next_root_params_mapping(EitherTaskInput(ty), collected_root_params) + .to_resolved() + .await?, ); Ok(()) } #[turbo_tasks::function] async fn get_next_root_params_mapping( - is_root_params_enabled: Vc, ty: EitherTaskInput, collected_root_params: Option>, ) -> Result> { @@ -61,7 +55,7 @@ async fn get_next_root_params_mapping( // `collected_root_params` changes, the resolve options will remain the same, and // only the mapping result will be invalidated. let mapping = ImportMapping::Dynamic(ResolvedVc::upcast( - NextRootParamsMapper::new(is_root_params_enabled, ty, collected_root_params) + NextRootParamsMapper::new(ty, collected_root_params) .to_resolved() .await?, )); @@ -70,7 +64,6 @@ async fn get_next_root_params_mapping( #[turbo_tasks::value] struct NextRootParamsMapper { - is_root_params_enabled: ResolvedVc, #[bincode(with = "turbo_bincode::either")] context_type: Either, collected_root_params: Option>, @@ -80,12 +73,10 @@ struct NextRootParamsMapper { impl NextRootParamsMapper { #[turbo_tasks::function] pub fn new( - is_root_params_enabled: ResolvedVc, context_type: EitherTaskInput, collected_root_params: Option>, ) -> Vc { NextRootParamsMapper { - is_root_params_enabled, context_type: context_type.0, collected_root_params, } @@ -95,61 +86,47 @@ impl NextRootParamsMapper { #[turbo_tasks::function] async fn import_map_result(self: Vc) -> Result> { let this = self.await?; - Ok({ - if !(*this.is_root_params_enabled.await?) { + Ok(match &this.context_type { + Either::Left(server_ty) => match &server_ty { + ServerContextType::AppRSC { .. } | ServerContextType::AppRoute { .. } => { + let collected_root_params = *this.collected_root_params.ok_or_else(|| { + anyhow!( + "Invariant: Root params should have been collected for context {:?}. \ + This is a bug in Next.js.", + server_ty.clone() + ) + })?; + Self::valid_import_map_result(collected_root_params) + } + ServerContextType::PagesApi { .. } + | ServerContextType::Instrumentation { .. } + | ServerContextType::Middleware { .. } => { + // There's no sensible way to use root params outside of the app + // directory. TODO: make sure this error is consistent with webpack + Self::invalid_import_map_result( + "'next/root-params' can only be used inside the App Directory.".into(), + ) + } + _ => { + // In general, the compiler should prevent importing 'next/root-params' + // from client modules, but it doesn't catch everything. If an import + // slips through our validation, make it error. + Self::invalid_import_map_result( + "'next/root-params' cannot be imported from a Client Component module. It \ + should only be used from a Server Component." + .into(), + ) + } + }, + Either::Right(_) => { + // In general, the compiler should prevent importing 'next/root-params' from + // client modules, but it doesn't catch everything. If an import slips + // through our validation, make it error. Self::invalid_import_map_result( - "'next/root-params' can only be imported when `experimental.rootParams` is \ - enabled." + "'next/root-params' cannot be imported from a Client Component module. It \ + should only be used from a Server Component." .into(), ) - } else { - match &this.context_type { - Either::Left(server_ty) => match &server_ty { - ServerContextType::AppRSC { .. } | ServerContextType::AppRoute { .. } => { - let collected_root_params = - *this.collected_root_params.ok_or_else(|| { - anyhow!( - "Invariant: Root params should have been collected for \ - context {:?}. This is a bug in Next.js.", - server_ty.clone() - ) - })?; - Self::valid_import_map_result(collected_root_params) - } - ServerContextType::PagesApi { .. } - | ServerContextType::Instrumentation { .. } - | ServerContextType::Middleware { .. } => { - // There's no sensible way to use root params outside of the app - // directory. TODO: make sure this error is - // consistent with webpack - Self::invalid_import_map_result( - "'next/root-params' can only be used inside the App Directory." - .into(), - ) - } - _ => { - // In general, the compiler should prevent importing 'next/root-params' - // from client modules, but it doesn't catch - // everything. If an import slips through - // our validation, make it error. - Self::invalid_import_map_result( - "'next/root-params' cannot be imported from a Client Component \ - module. It should only be used from a Server Component." - .into(), - ) - } - }, - Either::Right(_) => { - // In general, the compiler should prevent importing 'next/root-params' from - // client modules, but it doesn't catch everything. If an - // import slips through our validation, make it error. - Self::invalid_import_map_result( - "'next/root-params' cannot be imported from a Client Component \ - module. It should only be used from a Server Component." - .into(), - ) - } - } } }) } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index bba1cd4a6529..ed0149b61654 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1430,8 +1430,7 @@ export default async function build( await writeRootParamsTypes( routeTypesManifest, - path.join(distDir, 'types', 'root-params.d.ts'), - config + path.join(distDir, 'types', 'root-params.d.ts') ) }) diff --git a/packages/next/src/build/type-check.ts b/packages/next/src/build/type-check.ts index 3b2fe44a8722..861f31d3b36e 100644 --- a/packages/next/src/build/type-check.ts +++ b/packages/next/src/build/type-check.ts @@ -31,8 +31,7 @@ function verifyAndRunTypeScript( hasPagesDir: boolean, appDir: string | undefined, pagesDir: string | undefined, - debugBuildPaths: { app: string[]; pages: string[] } | undefined, - rootParams: boolean + debugBuildPaths: { app: string[]; pages: string[] } | undefined ) { let impl: typeof import('../lib/verify-typescript-setup').verifyAndRunTypeScript let typeCheckWorker: @@ -75,7 +74,6 @@ function verifyAndRunTypeScript( appDir, pagesDir, debugBuildPaths, - rootParams, }) .then((result) => { typeCheckWorker?.end() @@ -144,8 +142,7 @@ export async function startTypeChecking({ !!pagesDir, appDir, pagesDir, - debugBuildPaths, - !!config.experimental.rootParams || !!config.cacheComponents + debugBuildPaths ).then((resolved) => { const checkEnd = process.hrtime(typeCheckAndLintStart) return [resolved, checkEnd] as const diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index e7bed6304611..59be2705f54e 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1577,11 +1577,6 @@ export default async function getBaseWebpackConfig( : []), ...getNextRootParamsRules({ - isRootParamsEnabled: - config.experimental.rootParams ?? - // `cacheComponents` implies `experimental.rootParams`. - config.cacheComponents ?? - false, isClient, appDir, pageExtensions, @@ -2881,12 +2876,10 @@ export default async function getBaseWebpackConfig( } function getNextRootParamsRules({ - isRootParamsEnabled, isClient, appDir, pageExtensions, }: { - isRootParamsEnabled: boolean isClient: boolean appDir: string | undefined pageExtensions: string[] @@ -2904,15 +2897,6 @@ function getNextRootParamsRules({ } satisfies webpack.RuleSetRule } - // Hard-error if the flag is not enabled, regardless of if we're on the server or on the client. - if (!isRootParamsEnabled) { - return [ - createInvalidImportRule( - "'next/root-params' can only be imported when `experimental.rootParams` is enabled." - ), - ] - } - // If there's no app-dir (and thus no layouts), there's no sensible way to use 'next/root-params', // because we wouldn't generate any getters. if (!appDir) { diff --git a/packages/next/src/cli/next-test.ts b/packages/next/src/cli/next-test.ts index 166678d9aa2d..f48d76b88e23 100644 --- a/packages/next/src/cli/next-test.ts +++ b/packages/next/src/cli/next-test.ts @@ -149,8 +149,6 @@ async function runPlaywright( hasPagesDir: !!pagesDir, appDir: appDir || undefined, pagesDir: pagesDir || undefined, - rootParams: - !!nextConfig.experimental.rootParams || !!nextConfig.cacheComponents, }) const isUsingTypeScript = !!typeScriptVersion diff --git a/packages/next/src/cli/next-typegen.ts b/packages/next/src/cli/next-typegen.ts index 27fbdf8d7c16..976990959a92 100644 --- a/packages/next/src/cli/next-typegen.ts +++ b/packages/next/src/cli/next-typegen.ts @@ -55,8 +55,6 @@ const nextTypegen = async ( hasPagesDir: !!pagesDir, appDir: appDir || undefined, pagesDir: pagesDir || undefined, - rootParams: - !!nextConfig.experimental.rootParams || !!nextConfig.cacheComponents, }) console.log('Generating route types...') @@ -117,8 +115,7 @@ const nextTypegen = async ( await writeRootParamsTypes( routeTypesManifest, - join(distDir, 'types', 'root-params.d.ts'), - nextConfig + join(distDir, 'types', 'root-params.d.ts') ) console.log('✓ Types generated successfully') diff --git a/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts b/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts index 4832a3acefeb..22112f835159 100644 --- a/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts +++ b/packages/next/src/lib/typescript/writeAppTypeDeclarations.ts @@ -10,7 +10,6 @@ export async function writeAppTypeDeclarations({ hasAppDir, strictRouteTypes, typedRoutes, - rootParams, }: { baseDir: string distDir: string @@ -19,7 +18,6 @@ export async function writeAppTypeDeclarations({ hasAppDir: boolean strictRouteTypes: boolean typedRoutes: boolean - rootParams: boolean }): Promise { // Reference `next` types const appTypeDeclarations = path.join(baseDir, 'next-env.d.ts') @@ -71,13 +69,11 @@ export async function writeAppTypeDeclarations({ // Use ESM import instead of triple-slash reference for better ESLint compatibility lines.push(`import "./${routeTypesPath}";`) - if (rootParams) { - const rootParamsTypesPath = path.posix.join( - distDir.replaceAll(path.win32.sep, path.posix.sep), - 'types/root-params.d.ts' - ) - lines.push(`import "./${rootParamsTypesPath}";`) - } + const rootParamsTypesPath = path.posix.join( + distDir.replaceAll(path.win32.sep, path.posix.sep), + 'types/root-params.d.ts' + ) + lines.push(`import "./${rootParamsTypesPath}";`) if (strictRouteTypes) { const cacheLifePath = path.posix.join( diff --git a/packages/next/src/lib/verify-typescript-setup.ts b/packages/next/src/lib/verify-typescript-setup.ts index 3983696d6ba2..52c63d869348 100644 --- a/packages/next/src/lib/verify-typescript-setup.ts +++ b/packages/next/src/lib/verify-typescript-setup.ts @@ -67,7 +67,6 @@ export async function verifyAndRunTypeScript({ appDir, pagesDir, debugBuildPaths, - rootParams, }: { dir: string distDir: string @@ -82,7 +81,6 @@ export async function verifyAndRunTypeScript({ appDir?: string pagesDir?: string debugBuildPaths?: { app?: string[]; pages?: string[] } - rootParams?: boolean }): Promise<{ result?: TypeCheckResult; version: string | null }> { const tsConfigFileName = tsconfigPath || 'tsconfig.json' const resolvedTsConfigPath = path.join(dir, tsConfigFileName) @@ -133,7 +131,6 @@ export async function verifyAndRunTypeScript({ hasAppDir, strictRouteTypes, typedRoutes, - rootParams: !!rootParams, }) return { version: null } @@ -217,7 +214,6 @@ export async function verifyAndRunTypeScript({ hasAppDir, strictRouteTypes, typedRoutes, - rootParams: !!rootParams, }) let result diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index b8f11833fa34..a56de95104e2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1196,11 +1196,6 @@ export interface ExperimentalConfig { showSourceLocation?: boolean } - /** - * Enable accessing root params via the `next/root-params` module. - */ - rootParams?: boolean - /** * Body size limit for request bodies with middleware configured. * Defaults to 10MB. Can be specified as a number (bytes) or string (e.g. '5mb'). diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 2c4827f7abdd..4125151a83d5 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -180,6 +180,13 @@ function checkDeprecations( silent ) + warnOptionHasBeenDeprecated( + userConfig, + 'experimental.rootParams', + `\`experimental.rootParams\` is no longer needed, because \`next/root-params\` is available by default. You can remove it from ${configFileName}.`, + silent + ) + warnOptionHasBeenDeprecated( userConfig, 'eslint', diff --git a/packages/next/src/server/lib/router-utils/root-params-type-utils.ts b/packages/next/src/server/lib/router-utils/root-params-type-utils.ts index 8729d6cd17c8..953a21fd4cdf 100644 --- a/packages/next/src/server/lib/router-utils/root-params-type-utils.ts +++ b/packages/next/src/server/lib/router-utils/root-params-type-utils.ts @@ -1,7 +1,6 @@ import fs from 'fs' import path from 'path' import type { RouteTypesManifest } from './route-types-utils' -import type { NextConfigComplete } from '../../config-shared' export type RootParamValueType = 'string' | 'string[]' | 'undefined' @@ -45,24 +44,15 @@ function getRootParamReturnType(valueTypes: RootParamInfo): string { } /** - * Writes root-params type definitions to a file if the feature is enabled - * and root params were collected from layouts. + * Writes root-params type definitions to a file if root params were collected + * from layouts. */ export async function writeRootParamsTypes( manifest: RouteTypesManifest, - filePath: string, - config: NextConfigComplete + filePath: string ) { const rootParams = manifest.rootParams - const featureEnabled = - !!config.experimental.rootParams || !!config.cacheComponents - - if (!featureEnabled) { - await fs.promises.rm(filePath, { force: true }) - return - } - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }) if (!rootParams.size) { diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 504ce9c4953c..d91df07f43cb 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -165,9 +165,6 @@ async function verifyTypeScript(opts: SetupOpts) { hasPagesDir: !!opts.pagesDir, appDir: opts.appDir, pagesDir: opts.pagesDir, - rootParams: - !!opts.nextConfig.experimental.rootParams || - !!opts.nextConfig.cacheComponents, }) if (verifyResult.version) { @@ -1200,8 +1197,7 @@ async function startWatcher( await writeRootParamsTypes( routeTypesManifest, - path.join(distTypesDir, 'root-params.d.ts'), - opts.nextConfig + path.join(distTypesDir, 'root-params.d.ts') ) } diff --git a/test/development/acceptance-app/rsc-build-errors.test.ts b/test/development/acceptance-app/rsc-build-errors.test.ts index fc816123aba2..5a7545837dd7 100644 --- a/test/development/acceptance-app/rsc-build-errors.test.ts +++ b/test/development/acceptance-app/rsc-build-errors.test.ts @@ -371,9 +371,7 @@ describe('Error overlay - RSC build errors', () => { }) describe('next/root-params', () => { - const isCacheComponentsEnabled = - process.env.__NEXT_CACHE_COMPONENTS === 'true' - it("importing 'next/root-params' when experimental.rootParams is not enabled", async () => { + it("importing a non-existent getter from 'next/root-params'", async () => { await using sandbox = await createSandbox( next, undefined, @@ -381,16 +379,13 @@ describe('Error overlay - RSC build errors', () => { ) const { session } = sandbox await session.waitForRedbox() - if (!isCacheComponentsEnabled) { + if (isTurbopack) { expect(await session.getRedboxSource()).toInclude( - `'next/root-params' can only be imported when \`experimental.rootParams\` is enabled.` + `Export whatever doesn't exist in target module` ) } else { - // in cacheComponents we auto-enable 'next/root-params', so we should get an error about using a non-existent getter instead. - expect(await session.getRedboxSource()).toInclude( - isTurbopack - ? `Export whatever doesn't exist in target module` - : `Attempted import error: 'whatever' is not exported from 'next/root-params' (imported as 'whatever').` + expect(await session.getRedboxDescription()).toInclude( + `whatever) is not a function` ) } }) @@ -398,17 +393,7 @@ describe('Error overlay - RSC build errors', () => { it("importing 'next/root-params' in a client component", async () => { await using sandbox = await createSandbox( next, - // if cacheComponents is not enabled, the import is guarded behind an experimental flag - isCacheComponentsEnabled - ? new Map() - : new Map([ - [ - 'next.config.js', - outdent` - module.exports = { experimental: { rootParams: true } } - `, - ], - ]), + undefined, `/server-with-errors/next-root-params/in-client` ) const { session } = sandbox @@ -421,17 +406,7 @@ describe('Error overlay - RSC build errors', () => { it("importing 'next/root-params' in a client component in a way that bypasses import analysis", async () => { await using sandbox = await createSandbox( next, - // if cacheComponents is not enabled, the import is guarded behind an experimental flag - isCacheComponentsEnabled - ? new Map() - : new Map([ - [ - 'next.config.js', - outdent` - module.exports = { experimental: { rootParams: true } } - `, - ], - ]), + undefined, `/server-with-errors/next-root-params/in-client-await-import` ) const { session } = sandbox diff --git a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts index b46cf8d215cc..36e95ed4856d 100644 --- a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts +++ b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts @@ -306,13 +306,6 @@ describe('Error Overlay for server components compiler errors in pages', () => { } `, ], - [ - // the import is guarded behind an experimental flag - 'next.config.js', - outdent` - module.exports = { experimental: { rootParams: true } } - `, - ], ]) await using sandbox = await createSandbox(next, files) const { session } = sandbox diff --git a/test/development/typescript-app-type-declarations/typescript-app-type-declarations.test.ts b/test/development/typescript-app-type-declarations/typescript-app-type-declarations.test.ts index 09e96a0dc2c2..4c3c0f2e8e3f 100644 --- a/test/development/typescript-app-type-declarations/typescript-app-type-declarations.test.ts +++ b/test/development/typescript-app-type-declarations/typescript-app-type-declarations.test.ts @@ -15,6 +15,7 @@ const nextEnvDts = strictRouteTypes ? `/// /// import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; import "./.next/dev/types/cache-life.d.ts"; import "./.next/dev/types/validator.ts"; @@ -24,6 +25,7 @@ import "./.next/dev/types/validator.ts"; : `/// /// import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params-error/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params-error/next.config.ts index 6993a945b3c6..e4f5738a310b 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params-error/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params-error/next.config.ts @@ -1,9 +1,5 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = { - experimental: { - rootParams: true, - }, -} +const nextConfig: NextConfig = {} export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts index 6f9923b8467f..fa33c7c54f24 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { - cacheComponents: true, // implies `rootParams: true`. + cacheComponents: true, } export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts index 6993a945b3c6..e4f5738a310b 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts @@ -1,9 +1,5 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = { - experimental: { - rootParams: true, - }, -} +const nextConfig: NextConfig = {} export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts index 6993a945b3c6..e4f5738a310b 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts @@ -1,9 +1,5 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = { - experimental: { - rootParams: true, - }, -} +const nextConfig: NextConfig = {} export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts index f9ec19251f03..a4ba114fc0f0 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts @@ -3,7 +3,6 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { useCache: true, - rootParams: true, }, } diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-dedup/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-dedup/next.config.ts index f9ec19251f03..a4ba114fc0f0 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-dedup/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-dedup/next.config.ts @@ -3,7 +3,6 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { useCache: true, - rootParams: true, }, } diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-private/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-private/next.config.ts index f9ec19251f03..a4ba114fc0f0 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-private/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-private/next.config.ts @@ -3,7 +3,6 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { useCache: true, - rootParams: true, }, } diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts index f9ec19251f03..a4ba114fc0f0 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts @@ -3,7 +3,6 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { useCache: true, - rootParams: true, }, } diff --git a/test/e2e/app-dir/ppr-root-param-rsc-fallback/next.config.js b/test/e2e/app-dir/ppr-root-param-rsc-fallback/next.config.js index 74b8350cb508..e64bae22d658 100644 --- a/test/e2e/app-dir/ppr-root-param-rsc-fallback/next.config.js +++ b/test/e2e/app-dir/ppr-root-param-rsc-fallback/next.config.js @@ -3,9 +3,6 @@ */ const nextConfig = { cacheComponents: true, - experimental: { - rootParams: true, - }, } module.exports = nextConfig diff --git a/test/production/app-dir/generate-static-params-errors/next.config.ts b/test/production/app-dir/generate-static-params-errors/next.config.ts index 42500be934ae..e4f5738a310b 100644 --- a/test/production/app-dir/generate-static-params-errors/next.config.ts +++ b/test/production/app-dir/generate-static-params-errors/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = { - experimental: { rootParams: true }, -} +const nextConfig: NextConfig = {} export default nextConfig diff --git a/test/rspack-dev-tests-manifest.json b/test/rspack-dev-tests-manifest.json index 25c92af64619..d5427fce2ab8 100644 --- a/test/rspack-dev-tests-manifest.json +++ b/test/rspack-dev-tests-manifest.json @@ -225,7 +225,7 @@ "Error overlay - RSC build errors importing 'next/cache' APIs in a client component unstable_noStore is allowed", "Error overlay - RSC build errors next/root-params importing 'next/root-params' in a client component", "Error overlay - RSC build errors next/root-params importing 'next/root-params' in a client component in a way that bypasses import analysis", - "Error overlay - RSC build errors next/root-params importing 'next/root-params' when experimental.rootParams is not enabled", + "Error overlay - RSC build errors next/root-params importing a non-existent getter from 'next/root-params'", "Error overlay - RSC build errors should allow to use and handle rsc poisoning client-only", "Error overlay - RSC build errors should allow to use and handle rsc poisoning server-only", "Error overlay - RSC build errors should error for invalid undefined module retuning from next dynamic", diff --git a/test/unit/write-app-declarations.test.ts b/test/unit/write-app-declarations.test.ts index f82b8887a7c4..24e4f7246a67 100644 --- a/test/unit/write-app-declarations.test.ts +++ b/test/unit/write-app-declarations.test.ts @@ -24,6 +24,8 @@ describe('find config', () => { : '') + `import "./.next/types/routes.d.ts";` + eol + + `import "./.next/types/root-params.d.ts";` + + eol + eol + '// NOTE: This file should not be edited' + eol + @@ -40,7 +42,6 @@ describe('find config', () => { hasAppDir: false, strictRouteTypes: false, typedRoutes: true, - rootParams: false, }) expect(await fs.readFile(declarationFile, 'utf8')).toBe(content) }) @@ -55,6 +56,8 @@ describe('find config', () => { : '') + `import "./.next/types/routes.d.ts";` + eol + + `import "./.next/types/root-params.d.ts";` + + eol + eol + '// NOTE: This file should not be edited' + eol + @@ -71,7 +74,6 @@ describe('find config', () => { hasAppDir: false, strictRouteTypes: false, typedRoutes: true, - rootParams: false, }) expect(await fs.readFile(declarationFile, 'utf8')).toBe(content) }) @@ -86,6 +88,8 @@ describe('find config', () => { : '') + `import "./.next/types/routes.d.ts";` + eol + + `import "./.next/types/root-params.d.ts";` + + eol + eol + '// NOTE: This file should not be edited' + eol + @@ -100,7 +104,6 @@ describe('find config', () => { hasAppDir: false, strictRouteTypes: false, typedRoutes: true, - rootParams: false, }) expect(await fs.readFile(declarationFile, 'utf8')).toBe(content) }) @@ -114,7 +117,6 @@ describe('find config', () => { hasAppDir: true, strictRouteTypes: false, typedRoutes: true, - rootParams: false, }) await expect(fs.readFile(declarationFile, 'utf8')).resolves.not.toContain( @@ -129,7 +131,6 @@ describe('find config', () => { hasAppDir: true, strictRouteTypes: false, typedRoutes: true, - rootParams: false, }) await expect(fs.readFile(declarationFile, 'utf8')).resolves.toContain( From 925e24e538840af62c892cc3e0f48038635e8959 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Tue, 19 May 2026 10:52:52 -0700 Subject: [PATCH 6/6] devlow: add run sampling snapshots and comparison reports (#93833) A lot of this was written with Claude. We lean a lot on published libraries for the statistics, so I've got confidence in them. This adds top-level commands to devlow, `run`, and `compare`, though we're still backwards compatible with `run` being the default if no command is specified. - `--n=` runs each variant N times, `--warmup=` discards the first N runs (don't use on cold-start metrics). Default behavior is unchanged at n=1. - Every run now writes a snapshot CSV to `.devlow-bench/snapshots/.csv` (long format, one row per sample, so mixed-unit metrics share a file). Override with `--snapshot=`. - `--compare` prints a side-by-side table at the end of a run against the newest snapshot. `--baseline=` points at a specific file or directory. - `devlow-bench compare ` is a separate subcommand for diffing two existing snapshots without rerunning anything. For every scenario/variant/metric combination, it shows baseline vs current `mean / p50 / p90 (n)`, the absolute and percent delta, and two p-values. --- pnpm-lock.yaml | 19 +- turbopack/packages/devlow-bench/package.json | 1 + turbopack/packages/devlow-bench/src/cli.ts | 171 ++++++++++--- .../packages/devlow-bench/src/compare.ts | 227 ++++++++++++++++++ turbopack/packages/devlow-bench/src/index.ts | 20 +- .../devlow-bench/src/interfaces/compare.ts | 32 +++ .../devlow-bench/src/interfaces/console.ts | 35 ++- .../devlow-bench/src/interfaces/json.ts | 60 +++-- .../devlow-bench/src/interfaces/snapshot.ts | 68 ++++++ .../packages/devlow-bench/src/jstat.d.ts | 9 + turbopack/packages/devlow-bench/src/runner.ts | 127 ++++++++-- .../packages/devlow-bench/src/snapshot.ts | 189 +++++++++++++++ .../devlow-bench/src/statistics-test.ts | 117 +++++++++ .../packages/devlow-bench/src/statistics.ts | 138 +++++++++++ turbopack/packages/devlow-bench/src/utils.ts | 15 +- 15 files changed, 1127 insertions(+), 101 deletions(-) create mode 100644 turbopack/packages/devlow-bench/src/compare.ts create mode 100644 turbopack/packages/devlow-bench/src/interfaces/compare.ts create mode 100644 turbopack/packages/devlow-bench/src/interfaces/snapshot.ts create mode 100644 turbopack/packages/devlow-bench/src/jstat.d.ts create mode 100644 turbopack/packages/devlow-bench/src/snapshot.ts create mode 100644 turbopack/packages/devlow-bench/src/statistics-test.ts create mode 100644 turbopack/packages/devlow-bench/src/statistics.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edaec9faea43..d953ad71ae8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2004,6 +2004,9 @@ importers: inquirer: specifier: ^9.2.7 version: 9.2.23 + jstat: + specifier: ^1.9.6 + version: 1.9.6 minimist: specifier: ^1.2.8 version: 1.2.8 @@ -12360,6 +12363,9 @@ packages: resolution: {integrity: sha512-4Dj8Rf+fQ+/Pn7C5qeEX02op1WfOss3PKTE9Nsop3Dx+6UPxlm1dr/og7o2cRa5hNN07CACr4NFzRLtj/rjWog==} engines: {'0': node >=0.6.0} + jstat@1.9.6: + resolution: {integrity: sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -16519,6 +16525,7 @@ packages: sliced@1.0.1: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported slide@1.1.6: resolution: {integrity: sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==} @@ -17963,11 +17970,11 @@ packages: uuid@2.0.3: resolution: {integrity: sha512-FULf7fayPdpASncVy4DLh3xydlXEJJpvIELjYjNeQWYUZ9pclcpvCZSr2gkmN2FrrGcI7G/cJsIEwk5/8vfXpg==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). uuid@3.3.3: resolution: {integrity: sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==} - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: @@ -23774,7 +23781,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@6.0.2) '@typescript-eslint/types': 8.46.0 - debug: 4.4.0 + debug: 4.4.3 typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -23798,7 +23805,7 @@ snapshots: '@typescript-eslint/types': 8.46.0 '@typescript-eslint/typescript-estree': 8.46.0(typescript@6.0.2) '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@6.0.2) - debug: 4.4.0 + debug: 4.4.3 eslint: 9.37.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@6.0.2) typescript: 6.0.2 @@ -27337,7 +27344,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.0 + debug: 4.4.3 esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -30863,6 +30870,8 @@ snapshots: json-schema: 0.2.3 verror: 1.10.0 + jstat@1.9.6: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 diff --git a/turbopack/packages/devlow-bench/package.json b/turbopack/packages/devlow-bench/package.json index 2c2776792b31..5be176caba24 100644 --- a/turbopack/packages/devlow-bench/package.json +++ b/turbopack/packages/devlow-bench/package.json @@ -45,6 +45,7 @@ "dependencies": { "@datadog/datadog-api-client": "^1.13.0", "inquirer": "^9.2.7", + "jstat": "^1.9.6", "minimist": "^1.2.8", "picocolors": "1.0.1", "pidusage-tree": "^2.0.5", diff --git a/turbopack/packages/devlow-bench/src/cli.ts b/turbopack/packages/devlow-bench/src/cli.ts index cc3ca16e4cb7..53f1743df0f8 100644 --- a/turbopack/packages/devlow-bench/src/cli.ts +++ b/turbopack/packages/devlow-bench/src/cli.ts @@ -3,8 +3,60 @@ import { setCurrentScenarios } from './describe.js' import { join } from 'path' import { Scenario, ScenarioVariant, runScenarios } from './index.js' import compose from './interfaces/compose.js' +import { groupRows, printComparison } from './compare.js' +import { readSnapshot, resolveCompareTarget } from './snapshot.js' import { pathToFileURL } from 'url' + +const SUBCOMMANDS = new Set(['run', 'compare']) + ;(async () => { + // Subcommand dispatch. `devlow-bench run [opts] scenario.mjs` and + // `devlow-bench compare ` are the explicit forms. A bare + // `devlow-bench