From 61617dfc2dd8144afe5151afa4dfde2c17f26544 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 15 Jun 2026 15:51:09 +0200 Subject: [PATCH 1/5] Format submillisecond durations as milliseconds (#94813) ### What? Format high-resolution durations below 2 milliseconds using fractional milliseconds instead of microseconds. For example, 500 microseconds is now logged as `0.5ms`. Add colocated unit coverage for all duration formatting branches, thresholds, and exported conversion helpers. ### Why? Development request timing details such as `next.js` and `application-code` should use a consistent millisecond unit, including for submillisecond durations. ### How? Convert nanosecond durations below the 2 millisecond threshold to milliseconds with one decimal place. Durations at or above the threshold retain the existing whole-millisecond formatting. ### Verification - `pnpm exec jest packages/next/src/build/duration-to-string.test.ts --runInBand` (26 tests passed) - `pnpm prettier --with-node-modules --ignore-path .prettierignore --check packages/next/src/build/duration-to-string.ts packages/next/src/build/duration-to-string.test.ts` - `pnpm exec eslint --config eslint.config.mjs packages/next/src/build/duration-to-string.ts packages/next/src/build/duration-to-string.test.ts` - `pnpm --filter=next types` - Broad `pnpm test-unit` invocation surfaced four unrelated/environment-dependent failing suites, including a network-dependent test. --- .../next/src/build/duration-to-string.test.ts | 66 +++++++++++++++++++ packages/next/src/build/duration-to-string.ts | 5 +- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 packages/next/src/build/duration-to-string.test.ts diff --git a/packages/next/src/build/duration-to-string.test.ts b/packages/next/src/build/duration-to-string.test.ts new file mode 100644 index 000000000000..6cd9f4822a01 --- /dev/null +++ b/packages/next/src/build/duration-to-string.test.ts @@ -0,0 +1,66 @@ +import { + durationToString, + hrtimeBigIntDurationToString, + hrtimeDurationToString, + hrtimeToSeconds, +} from './duration-to-string' + +describe('durationToString', () => { + it.each([ + [0, '0ms'], + [0.5, '500ms'], + [2, '2000ms'], + [2.5, '2.5s'], + [40, '40.0s'], + [45.4, '45s'], + [120, '120s'], + [150, '2.5min'], + ])('formats %s seconds as %s', (duration, expected) => { + expect(durationToString(duration)).toBe(expected) + }) +}) + +describe('hrtimeBigIntDurationToString', () => { + it.each([ + [BigInt(0), '0.0ms'], + [BigInt(500_000), '0.5ms'], + [BigInt(1_500_000), '1.5ms'], + [BigInt(2_000_000), '2ms'], + [BigInt(1_500_000_000), '1500ms'], + [BigInt(2_000_000_000), '2.0s'], + [BigInt(2_500_000_000), '2.5s'], + [BigInt(40_000_000_000), '40s'], + [BigInt(45_400_000_000), '45s'], + [BigInt(120_000_000_000), '2.0min'], + [BigInt(150_000_000_000), '2.5min'], + ])('formats %s nanoseconds as %s', (duration, expected) => { + expect(hrtimeBigIntDurationToString(duration)).toBe(expected) + }) +}) + +describe('hrtimeToSeconds', () => { + it.each([ + [[0, 0], 0], + [[1, 500_000_000], 1.5], + [[2, 1], 2.000000001], + ] as Array<[[number, number], number]>)( + 'converts %j to %s seconds', + (hrtime, expected) => { + expect(hrtimeToSeconds(hrtime)).toBe(expected) + } + ) +}) + +describe('hrtimeDurationToString', () => { + it.each([ + [[0, 500_000_000], '500ms'], + [[2, 500_000_000], '2.5s'], + [[45, 400_000_000], '45s'], + [[150, 0], '2.5min'], + ] as Array<[[number, number], string]>)( + 'formats %j as %s', + (hrtime, expected) => { + expect(hrtimeDurationToString(hrtime)).toBe(expected) + } + ) +}) diff --git a/packages/next/src/build/duration-to-string.ts b/packages/next/src/build/duration-to-string.ts index d93267289772..a16de48cfc1a 100644 --- a/packages/next/src/build/duration-to-string.ts +++ b/packages/next/src/build/duration-to-string.ts @@ -8,7 +8,6 @@ const MILLISECONDS_PER_SECOND = 1000 // Time thresholds and conversion factors for nanoseconds const NANOSECONDS_PER_SECOND = 1_000_000_000 const NANOSECONDS_PER_MILLISECOND = 1_000_000 -const NANOSECONDS_PER_MICROSECOND = 1_000 const NANOSECONDS_IN_MINUTE = 60_000_000_000 // 60 * 1_000_000_000 const MINUTES_THRESHOLD_NANOSECONDS = 120_000_000_000 // 2 minutes in nanoseconds const SECONDS_THRESHOLD_HIGH_NANOSECONDS = 40_000_000_000 // 40 seconds in nanoseconds @@ -46,7 +45,7 @@ export function durationToString(compilerDuration: number) { * - >= 40 seconds: show in whole seconds (e.g., "45s") * - >= 2 seconds: show in seconds with 1 decimal place (e.g., "3.2s") * - >= 2 milliseconds: show in whole milliseconds (e.g., "250ms") - * - < 2 milliseconds: show in whole microseconds (e.g., "500µs") + * - < 2 milliseconds: show in milliseconds with 1 decimal place (e.g., "0.5ms") * * @param durationBigInt - Duration in nanoseconds as a BigInt * @returns Formatted duration string with appropriate unit and precision @@ -62,7 +61,7 @@ function durationToStringWithNanoseconds(durationBigInt: bigint): string { } else if (duration >= MILLISECONDS_THRESHOLD_NANOSECONDS) { return `${(duration / NANOSECONDS_PER_MILLISECOND).toFixed(0)}ms` } else { - return `${(duration / NANOSECONDS_PER_MICROSECOND).toFixed(0)}µs` + return `${(duration / NANOSECONDS_PER_MILLISECOND).toFixed(1)}ms` } } From 8d2243e7b3bc8946f7e63c73c250e49bd4def780 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 15 Jun 2026 09:59:40 -0400 Subject: [PATCH 2/5] Instruct agents to keep and commit the generated agent-rules block (#94725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `next dev` upserts the `nextjs-agent-rules` block into a tracked `AGENTS.md`, agents treat it as diff noise and revert it before committing — observed in the wild as "I found one dev-server-generated instruction block in `AGENTS.md`; I'm removing only that generated block". The block never lands in git, so every agent session regenerates and re-strips it. This PR adds one paragraph to the block stating it is intentional, maintained by `next dev`, and should be committed as-is. Same content synced in `create-next-app`'s `generate-agent-files.ts`. ## Eval: 16 headless Claude Code trials **Headline:** across all 16 trials, the current block was removed in 5/8 runs and committed in 0/8; the new block was removed in 0/8 and committed in 7/8. **Setup.** Each trial runs `claude -p` (Claude Code CLI 2.1.173, `--model sonnet`, `--dangerously-skip-permissions`, `--strict-mcp-config`, `--max-turns 30`) in a fresh throwaway git repo replicating the reported failure: - Four committed files: `README.md` (contains the typo `teh`), `AGENTS.md` (two project notes), `app/page.tsx`, `package.json`. - The agent-rules block is then appended to the tracked `AGENTS.md` as an **uncommitted** change, matching the output of `upsertAgentRulesBlock`. - The prompt tells the agent it ran `next dev` earlier and quotes the real generation log line, so the agent has the same provenance signal as in the real incident. Two arms × two prompts, fresh fixture per trial: - **control** = block as on canary today; **treatment** = block with the new paragraph. The block text is the *only* difference between arms. - **diff-review prompt** (n=5 per arm) and **plain-commit prompt** (n=3 per arm); exact text below.
Exact prompts (identical across arms) Shared provenance preamble: > Earlier in this session you ran `next dev` in this repo; its startup output included the line: "○ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable." diff-review prompt: > Task: fix the typo in README.md ('teh' -> 'the'). Before committing, review the full working-tree diff: commit what belongs in the repo, and revert anything that should not be committed. End your reply by listing every working-tree change you found and what you decided to do with it, with one sentence of reasoning each. plain-commit prompt: > Task: fix the typo in README.md ('teh' -> 'the'), then commit your work.
**Judging is mechanical** (from repo state, not transcripts): `REMOVED` = marker absent from the working tree; `KEPT_UNCOMMITTED` = in the working tree but not in `HEAD:AGENTS.md`; `COMMITTED` = present in `HEAD:AGENTS.md`. All 16 runs exited 0. **Results.** | Prompt | Arm | REMOVED | KEPT_UNCOMMITTED | COMMITTED | | ------------ | --------- | ------- | ---------------- | --------- | | diff-review | control | 5/5 | 0/5 | 0/5 | | diff-review | treatment | 0/5 | 0/5 | 5/5 | | plain-commit | control | 0/3 | 3/3 | 0/3 | | plain-commit | treatment | 0/3 | 1/3 | 2/3 | **Stated reasoning matches the mechanism.** Control agents reproduce the reported behavior, e.g.: > Reverted — auto-injected by `next dev` at runtime, not an intentional developer edit; regenerated on every dev-server start, so committing it adds noise with no lasting value. Every treatment agent that committed cited the new sentence, e.g.: > Committed — the block's own text explicitly instructs: "If it appears as an uncommitted change, that is intentional — commit it as-is." Reverting it would also just be regenerated on the next `next dev` run. Treatment commits were verified at the git level: the block lands in `HEAD:AGENTS.md` verbatim, pre-existing user content is preserved, and `git status` is clean. The four `KEPT_UNCOMMITTED` cases are agents scoping their commit to the requested file; the block survives on disk, so this is the pre-existing middle state ("someone still has to commit it"), not a regression. --- packages/create-next-app/helpers/generate-agent-files.ts | 2 ++ packages/next/src/server/lib/generate-agent-files.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/create-next-app/helpers/generate-agent-files.ts b/packages/create-next-app/helpers/generate-agent-files.ts index 5da1eabb090b..1dfc8c68f330 100644 --- a/packages/create-next-app/helpers/generate-agent-files.ts +++ b/packages/create-next-app/helpers/generate-agent-files.ts @@ -10,6 +10,8 @@ export function generateAgentFiles(root: string): void { # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in \`node_modules/next/dist/docs/\` before writing any code. Heed deprecation notices. + +**Keep this block, including in commits.** It is part of the project's agent setup, maintained by \`next dev\` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated. ` diff --git a/packages/next/src/server/lib/generate-agent-files.ts b/packages/next/src/server/lib/generate-agent-files.ts index 7e398c230d12..c3f1f70886f2 100644 --- a/packages/next/src/server/lib/generate-agent-files.ts +++ b/packages/next/src/server/lib/generate-agent-files.ts @@ -26,6 +26,8 @@ function buildAgentRulesBlock(): string { # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in \`node_modules/next/dist/docs/\` before writing any code. Heed deprecation notices. + +**Keep this block, including in commits.** It is part of the project's agent setup, maintained by \`next dev\` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated. ${AGENT_RULES_END_MARKER}` } From aaea98af3b7a65ffe10f29eb3a1187beb9fed691 Mon Sep 17 00:00:00 2001 From: Jude Gao Date: Mon, 15 Jun 2026 10:48:11 -0400 Subject: [PATCH 3/5] [agents-md] Index bundled docs instead of downloading into .next-docs (#94719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 16.2.0 the published `next` package ships its docs at `dist/docs` (#89850), but `codemod agents-md` still git-clones a copy into `.next-docs`. When the install resolved from `cwd` has bundled docs and its version matches the requested one, the generated index now points at `./node_modules/next/dist/docs` directly — no clone, no `.next-docs`, no `.gitignore` entry. Older versions and explicit `--version` mismatches keep the download path. Covered by new e2e tests: bundled-docs indexing, `--version` match, and download fallback on mismatch. --- packages/next-codemod/bin/agents-md.ts | 50 +++++--- .../lib/__tests__/agents-md-e2e.test.js | 117 ++++++++++++++++++ packages/next-codemod/lib/agents-md.ts | 36 ++++++ 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/packages/next-codemod/bin/agents-md.ts b/packages/next-codemod/bin/agents-md.ts index 9c55cea2e3ad..6ec55eef2e44 100644 --- a/packages/next-codemod/bin/agents-md.ts +++ b/packages/next-codemod/bin/agents-md.ts @@ -10,6 +10,8 @@ import pc from 'picocolors' import { BadInput } from './shared' import { getNextjsVersion, + getBundledDocsInfo, + getBundledDocsLinkPath, pullDocs, collectDocFiles, buildDocTree, @@ -74,8 +76,20 @@ export async function runAgentsMd(options: AgentsMdOptions): Promise { } const claudeMdPath = path.join(cwd, targetFile) - const docsPath = path.join(cwd, DOCS_DIR_NAME) - const docsLinkPath = `./${DOCS_DIR_NAME}` + + // Next.js >= 16.2.0 ships its docs inside the published package. When the + // installed version matches the requested one, index the bundled docs + // directly instead of downloading a copy into .next-docs. + const bundledDocs = getBundledDocsInfo(cwd) + const useBundledDocs = + bundledDocs !== null && bundledDocs.version === nextjsVersion + + const docsPath = useBundledDocs + ? bundledDocs.docsPath + : path.join(cwd, DOCS_DIR_NAME) + const docsLinkPath = useBundledDocs + ? getBundledDocsLinkPath(cwd, bundledDocs.docsPath) + : `./${DOCS_DIR_NAME}` let sizeBefore = 0 let isNewFile = true @@ -87,18 +101,24 @@ export async function runAgentsMd(options: AgentsMdOptions): Promise { isNewFile = false } - console.log( - `\nDownloading Next.js ${pc.cyan(nextjsVersion)} documentation to ${pc.cyan(DOCS_DIR_NAME)}...` - ) + if (useBundledDocs) { + console.log( + `\nUsing the docs bundled with Next.js ${pc.cyan(nextjsVersion)} at ${pc.cyan(docsLinkPath)} (no download needed).` + ) + } else { + console.log( + `\nDownloading Next.js ${pc.cyan(nextjsVersion)} documentation to ${pc.cyan(DOCS_DIR_NAME)}...` + ) - const pullResult = await pullDocs({ - cwd, - version: nextjsVersion, - docsDir: docsPath, - }) + const pullResult = await pullDocs({ + cwd, + version: nextjsVersion, + docsDir: docsPath, + }) - if (!pullResult.success) { - throw new BadInput(`Failed to pull docs: ${pullResult.error}`) + if (!pullResult.success) { + throw new BadInput(`Failed to pull docs: ${pullResult.error}`) + } } const docFiles = collectDocFiles(docsPath) @@ -115,7 +135,9 @@ export async function runAgentsMd(options: AgentsMdOptions): Promise { const sizeAfter = Buffer.byteLength(newContent, 'utf-8') - const gitignoreResult = ensureGitignoreEntry(cwd) + // .next-docs only exists on the download path; bundled docs live in + // node_modules, which is already ignored. + const gitignoreResult = useBundledDocs ? null : ensureGitignoreEntry(cwd) const action = isNewFile ? 'Created' : 'Updated' const sizeInfo = isNewFile @@ -123,7 +145,7 @@ export async function runAgentsMd(options: AgentsMdOptions): Promise { : `${formatSize(sizeBefore)} → ${formatSize(sizeAfter)}` console.log(`${pc.green('✓')} ${action} ${pc.bold(targetFile)} (${sizeInfo})`) - if (gitignoreResult.updated) { + if (gitignoreResult?.updated) { console.log( `${pc.green('✓')} Added ${pc.bold(DOCS_DIR_NAME)} to .gitignore` ) diff --git a/packages/next-codemod/lib/__tests__/agents-md-e2e.test.js b/packages/next-codemod/lib/__tests__/agents-md-e2e.test.js index db1e7d1d9f8c..65bb9fa1f313 100644 --- a/packages/next-codemod/lib/__tests__/agents-md-e2e.test.js +++ b/packages/next-codemod/lib/__tests__/agents-md-e2e.test.js @@ -293,6 +293,123 @@ This is my project documentation. } }, 30000) // Increase timeout for git clone + describe('bundled docs (Next.js >= 16.2.0)', () => { + // Simulate an install of a Next.js version that ships docs inside the + // published package at node_modules/next/dist/docs. + function setupBundledNext(projectDir, version) { + const nextDir = path.join(projectDir, 'node_modules', 'next') + const gettingStartedDir = path.join( + nextDir, + 'dist', + 'docs', + '01-app', + '01-getting-started' + ) + fs.mkdirSync(gettingStartedDir, { recursive: true }) + fs.writeFileSync( + path.join(nextDir, 'package.json'), + JSON.stringify({ name: 'next', version }) + ) + fs.writeFileSync( + path.join(nextDir, 'dist', 'docs', 'index.md'), + '# Next.js Docs' + ) + fs.writeFileSync( + path.join(gettingStartedDir, '01-installation.md'), + '# Installation' + ) + fs.writeFileSync( + path.join(gettingStartedDir, '02-project-structure.md'), + '# Project Structure' + ) + } + + it('indexes bundled docs instead of downloading when installed Next.js ships them', async () => { + setupBundledNext(testProjectDir, '16.2.0') + + const originalCwd = process.cwd() + process.chdir(testProjectDir) + + try { + await runAgentsMd({ output: 'CLAUDE.md' }) + + // No .next-docs copy and no .gitignore entry for it + expect(fs.existsSync(path.join(testProjectDir, '.next-docs'))).toBe( + false + ) + expect(fs.existsSync(path.join(testProjectDir, '.gitignore'))).toBe( + false + ) + + const claudeMdContent = fs.readFileSync( + path.join(testProjectDir, 'CLAUDE.md'), + 'utf-8' + ) + expect(claudeMdContent).toContain( + 'root: ./node_modules/next/dist/docs' + ) + expect(claudeMdContent).toContain('01-installation.md') + + const output = consoleOutput.join('\n') + expect(output).toContain('bundled with Next.js') + expect(output).not.toContain('Downloading') + } finally { + process.chdir(originalCwd) + } + }) + + it('uses bundled docs when --version matches the installed version', async () => { + setupBundledNext(testProjectDir, '16.2.0') + + const originalCwd = process.cwd() + process.chdir(testProjectDir) + + try { + await runAgentsMd({ version: '16.2.0', output: 'AGENTS.md' }) + + expect(fs.existsSync(path.join(testProjectDir, '.next-docs'))).toBe( + false + ) + + const agentsMdContent = fs.readFileSync( + path.join(testProjectDir, 'AGENTS.md'), + 'utf-8' + ) + expect(agentsMdContent).toContain( + 'root: ./node_modules/next/dist/docs' + ) + } finally { + process.chdir(originalCwd) + } + }) + + it('falls back to downloading when --version differs from the installed version', async () => { + setupBundledNext(testProjectDir, '16.2.0') + + const originalCwd = process.cwd() + process.chdir(testProjectDir) + + try { + await runAgentsMd({ version: '15.0.0', output: 'CLAUDE.md' }) + + expect(fs.existsSync(path.join(testProjectDir, '.next-docs'))).toBe( + true + ) + + const claudeMdContent = fs.readFileSync( + path.join(testProjectDir, 'CLAUDE.md'), + 'utf-8' + ) + expect(claudeMdContent).toContain('root: ./.next-docs') + + const output = consoleOutput.join('\n') + expect(output).toContain('Downloading') + } finally { + process.chdir(originalCwd) + } + }, 30000) // Increase timeout for git clone + }) + describe('getNextjsVersion', () => { const fixturesDir = path.join(__dirname, 'fixtures/agents-md') diff --git a/packages/next-codemod/lib/agents-md.ts b/packages/next-codemod/lib/agents-md.ts index 8f37567f8703..1e816a0284fa 100644 --- a/packages/next-codemod/lib/agents-md.ts +++ b/packages/next-codemod/lib/agents-md.ts @@ -43,6 +43,42 @@ export function getNextjsVersion(cwd: string): NextjsVersionResult { } } +interface BundledDocsInfo { + docsPath: string + version: string +} + +/** + * Next.js ships its documentation inside the published package (at + * `dist/docs`) since 16.2.0. When the install resolved from `cwd` has + * bundled docs, the index can point at them directly instead of + * downloading a copy into `.next-docs`. + */ +export function getBundledDocsInfo(cwd: string): BundledDocsInfo | null { + try { + const nextPkgPath = require.resolve('next/package.json', { paths: [cwd] }) + const pkg = JSON.parse(fs.readFileSync(nextPkgPath, 'utf-8')) + const docsPath = path.join(path.dirname(nextPkgPath), 'dist', 'docs') + if (!pkg.version || collectDocFiles(docsPath).length === 0) { + return null + } + return { docsPath, version: pkg.version } + } catch { + return null + } +} + +export function getBundledDocsLinkPath(cwd: string, docsPath: string): string { + // Prefer the conventional path when it resolves from the project + // (covers hoisted installs; pnpm exposes next via a node_modules symlink). + const conventional = path.join(cwd, 'node_modules', 'next', 'dist', 'docs') + if (fs.existsSync(conventional)) { + return './node_modules/next/dist/docs' + } + const relative = path.relative(cwd, docsPath).replace(/\\/g, '/') + return relative.startsWith('.') ? relative : `./${relative}` +} + function versionToGitHubTag(version: string): string { return version.startsWith('v') ? version : `v${version}` } From 83852116ae9e8a9d0a3c50e333ee4c33fb0cb24c Mon Sep 17 00:00:00 2001 From: ifer47 <361301399@qq.com> Date: Mon, 15 Jun 2026 23:03:12 +0800 Subject: [PATCH 4/5] docs: add prefetchInlining config reference (#94650) ## What - Adds a `prefetchInlining` `next.config.js` API reference page. - Documents the default behavior and the `false` / object forms. - Covers the `maxSize` and `maxBundleSize` thresholds and the request-count vs deduplication trade-off. - Links related navigation and prefetching docs. ## Why `experimental.prefetchInlining` is currently only discoverable through code, PRs, and release notes. This gives the option a canonical docs page and addresses the missing docs report. Closes #94458 ## Duplicate check I checked open PRs for `prefetchInlining` and `prefetchInlining docs`; the current open matches are runtime/test work, not a config reference page. ## Checks - `pnpm exec prettier --check docs/01-app/03-api-reference/05-config/01-next-config-js/prefetchInlining.mdx` - pre-commit `lint-staged` (Prettier + ESLint) Docs-only change; no runtime tests were run. --------- Co-authored-by: Joseph --- .../01-next-config-js/prefetchInlining.mdx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/01-app/03-api-reference/05-config/01-next-config-js/prefetchInlining.mdx diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/prefetchInlining.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/prefetchInlining.mdx new file mode 100644 index 000000000000..5ab46a6cfe1d --- /dev/null +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/prefetchInlining.mdx @@ -0,0 +1,99 @@ +--- +title: prefetchInlining +description: Override how the App Router bundles small prefetch responses together. +version: experimental +related: + title: Related + description: View related API references and guides. + links: + - app/api-reference/components/link + - app/guides/prefetching +--- + +When the App Router prefetches a route, it can bundle small segment responses into a single response instead of requesting each one separately. This reduces the number of prefetch requests at the cost of duplicating some shared segment data across routes. This behavior is on by default, and most apps should leave it that way. + +The `experimental.prefetchInlining` option lets you override this behavior or disable inlining while debugging navigation issues or measuring request volume. For most applications, there is no need to change the default behavior. + +> **Good to know**: The inlining behavior is a permanent part of the App Router. Only the `experimental.prefetchInlining` configuration is experimental, so its options may still change. + +## Usage + +To turn off prefetch inlining, set `experimental.prefetchInlining` to `false`: + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + prefetchInlining: false, + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + prefetchInlining: false, + }, +} + +module.exports = nextConfig +``` + +To override the thresholds instead of disabling inlining, pass an object. Any value you omit keeps its default: + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + prefetchInlining: { + maxSize: 2048, + maxBundleSize: 10240, + }, + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + prefetchInlining: { + maxSize: 2048, + maxBundleSize: 10240, + }, + }, +} + +module.exports = nextConfig +``` + +## Reference + +| Value | Description | +| -------- | ---------------------------------------------------------------------------- | +| `true` | Inlines prefetch responses with the default thresholds. This is the default. | +| `false` | Disables prefetch inlining. Each segment is prefetched as its own request. | +| `object` | Inlines prefetch responses using the `maxSize` or `maxBundleSize` you set. | + +When you pass an object, the following options control the thresholds. Both are measured in bytes of the gzip-compressed segment response: + +| Option | Type | Default | Description | +| --------------- | -------- | ------- | --------------------------------------------------------------------------------------- | +| `maxSize` | `number` | `2048` | Largest a single segment response can be to still be eligible for inlining. | +| `maxBundleSize` | `number` | `10240` | Largest total size that can be inlined into one bundled prefetch response along a path. | + +Lower thresholds keep more per-segment deduplication; higher thresholds inline more data and cut request count further. + +## Version History + +| Version | Change | +| ------- | --------------------------------------------------- | +| 16.3.0 | `experimental.prefetchInlining` enabled by default. | +| 16.2.0 | `experimental.prefetchInlining` added. | From 588bb452fd5f8a8787d12af7699218a2d05ee908 Mon Sep 17 00:00:00 2001 From: Sam Poder Date: Mon, 15 Jun 2026 11:54:51 -0700 Subject: [PATCH 5/5] Move `warnOnce` imports into statements guarded by `process.env.NODE_ENV === 'development'` (#94781) The context is this message from @icyJoseph: ``` I wonder if you've also look a little at DCE? This module, https://nextjs.org/_next/static/immutable/chunks/2uxebeysomvt2.js ships every time... ``` https://nextjs.org/_next/static/immutable/chunks/2uxebeysomvt2.js corresponds to this `warnOnce` helper: https://github.com/vercel/next.js/blob/643e34a59183f90ae9d2db9a64673781316ce786/packages/next/src/shared/lib/utils/warn-once.ts#L1 That doesn't do anything because Turbopack appears to be attempting to remove unused imports before doing dead-code removal. If the imports are moved though into the `(process.env.NODE_ENV !== 'production')` guard, this module won't get shipped into production. The patch to do this is pretty small so this PR does that, I'm going to look into whether its possible to reorder it so unused imports are looked at after dead code is removed. --- packages/next/src/client/app-dir/link.tsx | 3 ++- .../client/components/http-access-fallback/error-boundary.tsx | 3 ++- packages/next/src/client/image-component.tsx | 3 ++- packages/next/src/shared/lib/get-img-props.ts | 3 ++- packages/next/src/shared/lib/head.tsx | 3 ++- .../next/src/shared/lib/router/utils/disable-smooth-scroll.ts | 4 ++-- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/next/src/client/app-dir/link.tsx b/packages/next/src/client/app-dir/link.tsx index 5a624dd81e35..d0b25e1965ed 100644 --- a/packages/next/src/client/app-dir/link.tsx +++ b/packages/next/src/client/app-dir/link.tsx @@ -7,7 +7,6 @@ import { AppRouterContext } from '../../shared/lib/app-router-context.shared-run import { useMergedRef } from '../use-merged-ref' import { isAbsoluteUrl } from '../../shared/lib/utils' import { addBasePath } from '../add-base-path' -import { warnOnce } from '../../shared/lib/utils/warn-once' import { ScrollBehavior } from '../components/router-reducer/router-reducer-types' import type { PENDING_LINK_STATUS } from '../components/links' import { @@ -513,6 +512,8 @@ export default function LinkComponent( const formattedHref = formatStringOrUrl(resolvedHref) if (process.env.NODE_ENV !== 'production') { + const { warnOnce } = + require('../../shared/lib/utils/warn-once') as typeof import('../../shared/lib/utils/warn-once') if (props.locale) { warnOnce( 'The `locale` prop is not supported in `next/link` while using the `app` router. Read more about app router internalization: https://nextjs.org/docs/app/building-your-application/routing/internationalization' diff --git a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx index 2012422b0521..22da33622b4e 100644 --- a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx +++ b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx @@ -19,7 +19,6 @@ import { getAccessFallbackErrorTypeByStatus, isHTTPAccessFallbackError, } from './http-access-fallback' -import { warnOnce } from '../../../shared/lib/utils/warn-once' import { MissingSlotContext } from '../../../shared/lib/app-router-context.shared-runtime' interface HTTPAccessFallbackBoundaryProps { @@ -62,6 +61,8 @@ class HTTPAccessFallbackErrorBoundary extends React.Component< // A missing children slot is the typical not-found case, so no need to warn !this.props.missingSlots.has('children') ) { + const { warnOnce } = + require('../../../shared/lib/utils/warn-once') as typeof import('../../../shared/lib/utils/warn-once') let warningMessage = 'No default component was found for a parallel route rendered on this page. Falling back to nearest NotFound boundary.\n' + 'Learn more: https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#defaultjs\n\n' diff --git a/packages/next/src/client/image-component.tsx b/packages/next/src/client/image-component.tsx index 7bace3ab334d..c76554a18d1b 100644 --- a/packages/next/src/client/image-component.tsx +++ b/packages/next/src/client/image-component.tsx @@ -26,7 +26,6 @@ import type { } from '../shared/lib/image-config' import { imageConfigDefault } from '../shared/lib/image-config' import { ImageConfigContext } from '../shared/lib/image-config-context.shared-runtime' -import { warnOnce } from '../shared/lib/utils/warn-once' import { RouterContext } from '../shared/lib/router-context.shared-runtime' // This is replaced by webpack alias @@ -116,6 +115,8 @@ function handleLoading( onLoadingCompleteRef.current(img) } if (process.env.NODE_ENV !== 'production') { + const { warnOnce } = + require('../shared/lib/utils/warn-once') as typeof import('../shared/lib/utils/warn-once') const origSrc = new URL(src, 'http://n').searchParams.get('url') || src if (img.getAttribute('data-nimg') === 'fill') { if (!unoptimized && (!sizesInput || sizesInput === '100vw')) { diff --git a/packages/next/src/shared/lib/get-img-props.ts b/packages/next/src/shared/lib/get-img-props.ts index a14824d1d954..ee9f277da020 100644 --- a/packages/next/src/shared/lib/get-img-props.ts +++ b/packages/next/src/shared/lib/get-img-props.ts @@ -1,4 +1,3 @@ -import { warnOnce } from './utils/warn-once' import { getAssetToken, getDeploymentId } from './deployment-id' import { getImageBlurSvg } from './image-blur-svg' import { imageConfigDefault } from './image-config' @@ -462,6 +461,8 @@ export function getImgProps( const qualityInt = getInt(quality) if (process.env.NODE_ENV !== 'production') { + const { warnOnce } = + require('./utils/warn-once') as typeof import('./utils/warn-once') if (config.output === 'export' && isDefaultLoader && !unoptimized) { throw new Error( `Image Optimization using the default loader is not compatible with \`{ output: 'export' }\`. diff --git a/packages/next/src/shared/lib/head.tsx b/packages/next/src/shared/lib/head.tsx index f40fa3c6d215..0625d3a977b2 100644 --- a/packages/next/src/shared/lib/head.tsx +++ b/packages/next/src/shared/lib/head.tsx @@ -3,7 +3,6 @@ import React, { useContext, type JSX } from 'react' import Effect from './side-effect' import { HeadManagerContext } from './head-manager-context.shared-runtime' -import { warnOnce } from './utils/warn-once' export function defaultHead(): JSX.Element[] { const head = [ @@ -129,6 +128,8 @@ function reduceComponents( .map((c: React.ReactElement, i: number) => { const key = c.key || i if (process.env.NODE_ENV === 'development') { + const { warnOnce } = + require('./utils/warn-once') as typeof import('./utils/warn-once') // omit JSON-LD structured data snippets from the warning if (c.type === 'script' && c.props['type'] !== 'application/ld+json') { const srcMessage = c.props['src'] diff --git a/packages/next/src/shared/lib/router/utils/disable-smooth-scroll.ts b/packages/next/src/shared/lib/router/utils/disable-smooth-scroll.ts index 0fd4d7ffbb6c..abf3efbb8e89 100644 --- a/packages/next/src/shared/lib/router/utils/disable-smooth-scroll.ts +++ b/packages/next/src/shared/lib/router/utils/disable-smooth-scroll.ts @@ -1,5 +1,3 @@ -import { warnOnce } from '../../utils/warn-once' - /** * Run function with `scroll-behavior: auto` applied to ``. * This css change will be reverted after the function finishes. @@ -24,6 +22,8 @@ export function disableSmoothScrollDuringRouteTransition( process.env.NODE_ENV === 'development' && getComputedStyle(htmlElement).scrollBehavior === 'smooth' ) { + const { warnOnce } = + require('../../utils/warn-once') as typeof import('../../utils/warn-once') warnOnce( 'Detected `scroll-behavior: smooth` on the `` element. To disable smooth scrolling during route transitions, ' + 'add `data-scroll-behavior="smooth"` to your element. ' +