diff --git a/.claude/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md
similarity index 100%
rename from .claude/skills/update-docs/SKILL.md
rename to .agents/skills/update-docs/SKILL.md
diff --git a/.claude/skills/update-docs/references/CODE-TO-DOCS-MAPPING.md b/.agents/skills/update-docs/references/CODE-TO-DOCS-MAPPING.md
similarity index 100%
rename from .claude/skills/update-docs/references/CODE-TO-DOCS-MAPPING.md
rename to .agents/skills/update-docs/references/CODE-TO-DOCS-MAPPING.md
diff --git a/.claude/skills/update-docs/references/DOC-CONVENTIONS.md b/.agents/skills/update-docs/references/DOC-CONVENTIONS.md
similarity index 100%
rename from .claude/skills/update-docs/references/DOC-CONVENTIONS.md
rename to .agents/skills/update-docs/references/DOC-CONVENTIONS.md
diff --git a/.agents/skills/write-api-reference/SKILL.md b/.agents/skills/write-api-reference/SKILL.md
new file mode 100644
index 0000000000000..3a069f73ae2ac
--- /dev/null
+++ b/.agents/skills/write-api-reference/SKILL.md
@@ -0,0 +1,139 @@
+---
+name: write-api-reference
+description: |
+ Produces API reference documentation for Next.js APIs: functions, components, file conventions, directives, and config options.
+
+ **Auto-activation:** User asks to write, create, or draft an API reference page. Also triggers on paths like `docs/01-app/03-api-reference/`, or keywords like "API reference", "props", "parameters", "returns", "signature".
+
+ **Input sources:** Next.js source code, existing API reference pages, or user-provided specifications.
+
+ **Output type:** A markdown (.mdx) API reference page with YAML frontmatter, usage example, reference section, behavior notes, and examples.
+agent: Plan
+context: fork
+---
+
+# Writing API Reference Pages
+
+## Goal
+
+Produce an API reference page that documents a single API surface (function, component, file convention, directive, or config option). The page should be concise, scannable, and example-driven.
+
+Each page documents **one API**. If the API has sub-methods (like `cookies.set()`), document them on the same page. If two APIs are independent, they get separate pages.
+
+## Structure
+
+Identify which category the API belongs to, then follow the corresponding template.
+
+### Categories
+
+1. **Function** (`cookies`, `fetch`, `generateStaticParams`): signature, params/returns, methods table, examples
+2. **Component** (`Link`, `Image`, `Script`): props summary table, individual prop docs, examples
+3. **File convention** (`page`, `layout`, `route`): definition, code showing the convention, props, behavior, examples
+4. **Directive** (`use client`, `use cache`): definition, usage, serialization/boundary rules, reference
+5. **Config option** (`basePath`, `images`, etc.): definition, config code, behavioral sections
+
+### Template
+
+````markdown
+---
+title: {API name}
+description: {API Reference for the {API name} {function|component|file convention|directive|config option}.}
+---
+
+{One sentence defining what it does and where it's used.}
+
+```tsx filename="path/to/file.tsx" switcher
+// Minimal working usage
+```
+
+```jsx filename="path/to/file.js" switcher
+// Same example in JS
+```
+
+## Reference
+
+{For functions: methods/params table, return type.}
+{For components: props summary table, then `#### propName` subsections.}
+{For file conventions: `### Props` with `#### propName` subsections.}
+{For directives: usage rules and serialization constraints.}
+{For config: options table or individual option docs.}
+
+### {Subsection name}
+
+{Description + code example + table of values where applicable.}
+
+## Good to know
+
+- {Default behavior or implicit effects.}
+- {Caveats, limitations, or version-specific notes.}
+- {Edge cases the developer should be aware of.}
+
+## Examples
+
+### {Example name}
+
+{Brief context, 1-2 sentences.}
+
+```tsx filename="path/to/file.tsx" switcher
+// Complete working example
+```
+
+```jsx filename="path/to/file.js" switcher
+// Same example in JS
+```
+
+## Version History
+
+| Version | Changes |
+| -------- | --------------- |
+| `vX.Y.Z` | {What changed.} |
+````
+
+**Category-specific notes:**
+
+- **Functions**: Lead with the function signature and `await` if async. Document methods in a table if the return value has methods (like `cookies`). Document options in a separate table if applicable.
+- **Components**: Start with a props summary table (`| Prop | Example | Type | Required |`). Then document each prop under `#### propName` with description, code example, and value table where useful.
+- **File conventions**: Show the default export signature with TypeScript types. Document each prop (`params`, `searchParams`, etc.) under `#### propName` with a route/URL/value example table.
+- **Directives**: No `## Reference` section. Use `## Usage` instead, showing correct placement. Document serialization constraints and boundary rules.
+- **Config options**: Show the `next.config.ts` snippet. Use subsections for each behavioral aspect.
+
+## Rules
+
+1. **Lead with what it does.** First sentence defines the API. No preamble.
+2. **Show working code immediately.** A minimal usage example appears right after the opening sentence, before `## Reference`.
+3. **Use `switcher` for tsx/jsx pairs.** Always include both. Always include `filename="path/to/file.ext"`.
+4. **Use `highlight={n}` for key lines.** Highlight the line that demonstrates the API being documented.
+5. **Tables for simple APIs, subsections for complex ones.** If a prop/param needs only a type and one-line description, use a table row. If it needs a code example or multiple values, use a `####` subsection.
+6. **Behavior section uses `> **Good to know**:`or`## Good to know`.** Use the blockquote format for brief notes (1-3 bullets). Use the heading format for longer sections. Not "Note:" or "Warning:".
+7. **Examples section uses `### Example Name` subsections.** Each example solves one specific use case.
+8. **Version History table at the end.** Include when the API has changed across versions. Omit for new APIs.
+9. **No em dashes.** Use periods, commas, or parentheses instead.
+10. **Mechanical, observable language.** Describe what happens, not how it feels. "Returns an object" not "gives you an object".
+11. **Link to related docs with relative paths.** Use `/docs/app/...` format.
+12. **No selling or justifying.** No "powerful", "easily", "simply". State what the API does.
+
+| Don't | Do |
+| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| "This powerful function lets you easily manage cookies" | "`cookies` is an async function that reads HTTP request cookies in Server Components" |
+| "You can conveniently access..." | "Returns an object containing..." |
+| "The best way to handle navigation" | "`` extends the HTML `` element to provide prefetching and client-side navigation" |
+
+## Workflow
+
+1. **Ask for reference material.** Ask the user if they have any RFCs, PRs, design docs, or other context that should inform the doc.
+2. **Identify the API category** (function, component, file convention, directive, config).
+3. **Research the implementation.** Read the source code to understand params, return types, edge cases, and defaults.
+4. **Check e2e tests.** Search `test/` for tests exercising the API to find real usage patterns, edge cases, and expected behavior.
+5. **Check existing related docs** for linking opportunities and to avoid duplication.
+6. **Write using the appropriate category template.** Follow the rules above.
+7. **Review against the rules.** Verify: one sentence opener, immediate code example, correct `switcher`/`filename` usage, tables vs subsections, "Good to know" format, no em dashes, mechanical language.
+
+## References
+
+Read these pages in `docs/01-app/03-api-reference/` before writing. They demonstrate the patterns above.
+
+- `04-functions/cookies.mdx` - Function with methods table, options table, and behavior notes
+- `03-file-conventions/page.mdx` - File convention with props subsections and route/URL/value tables
+- `02-components/link.mdx` - Component with props summary table and detailed per-prop docs
+- `01-directives/use-client.mdx` - Directive with usage section and serialization rules
+- `04-functions/fetch.mdx` - Function with troubleshooting section and version history
diff --git a/.agents/skills/write-guide/SKILL.md b/.agents/skills/write-guide/SKILL.md
new file mode 100644
index 0000000000000..8978365f5e61c
--- /dev/null
+++ b/.agents/skills/write-guide/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: write-guide
+description: |
+ Generates technical guides that teach real-world use cases through progressive examples.
+
+ **Auto-activation:** User asks to write, create, or draft a guide or tutorial. Also use when converting feature documentation, API references, or skill knowledge into step-by-step learning content.
+
+ **Input sources:** Feature skills, API documentation, existing code examples, or user-provided specifications.
+
+ **Output type:** A markdown guide with YAML frontmatter, introduction, 2-4 progressive steps, and next steps section.
+agent: Plan
+context: fork
+---
+
+# Writing Guides
+
+## Goal
+
+Produce a technical guide that teaches a real-world use case through progressive examples. Concepts are introduced only when the reader needs them.
+
+Each guide solves **one specific problem**. Not a category of problems. If the outline has 5+ steps or covers multiple approaches, split it.
+
+## Structure
+
+Every guide follows this arc: introduction, example setup, 2-5 progressive steps, next steps.
+
+Each step follows this loop: working code → new requirement → friction → explanation → resolution → observable proof.
+
+Sections: introduction (no heading, 2 paragraphs max), `## Example` (what we're building + source link), `### Step N` (action-oriented titles, 2-4 steps), `## Next steps` (summary + related links).
+
+Headings should tell a story on their own. If readers only saw the headings, they'd understand the guide's takeaway.
+
+### Template
+
+````markdown
+---
+title: {Action-oriented, e.g., "Building X" or "How to Y"}
+description: {One sentence}
+nav_title: {Short title for navigation}
+---
+
+{What the reader will accomplish and why it matters. The friction and how this approach resolves it. 2 paragraphs max.}
+
+## Example
+
+As an example, we'll build {what we're building}.
+
+We'll start with {step 1}, then {step 2}, and {step 3}.
+
+{Source code link.}
+
+### Step 1: {Action-oriented title}
+
+{Brief context, 1-2 sentences.}
+
+```tsx filename="path/to/file.tsx"
+// Minimal working code
+```
+
+{Explain what happens.}
+
+{Introduce friction: warning, limitation, or constraint.}
+
+{Resolution: explain the choice, apply the fix.}
+
+{Verify the fix with observable proof.}
+
+### Step 2: {Action-oriented title}
+
+{Same pattern: context → code → explain → friction → resolution → proof.}
+
+### Step 3: {Action-oriented title}
+
+{Same pattern.}
+
+## Next steps
+
+You now know how to {summary}.
+
+Next, learn how to:
+
+- [Related guide 1]()
+- [Related guide 2]()
+````
+
+### Workflow
+
+1. **Research**: Check available skills for relevant features. Read existing docs for context and linking opportunities.
+2. **Plan**: Outline sections. Verify scope (one problem, 2-4 steps). Each step needs a friction point and resolution.
+3. **Write**: Follow the template above. Apply the rules below.
+4. **Review**: Re-read the rules, verify, then present.
+
+## Rules
+
+1. **Progressive disclosure.** Start with the smallest working example. Introduce complexity only when the example breaks. Name concepts at the moment of resolution, after the reader has felt the problem. Full loop: working → new requirement → something breaks → explain why → name the fix → apply → verify with proof → move on.
+2. **Show problems visually.** Console errors, terminal output, build warnings, slow-loading pages. "If we refresh the page, we can see the component blocks the response."
+3. **Verify resolutions with observable proof.** Before/after comparisons, browser reloads, terminal output. "If we refresh the page again, we can see it loads instantly."
+4. **One friction point per step.** If a step has multiple friction points, split it.
+5. **Minimal code blocks.** Only the code needed for the current step. Collapse unchanged functions with `function Header() {}`.
+6. **No em dashes.** Use periods, commas, or parentheses instead.
+7. **Mechanical, observable language.** Describe what happens, not how it feels.
+8. **No selling, justifying, or comparing.** No "the best way," no historical context, no framework comparisons.
+
+| Don't | Do |
+| ---------------------------------------------------- | -------------------------------------------------------- |
+| "creates friction in the pipeline" | "blocks the response" |
+| "needs dynamic information" | "depends on request-time data" |
+| "requires dynamic processing" | "output can't be known ahead of time" |
+| "The component blocks the response — causing delays" | "The component blocks the response. This causes delays." |
+
+## References
+
+Read these guides in `docs/01-app/02-guides/` before writing. They demonstrate the patterns above.
+
+- `public-static-pages.mdx` — intro → example → 3 progressive steps → next steps. Concepts named at point of resolution. Problems shown with build output.
+- `forms.mdx` — progressive feature building without explicit "Step" labels. Each section adds one capability.
diff --git a/contributing/core/developing.md b/contributing/core/developing.md
index db38398f5909f..06e02b73f4243 100644
--- a/contributing/core/developing.md
+++ b/contributing/core/developing.md
@@ -180,17 +180,3 @@ pnpm sweep
```
It will also clean up other caches (pnpm store, cargo, etc.) and run `git gc` for you.
-
-### MacOS disk compression
-
-If you want to automatically use APFS disk compression on macOS for `node_modules/` and `target/` you can install a launch agent with:
-
-```bash
-./scripts/LaunchAgents/install-macos-agents.sh
-```
-
-Or run it manually with:
-
-```bash
-./scripts/macos-compress.sh
-```
diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs
index b4d525eb852e4..e12629d5c9e00 100644
--- a/crates/next-api/src/app.rs
+++ b/crates/next-api/src/app.rs
@@ -1358,45 +1358,52 @@ impl AppEndpoint {
let manifest_path_prefix = &app_entry.original_name;
- // polyfill-nomodule.js is a pre-compiled asset distributed as part of next
- let next_package = get_next_package(project.project_path().owned().await?).await?;
- let polyfill_source_path =
- next_package.join("dist/build/polyfills/polyfill-nomodule.js")?;
- let polyfill_source = FileSource::new(polyfill_source_path.clone());
- let polyfill_output_path = client_chunking_context
- .chunk_path(
- Some(Vc::upcast(polyfill_source)),
- polyfill_source.ident(),
- None,
- rcstr!(".js"),
- )
- .owned()
- .await?;
-
- let polyfill_output = SingleFileEcmascriptOutput::new(
- polyfill_output_path.clone(),
- polyfill_source_path,
- Vc::upcast(polyfill_source),
- )
- .to_resolved()
- .await?;
-
- let polyfill_output_asset = ResolvedVc::upcast(polyfill_output);
- client_assets.insert(polyfill_output_asset);
+ // Only Pages need a polyfill chunk, Routes handlers don't have any inherent code that runs
+ // in the browser.
+ let polyfill_output_asset = if matches!(this.ty, AppEndpointType::Page { .. }) {
+ // polyfill-nomodule.js is a pre-compiled asset distributed as part of next
+ let next_package = get_next_package(project.project_path().owned().await?).await?;
+ let polyfill_source_path =
+ next_package.join("dist/build/polyfills/polyfill-nomodule.js")?;
+ let polyfill_source = FileSource::new(polyfill_source_path.clone());
+ let polyfill_output_path = client_chunking_context
+ .chunk_path(
+ Some(Vc::upcast(polyfill_source)),
+ polyfill_source.ident(),
+ None,
+ rcstr!(".js"),
+ )
+ .owned()
+ .await?;
- let client_source_maps = project
- .next_config()
- .client_source_maps(project.next_mode())
- .await?;
- if *client_source_maps != SourceMapsType::None {
- let polyfill_source_map_asset = SourceMapAsset::new_fixed(
+ let polyfill_output = SingleFileEcmascriptOutput::new(
polyfill_output_path.clone(),
- *ResolvedVc::upcast(polyfill_output),
+ polyfill_source_path,
+ Vc::upcast(polyfill_source),
)
.to_resolved()
.await?;
- client_assets.insert(ResolvedVc::upcast(polyfill_source_map_asset));
- }
+
+ let polyfill_output_asset = ResolvedVc::upcast(polyfill_output);
+ client_assets.insert(polyfill_output_asset);
+
+ let client_source_maps = project
+ .next_config()
+ .client_source_maps(project.next_mode())
+ .await?;
+ if *client_source_maps != SourceMapsType::None {
+ let polyfill_source_map_asset = SourceMapAsset::new_fixed(
+ polyfill_output_path.clone(),
+ *ResolvedVc::upcast(polyfill_output),
+ )
+ .to_resolved()
+ .await?;
+ client_assets.insert(ResolvedVc::upcast(polyfill_source_map_asset));
+ }
+ Some(polyfill_output_asset)
+ } else {
+ None
+ };
let client_assets: ResolvedVc =
ResolvedVc::cell(client_assets.into_iter().collect::>());
@@ -1437,7 +1444,7 @@ impl AppEndpoint {
client_relative_path: client_relative_path.clone(),
pages: Default::default(),
root_main_files: client_shared_chunks,
- polyfill_files: vec![polyfill_output_asset],
+ polyfill_files: polyfill_output_asset.into_iter().collect(),
};
server_assets.insert(ResolvedVc::upcast(build_manifest.resolved_cell()));
}
diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs
index 823de190be10f..6b419f4b80102 100644
--- a/crates/next-core/src/next_client/context.rs
+++ b/crates/next-core/src/next_client/context.rs
@@ -11,13 +11,13 @@ use turbopack::module_options::{
side_effect_free_packages_glob,
};
use turbopack_browser::{
- BrowserChunkingContext, ContentHashing, CurrentChunkMethod,
- react_refresh::assert_can_resolve_react_refresh,
+ BrowserChunkingContext, CurrentChunkMethod, react_refresh::assert_can_resolve_react_refresh,
};
use turbopack_core::{
chunk::{
- AssetSuffix, ChunkingConfig, ChunkingContext, MangleType, MinifyType, SourceMapSourceType,
- SourceMapsType, UnusedReferences, UrlBehavior, chunk_id_strategy::ModuleIdStrategy,
+ AssetSuffix, ChunkingConfig, ChunkingContext, ContentHashing, MangleType, MinifyType,
+ SourceMapSourceType, SourceMapsType, UnusedReferences, UrlBehavior,
+ chunk_id_strategy::ModuleIdStrategy,
},
compile_time_info::{CompileTimeDefines, CompileTimeInfo, FreeVarReference, FreeVarReferences},
environment::{BrowserEnvironment, Environment, ExecutionEnvironment},
@@ -530,7 +530,7 @@ pub async fn get_client_chunking_context(
..Default::default()
},
)
- .use_content_hashing(ContentHashing::Direct { length: 16 })
+ .chunk_content_hashing(ContentHashing::Direct { length: 13 })
.module_merging(*scope_hoisting.await?);
}
diff --git a/docs/01-app/01-getting-started/13-fonts.mdx b/docs/01-app/01-getting-started/13-fonts.mdx
index e5f1df8fe83f0..bc6a37f037fa4 100644
--- a/docs/01-app/01-getting-started/13-fonts.mdx
+++ b/docs/01-app/01-getting-started/13-fonts.mdx
@@ -89,50 +89,6 @@ export default function MyApp({ Component, pageProps }) {
}
```
-If you want to apply the font to the `` element, you can use a [Custom Document](/docs/pages/building-your-application/routing/custom-document) (`pages/_document`):
-
-```tsx filename="pages/_document.tsx" highlight={2,4-6,10} switcher
-import { Html, Head, Main, NextScript } from 'next/document'
-import { Geist } from 'next/font/google'
-
-const geist = Geist({
- subsets: ['latin'],
-})
-
-export default function Document() {
- return (
-
-
-
-
-
-
-
- )
-}
-```
-
-```jsx filename="pages/_document.js" highlight={2,4-6,10} switcher
-import { Html, Head, Main, NextScript } from 'next/document'
-import { Geist } from 'next/font/google'
-
-const geist = Geist({
- subsets: ['latin'],
-})
-
-export default function Document() {
- return (
-
-
-
-
-
-
-
- )
-}
-```
-
## Google fonts
diff --git a/eslint.config.mjs b/eslint.config.mjs
index d0cee73dcac52..4a0f2ca7b9e4f 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -307,6 +307,7 @@ export default defineConfig([
'retry',
'itCI',
'itHeaded',
+ 'itTurbopack',
'itTurbopackDev',
'itOnlyTurbopack',
],
diff --git a/lerna.json b/lerna.json
index 0ddd6bfe0bee4..aada7b55dc77b 100644
--- a/lerna.json
+++ b/lerna.json
@@ -15,5 +15,5 @@
"registry": "https://registry.npmjs.org/"
}
},
- "version": "16.2.0-canary.96"
+ "version": "16.2.0-canary.97"
}
\ No newline at end of file
diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json
index c4a13dba3ddb6..1afe3188658ce 100644
--- a/packages/create-next-app/package.json
+++ b/packages/create-next-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-next-app",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"keywords": [
"react",
"next",
diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json
index 574d2a1fc6d71..18b7f23fd4422 100644
--- a/packages/eslint-config-next/package.json
+++ b/packages/eslint-config-next/package.json
@@ -1,6 +1,6 @@
{
"name": "eslint-config-next",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "ESLint configuration used by Next.js.",
"license": "MIT",
"repository": {
@@ -12,7 +12,7 @@
"dist"
],
"dependencies": {
- "@next/eslint-plugin-next": "16.2.0-canary.96",
+ "@next/eslint-plugin-next": "16.2.0-canary.97",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json
index aa79d48e4a58a..2b6dc6cdb9b98 100644
--- a/packages/eslint-plugin-internal/package.json
+++ b/packages/eslint-plugin-internal/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/eslint-plugin-internal",
"private": true,
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "ESLint plugin for working on Next.js.",
"exports": {
".": "./src/eslint-plugin-internal.js"
diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json
index e88f37614b4ff..3bcaa4c3c75c4 100644
--- a/packages/eslint-plugin-next/package.json
+++ b/packages/eslint-plugin-next/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/eslint-plugin-next",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "ESLint plugin for Next.js.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/font/package.json b/packages/font/package.json
index 5c71376d178a9..d1867df1026bd 100644
--- a/packages/font/package.json
+++ b/packages/font/package.json
@@ -1,7 +1,7 @@
{
"name": "@next/font",
"private": true,
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"repository": {
"url": "vercel/next.js",
"directory": "packages/font"
diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json
index 877f74d6127ff..775100ff5694c 100644
--- a/packages/next-bundle-analyzer/package.json
+++ b/packages/next-bundle-analyzer/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/bundle-analyzer",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT",
diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json
index 5a65beb370d28..9c7eb7f7d0418 100644
--- a/packages/next-codemod/package.json
+++ b/packages/next-codemod/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/codemod",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/packages/next-env/package.json b/packages/next-env/package.json
index ebb5b6cc5c234..5b6654922b4fa 100644
--- a/packages/next-env/package.json
+++ b/packages/next-env/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/env",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"keywords": [
"react",
"next",
diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json
index 578460eecab1a..dc0d33a422798 100644
--- a/packages/next-mdx/package.json
+++ b/packages/next-mdx/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/mdx",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"main": "index.js",
"license": "MIT",
"repository": {
diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json
index 3bf66519b9fd2..5a29215d3bd0c 100644
--- a/packages/next-playwright/package.json
+++ b/packages/next-playwright/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/playwright",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-playwright"
diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json
index fc2226f90bf32..ba4e35995468b 100644
--- a/packages/next-plugin-storybook/package.json
+++ b/packages/next-plugin-storybook/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/plugin-storybook",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-plugin-storybook"
diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json
index a31b5a176bd2d..66d4891940edb 100644
--- a/packages/next-polyfill-module/package.json
+++ b/packages/next-polyfill-module/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-module",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)",
"main": "dist/polyfill-module.js",
"license": "MIT",
diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json
index 3e0a46f467a3b..49f9c5541bd36 100644
--- a/packages/next-polyfill-nomodule/package.json
+++ b/packages/next-polyfill-nomodule/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/polyfill-nomodule",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "A polyfill for non-dead, nomodule browsers.",
"main": "dist/polyfill-nomodule.js",
"license": "MIT",
diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json
index 024c21f749ac5..2e3aa12bb7af2 100644
--- a/packages/next-routing/package.json
+++ b/packages/next-routing/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/routing",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"keywords": [
"react",
"next",
diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json
index da4dd7185849b..b3f280abd3246 100644
--- a/packages/next-rspack/package.json
+++ b/packages/next-rspack/package.json
@@ -1,6 +1,6 @@
{
"name": "next-rspack",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"repository": {
"url": "vercel/next.js",
"directory": "packages/next-rspack"
diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json
index a8ab05af3b578..226a789e93894 100644
--- a/packages/next-swc/package.json
+++ b/packages/next-swc/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/swc",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"private": true,
"files": [
"native/"
diff --git a/packages/next/package.json b/packages/next/package.json
index 163f1664cf5af..905ef6b879b47 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "next",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "The React Framework",
"main": "./dist/server/next.js",
"license": "MIT",
@@ -97,7 +97,7 @@
]
},
"dependencies": {
- "@next/env": "16.2.0-canary.96",
+ "@next/env": "16.2.0-canary.97",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -161,11 +161,11 @@
"@modelcontextprotocol/sdk": "1.18.1",
"@mswjs/interceptors": "0.23.0",
"@napi-rs/triples": "1.2.0",
- "@next/font": "16.2.0-canary.96",
- "@next/polyfill-module": "16.2.0-canary.96",
- "@next/polyfill-nomodule": "16.2.0-canary.96",
- "@next/react-refresh-utils": "16.2.0-canary.96",
- "@next/swc": "16.2.0-canary.96",
+ "@next/font": "16.2.0-canary.97",
+ "@next/polyfill-module": "16.2.0-canary.97",
+ "@next/polyfill-nomodule": "16.2.0-canary.97",
+ "@next/react-refresh-utils": "16.2.0-canary.97",
+ "@next/swc": "16.2.0-canary.97",
"@opentelemetry/api": "1.6.0",
"@playwright/test": "1.51.1",
"@rspack/core": "1.6.7",
diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts
index 963e76055e4d6..32cded960f5bf 100644
--- a/packages/next/src/build/adapter/build-complete.ts
+++ b/packages/next/src/build/adapter/build-complete.ts
@@ -37,6 +37,7 @@ import {
CACHE_ONE_YEAR_SECONDS,
HTML_CONTENT_TYPE_HEADER,
JSON_CONTENT_TYPE_HEADER,
+ NEXT_QUERY_PARAM_PREFIX,
NEXT_RESUME_HEADER,
} from '../../lib/constants'
@@ -1591,6 +1592,7 @@ export async function handleBuildComplete({
fallbackStatus,
fallbackSourceRoute,
fallbackRootParams,
+ remainingPrerenderableParams,
allowHeader,
dataRoute,
renderingMode,
@@ -1602,19 +1604,32 @@ export async function handleBuildComplete({
const isAppPage = Boolean(appOutputMap[srcRoute])
const meta = await getAppRouteMeta(dynamicRoute, isAppPage)
- const allowQuery = Object.values(
+ const routeKeys =
routesManifest.dynamicRoutes.find(
(item) => item.page === dynamicRoute
)?.routeKeys || {}
- )
+ const allowQuery = Object.values(routeKeys)
const partialFallbacksEnabled =
config.experimental.partialFallbacks === true
const partialFallback =
partialFallbacksEnabled &&
isAppPage &&
+ remainingPrerenderableParams !== undefined &&
+ remainingPrerenderableParams.length > 0 &&
renderingMode === RenderingMode.PARTIALLY_STATIC &&
typeof fallback === 'string' &&
Boolean(meta.postponed)
+
+ // Today, consumers of this build output can only upgrade a fallback shell
+ // when all remaining route params become concrete in the upgraded entry.
+ // They cannot yet represent intermediate shells like `/[foo]/[bar] -> /foo/[bar]`,
+ // because we do not emit which fallback params should remain deferred after
+ // the upgrade. Until that contract exists, only emit `partialFallback` for
+ // the conservative case where the upgraded entry can become fully concrete.
+ const canEmitPartialFallback =
+ partialFallback &&
+ fallbackRootParams?.length === 0 &&
+ allowQuery.length === remainingPrerenderableParams?.length
let htmlAllowQuery = allowQuery
// We only want to vary on the shell contents if there is a fallback
@@ -1632,12 +1647,18 @@ export async function handleBuildComplete({
// RSC shell.
else if (meta.postponed) {
// If there's postponed fallback content, we usually collapse to a shared shell (`[]`).
- // For opt-in partial fallbacks in cache components, keep route
- // allowQuery so fallback shells can be upgraded per-param instead
- // of sharing one cache key.
+ // For opt-in partial fallbacks in cache components, keep only the
+ // params that can still complete this shell.
+ const remainingPrerenderableQueryKeys = new Set(
+ (remainingPrerenderableParams ?? []).map(
+ (param) => `${NEXT_QUERY_PARAM_PREFIX}${param.paramName}`
+ )
+ )
htmlAllowQuery =
- partialFallback && routesManifest.rsc.clientParamParsing
- ? allowQuery
+ canEmitPartialFallback && routesManifest.rsc.clientParamParsing
+ ? Object.values(routeKeys).filter((routeKey) =>
+ remainingPrerenderableQueryKeys.has(routeKey)
+ )
: []
}
}
@@ -1683,7 +1704,7 @@ export async function handleBuildComplete({
allowQuery: htmlAllowQuery,
allowHeader,
renderingMode,
- partialFallback: partialFallback || undefined,
+ partialFallback: canEmitPartialFallback || undefined,
bypassFor: isAppPage ? experimentalBypassFor : undefined,
bypassToken: prerenderManifest.preview.previewModeId,
},
diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts
index ac7039edccb19..842e141a78cee 100644
--- a/packages/next/src/build/index.ts
+++ b/packages/next/src/build/index.ts
@@ -122,6 +122,7 @@ import { isWriteable } from './is-writeable'
import * as Log from './output/log'
import createSpinner from './spinner'
import { trace, flushAllTraces, setGlobal, type Span } from '../trace'
+import { writeRouteBundleStats } from './route-bundle-stats'
import {
detectConflictingPaths,
printCustomRoutes,
@@ -293,6 +294,13 @@ export interface DynamicPrerenderManifestRoute {
experimentalBypassFor?: RouteHas[]
fallback: Fallback
+ /**
+ * The unresolved fallback route params that can still be specialized into a
+ * more specific prerendered shell because their segments export
+ * `generateStaticParams`.
+ */
+ remainingPrerenderableParams?: readonly FallbackRouteParam[]
+
/**
* When defined, it describes the revalidation configuration for the fallback
* route.
@@ -2066,6 +2074,8 @@ export default async function build(
defaultLocale: config.i18n?.defaultLocale,
nextConfigOutput: config.output,
pprConfig: config.experimental.ppr,
+ partialFallbacksEnabled:
+ config.experimental.partialFallbacks === true,
cacheLifeProfiles: config.cacheLife,
buildId,
clientAssetToken:
@@ -2293,6 +2303,8 @@ export default async function build(
cacheMaxMemorySize: config.cacheMaxMemorySize,
nextConfigOutput: config.output,
pprConfig: config.experimental.ppr,
+ partialFallbacksEnabled:
+ config.experimental.partialFallbacks === true,
cacheLifeProfiles: config.cacheLife,
buildId,
clientAssetToken:
@@ -3422,6 +3434,8 @@ export default async function build(
prerenderManifest.dynamicRoutes[route.pathname] = {
experimentalPPR: isRoutePPREnabled,
+ remainingPrerenderableParams:
+ route.remainingPrerenderableParams,
renderingMode: isAppPPREnabled
? isRoutePPREnabled
? RenderingMode.PARTIALLY_STATIC
@@ -3439,7 +3453,7 @@ export default async function build(
fallbackExpire: fallbackCacheControl?.expire,
fallbackStatus: meta.status,
fallbackHeaders: meta.headers,
- fallbackRootParams: fallback
+ fallbackRootParams: route.fallbackRouteParams
? route.fallbackRootParams
: undefined,
fallbackSourceRoute:
@@ -4189,6 +4203,14 @@ export default async function build(
})
)
+ if (bundler === Bundler.Turbopack) {
+ await nextBuildSpan
+ .traceChild('write-route-bundle-stats')
+ .traceAsyncFn(() =>
+ writeRouteBundleStats(pageKeys, buildManifest, distDir, dir)
+ )
+ }
+
await nextBuildSpan
.traceChild('telemetry-flush')
.traceAsyncFn(() => telemetry.flush())
diff --git a/packages/next/src/build/route-bundle-stats.ts b/packages/next/src/build/route-bundle-stats.ts
new file mode 100644
index 0000000000000..3598be9788bb0
--- /dev/null
+++ b/packages/next/src/build/route-bundle-stats.ts
@@ -0,0 +1,221 @@
+import path from 'path'
+import { promises as fs, statSync } from 'fs'
+import {
+ APP_PATHS_MANIFEST,
+ CLIENT_REFERENCE_MANIFEST,
+ SERVER_DIRECTORY,
+} from '../shared/lib/constants'
+import type { BuildManifest } from '../server/get-page-files'
+import { filterAndSortList } from './utils'
+
+const ROUTE_BUNDLE_STATS_FILE = 'route-bundle-stats.json'
+
+type RouteBundleStat = {
+ route: string
+ firstLoadUncompressedJsBytes: number
+ firstLoadChunkPaths: string[]
+}
+
+function sumFileSizes(
+ distDir: string,
+ files: string[],
+ cache: Map
+): number {
+ let total = 0
+ for (const relPath of files) {
+ const cached = cache.get(relPath)
+ if (cached !== undefined) {
+ total += cached
+ continue
+ }
+ try {
+ const size = statSync(path.join(distDir, relPath)).size
+ cache.set(relPath, size)
+ total += size
+ } catch {
+ // ignore missing files
+ }
+ }
+ return total
+}
+
+function toProjectRelativePaths(
+ dir: string,
+ distDir: string,
+ relPaths: string[]
+): string[] {
+ return relPaths.map((f) => path.relative(dir, path.join(distDir, f)))
+}
+
+function buildRouteToAppPathsMap(
+ appPathsManifest: Record
+): Map {
+ const { normalizeAppPath } =
+ require('../shared/lib/router/utils/app-paths') as typeof import('../shared/lib/router/utils/app-paths')
+ // Keys in appPathsManifest are app paths like /blog/[slug]/page;
+ // values are server bundle file paths. Normalize the key to get the route.
+ const routeToAppPaths = new Map()
+ for (const appPath of Object.keys(appPathsManifest)) {
+ const route = normalizeAppPath(appPath)
+ const existing = routeToAppPaths.get(route)
+ if (existing) {
+ existing.push(appPath)
+ } else {
+ routeToAppPaths.set(route, [appPath])
+ }
+ }
+ return routeToAppPaths
+}
+
+// Reads the manfiest file and gets the entry JS files. The manifest file is
+// a JavaScript file that sets a global variable (__RSC_MANIFEST). We require()
+// it with a save/restore of the global
+function readEntryJSFiles(
+ distDir: string,
+ pagePath: string,
+ appRoute: string
+): Record | undefined {
+ const manifestFile = path.join(
+ distDir,
+ SERVER_DIRECTORY,
+ 'app',
+ `${pagePath}_${CLIENT_REFERENCE_MANIFEST}.js`
+ )
+ try {
+ const g = global as Record
+ const prev = g.__RSC_MANIFEST
+ g.__RSC_MANIFEST = undefined
+ require(manifestFile)
+ const rscManifest = g.__RSC_MANIFEST as
+ | Record }>
+ | undefined
+ g.__RSC_MANIFEST = prev
+
+ // The key in __RSC_MANIFEST is the app path (e.g. /blog/[slug]/page)
+ const manifestEntry = rscManifest?.[pagePath] ?? rscManifest?.[appRoute]
+ return manifestEntry?.entryJSFiles
+ } catch {
+ return undefined
+ }
+}
+
+function collectPagesRouterStats(
+ pages: ReadonlyArray,
+ buildManifest: BuildManifest,
+ distDir: string,
+ dir: string,
+ cache: Map
+): RouteBundleStat[] {
+ const rows: RouteBundleStat[] = []
+ const sharedFiles = buildManifest.pages['/_app'] ?? []
+ for (const page of filterAndSortList(pages, 'pages', false)) {
+ if (page === '/_app' || page === '/_document' || page === '/_error')
+ // Don't report on layouts directly
+ continue
+
+ const allFiles = (buildManifest.pages[page] ?? []).filter((f) =>
+ f.endsWith('.js')
+ )
+ const sharedJs = sharedFiles.filter((f) => f.endsWith('.js'))
+ const chunks = [...new Set([...allFiles, ...sharedJs])]
+ const firstLoadUncompressedJsBytes = sumFileSizes(distDir, chunks, cache)
+ rows.push({
+ route: page,
+ firstLoadUncompressedJsBytes,
+ firstLoadChunkPaths: toProjectRelativePaths(dir, distDir, chunks),
+ })
+ }
+ return rows
+}
+
+async function collectAppRouterStats(
+ appRoutes: ReadonlyArray,
+ buildManifest: BuildManifest,
+ distDir: string,
+ dir: string,
+ cache: Map
+): Promise {
+ let appPathsManifest: Record = {}
+ try {
+ const manifestPath = path.join(
+ distDir,
+ SERVER_DIRECTORY,
+ APP_PATHS_MANIFEST
+ )
+ appPathsManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
+ } catch {
+ // App paths manifest not available; skip app router sizes
+ return []
+ }
+
+ const routeToAppPaths = buildRouteToAppPathsMap(appPathsManifest)
+ const sharedFiles = buildManifest.rootMainFiles ?? []
+ const rows: RouteBundleStat[] = []
+
+ for (const appRoute of filterAndSortList(appRoutes, 'app', false)) {
+ const appPaths = routeToAppPaths.get(appRoute) ?? []
+ // Find the /page entry (most specific, has the most chunks)
+ const pagePath = appPaths.find((p) => p.endsWith('/page'))
+ if (!pagePath) continue
+
+ const entryJSFiles = readEntryJSFiles(distDir, pagePath, appRoute)
+ if (!entryJSFiles) continue
+
+ // Union JS files across all segments (page, layout, etc.) so that
+ // layout code's contribution is included in the First Load JS total.
+ const allFiles = [
+ ...new Set(
+ Object.values(entryJSFiles)
+ .flat()
+ .filter((f) => f.endsWith('.js'))
+ ),
+ ]
+ const sharedJs = sharedFiles.filter((f) => f.endsWith('.js'))
+ const chunks = [...new Set([...allFiles, ...sharedJs])]
+ const firstLoadUncompressedJsBytes = sumFileSizes(distDir, chunks, cache)
+ rows.push({
+ route: appRoute,
+ firstLoadUncompressedJsBytes,
+ firstLoadChunkPaths: toProjectRelativePaths(dir, distDir, chunks),
+ })
+ }
+ return rows
+}
+
+export async function writeRouteBundleStats(
+ lists: {
+ pages: ReadonlyArray
+ app: ReadonlyArray | undefined
+ },
+ buildManifest: BuildManifest,
+ distDir: string,
+ dir: string
+): Promise {
+ const cache = new Map()
+
+ const rows = [
+ ...(lists.pages.length > 0
+ ? collectPagesRouterStats(lists.pages, buildManifest, distDir, dir, cache)
+ : []),
+ ...(lists.app && lists.app.length > 0
+ ? await collectAppRouterStats(
+ lists.app,
+ buildManifest,
+ distDir,
+ dir,
+ cache
+ )
+ : []),
+ ]
+
+ rows.sort(
+ (a, b) => b.firstLoadUncompressedJsBytes - a.firstLoadUncompressedJsBytes
+ )
+
+ const diagnosticsDir = path.join(distDir, 'diagnostics')
+ await fs.mkdir(diagnosticsDir, { recursive: true })
+ await fs.writeFile(
+ path.join(diagnosticsDir, ROUTE_BUNDLE_STATS_FILE),
+ JSON.stringify(rows, null, 2)
+ )
+}
diff --git a/packages/next/src/build/static-paths/app.test.ts b/packages/next/src/build/static-paths/app.test.ts
index 0d0c4d43653a9..d34074d105c77 100644
--- a/packages/next/src/build/static-paths/app.test.ts
+++ b/packages/next/src/build/static-paths/app.test.ts
@@ -1,7 +1,7 @@
import { FallbackMode } from '../../lib/fallback'
import type { Params } from '../../server/request/params'
import {
- assignErrorIfEmpty,
+ assignStaticShellMetadata,
generateAllParamCombinations,
calculateFallbackMode,
filterUniqueParams,
@@ -11,7 +11,26 @@ import type { PrerenderedRoute } from './types'
import type { WorkStore } from '../../server/app-render/work-async-storage.external'
import type { AppSegment } from '../segment-config/app/app-segments'
-describe('assignErrorIfEmpty', () => {
+function pathnameSegments(
+ ...segments: Array
+): Array<{
+ paramName: string
+ hasGenerateStaticParams: boolean
+}> {
+ return segments.map((segment) =>
+ Array.isArray(segment)
+ ? {
+ paramName: segment[0],
+ hasGenerateStaticParams: segment[1],
+ }
+ : {
+ paramName: segment,
+ hasGenerateStaticParams: false,
+ }
+ )
+}
+
+describe('assignStaticShellMetadata', () => {
it('should assign throwOnEmptyStaticShell true for a static route with no children', () => {
const prerenderedRoutes: PrerenderedRoute[] = [
{
@@ -25,7 +44,7 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [])
+ assignStaticShellMetadata(prerenderedRoutes, [], true)
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(true)
})
@@ -57,7 +76,7 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [{ paramName: 'id' }])
+ assignStaticShellMetadata(prerenderedRoutes, pathnameSegments('id'), true)
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(true)
@@ -131,10 +150,11 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [
- { paramName: 'id' },
- { paramName: 'name' },
- ])
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments('id', 'name'),
+ true
+ )
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(false)
@@ -188,23 +208,71 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [
- { paramName: 'id' },
- { paramName: 'name' },
- { paramName: 'extra' },
- ])
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments('id', ['name', true], 'extra'),
+ true
+ )
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(false)
expect(prerenderedRoutes[2].throwOnEmptyStaticShell).toBe(true)
+ expect(prerenderedRoutes[0].remainingPrerenderableParams).toEqual([
+ {
+ paramName: 'name',
+ paramType: 'dynamic',
+ },
+ ])
+ expect(prerenderedRoutes[1].remainingPrerenderableParams).toEqual([
+ {
+ paramName: 'name',
+ paramType: 'dynamic',
+ },
+ ])
+ expect(prerenderedRoutes[2].remainingPrerenderableParams).toBeUndefined()
})
it('should handle empty input', () => {
const prerenderedRoutes: PrerenderedRoute[] = []
- assignErrorIfEmpty(prerenderedRoutes, [])
+ assignStaticShellMetadata(prerenderedRoutes, [], true)
expect(prerenderedRoutes).toEqual([])
})
+ it('should skip remaining prerenderable params when partial fallbacks are disabled', () => {
+ const prerenderedRoutes: PrerenderedRoute[] = [
+ {
+ params: {},
+ pathname: '/[id]',
+ encodedPathname: '/[id]',
+ fallbackRouteParams: [
+ {
+ paramName: 'id',
+ paramType: 'dynamic',
+ },
+ ],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ {
+ params: { id: '1' },
+ pathname: '/1',
+ encodedPathname: '/1',
+ fallbackRouteParams: [],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ ]
+
+ assignStaticShellMetadata(prerenderedRoutes, pathnameSegments('id'), false)
+
+ expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
+ expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(true)
+ expect(prerenderedRoutes[0].remainingPrerenderableParams).toBeUndefined()
+ expect(prerenderedRoutes[1].remainingPrerenderableParams).toBeUndefined()
+ })
+
it('should handle blog/[slug] not throwing when concrete routes exist (from docs example)', () => {
const prerenderedRoutes: PrerenderedRoute[] = [
{
@@ -241,7 +309,7 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [{ paramName: 'slug' }])
+ assignStaticShellMetadata(prerenderedRoutes, pathnameSegments('slug'), true)
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false) // Should not throw - has concrete children
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(true) // Should throw - concrete route
@@ -293,10 +361,11 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [
- { paramName: 'id' },
- { paramName: 'slug' },
- ])
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments('id', 'slug'),
+ true
+ )
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false) // Should not throw - has children
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(false) // Should not throw - has children
@@ -374,11 +443,11 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [
- { paramName: 'category' },
- { paramName: 'subcategory' },
- { paramName: 'item' },
- ])
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments('category', 'subcategory', 'item'),
+ true
+ )
// All except the last one should not throw on empty static shell
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
@@ -414,15 +483,126 @@ describe('assignErrorIfEmpty', () => {
},
]
- assignErrorIfEmpty(prerenderedRoutes, [
- { paramName: 'locale' },
- { paramName: 'segments' },
- ])
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments('locale', 'segments'),
+ true
+ )
// The route with more fallback params should not throw on empty static shell
expect(prerenderedRoutes[0].throwOnEmptyStaticShell).toBe(false)
expect(prerenderedRoutes[1].throwOnEmptyStaticShell).toBe(true)
})
+
+ it('should specialize only unresolved params backed by generateStaticParams', () => {
+ const prerenderedRoutes: PrerenderedRoute[] = [
+ {
+ params: {},
+ pathname: '/[one]/[two]',
+ encodedPathname: '/[one]/[two]',
+ fallbackRouteParams: [
+ {
+ paramName: 'one',
+ paramType: 'dynamic',
+ },
+ {
+ paramName: 'two',
+ paramType: 'dynamic',
+ },
+ ],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ {
+ params: { one: 'b' },
+ pathname: '/b/[two]',
+ encodedPathname: '/b/[two]',
+ fallbackRouteParams: [
+ {
+ paramName: 'two',
+ paramType: 'dynamic',
+ },
+ ],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ ]
+
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments(['one', true], 'two'),
+ true
+ )
+
+ expect(prerenderedRoutes[0].remainingPrerenderableParams).toEqual([
+ {
+ paramName: 'one',
+ paramType: 'dynamic',
+ },
+ ])
+ expect(prerenderedRoutes[1].remainingPrerenderableParams).toBeUndefined()
+ })
+
+ it('should stop specializing once it reaches a purely dynamic param', () => {
+ const prerenderedRoutes: PrerenderedRoute[] = [
+ {
+ params: {},
+ pathname: '/[one]/[two]/[three]',
+ encodedPathname: '/[one]/[two]/[three]',
+ fallbackRouteParams: [
+ {
+ paramName: 'one',
+ paramType: 'dynamic',
+ },
+ {
+ paramName: 'two',
+ paramType: 'dynamic',
+ },
+ {
+ paramName: 'three',
+ paramType: 'dynamic',
+ },
+ ],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ {
+ params: { one: 'a' },
+ pathname: '/a/[two]/[three]',
+ encodedPathname: '/a/[two]/[three]',
+ fallbackRouteParams: [
+ {
+ paramName: 'two',
+ paramType: 'dynamic',
+ },
+ {
+ paramName: 'three',
+ paramType: 'dynamic',
+ },
+ ],
+ fallbackMode: FallbackMode.NOT_FOUND,
+ fallbackRootParams: [],
+ throwOnEmptyStaticShell: true,
+ },
+ ]
+
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ pathnameSegments(['one', true], 'two', ['three', true]),
+ true
+ )
+
+ expect(prerenderedRoutes[0].remainingPrerenderableParams).toEqual([
+ {
+ paramName: 'one',
+ paramType: 'dynamic',
+ },
+ ])
+ expect(prerenderedRoutes[1].remainingPrerenderableParams).toBeUndefined()
+ })
})
describe('filterUniqueParams', () => {
diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts
index 726aaa5cb7886..2ea273d3c3fdf 100644
--- a/packages/next/src/build/static-paths/app.ts
+++ b/packages/next/src/build/static-paths/app.ts
@@ -406,22 +406,26 @@ interface TrieNode {
}
/**
- * Assigns the throwOnEmptyStaticShell property to each of the prerendered routes.
+ * Assigns static shell metadata to each prerendered route.
* This function uses a Trie data structure to efficiently determine whether each route
- * should throw an error when its static shell is empty.
+ * should throw an error when its static shell is empty and whether a fallback shell
+ * can still be completed into a more specific prerendered shell.
*
* A route should not throw on empty static shell if it has child routes in the Trie. For example,
* if we have two routes, `/blog/first-post` and `/blog/[slug]`, the route for
* `/blog/[slug]` should not throw because `/blog/first-post` is a more specific concrete route.
*
* @param prerenderedRoutes - The prerendered routes.
- * @param pathnameSegments - The keys of the route parameters.
+ * @param pathnameSegments - The pathname params and whether each one is still
+ * prerenderable via generateStaticParams.
*/
-export function assignErrorIfEmpty(
+export function assignStaticShellMetadata(
prerenderedRoutes: readonly PrerenderedRoute[],
pathnameSegments: ReadonlyArray<{
readonly paramName: string
- }>
+ readonly hasGenerateStaticParams: boolean
+ }>,
+ computeRemainingPrerenderableParams: boolean
): void {
// If there are no routes to process, exit early.
if (prerenderedRoutes.length === 0) {
@@ -470,7 +474,10 @@ export function assignErrorIfEmpty(
if (!childNode) {
// If the child node doesn't exist, create a new one and add it to
// the current node's children.
- childNode = { children: new Map(), routes: [] }
+ childNode = {
+ children: new Map(),
+ routes: [],
+ }
currentNode.children.set(valueKey, childNode)
}
// Move deeper into the Trie to the `childNode` for the next parameter.
@@ -537,6 +544,45 @@ export function assignErrorIfEmpty(
} else {
route.throwOnEmptyStaticShell = true // Should throw on empty static shell.
}
+
+ if (
+ computeRemainingPrerenderableParams &&
+ route.fallbackRouteParams &&
+ route.fallbackRouteParams.length > 0
+ ) {
+ const fallbackRouteParamsByName = new Map(
+ route.fallbackRouteParams.map((param) => [param.paramName, param])
+ )
+ const remainingPrerenderableParams: FallbackRouteParam[] = []
+
+ // Only unresolved pathname params that can still be filled by
+ // generateStaticParams belong here. Once we hit an unresolved param
+ // that is purely dynamic, the rest of the shell also stays dynamic
+ // and cannot be completed into a more specific prerendered shell.
+ for (const segment of pathnameSegments) {
+ if (route.params.hasOwnProperty(segment.paramName)) {
+ continue
+ }
+
+ if (!segment.hasGenerateStaticParams) {
+ break
+ }
+
+ const fallbackRouteParam = fallbackRouteParamsByName.get(
+ segment.paramName
+ )
+ if (!fallbackRouteParam) {
+ break
+ }
+
+ remainingPrerenderableParams.push(fallbackRouteParam)
+ }
+
+ route.remainingPrerenderableParams =
+ remainingPrerenderableParams.length > 0
+ ? remainingPrerenderableParams
+ : undefined
+ }
}
}
@@ -696,6 +742,7 @@ export async function buildAppStaticPaths({
nextConfigOutput,
ComponentMod,
isRoutePPREnabled = false,
+ partialFallbacksEnabled = false,
buildId,
rootParamKeys,
}: {
@@ -718,6 +765,7 @@ export async function buildAppStaticPaths({
nextConfigOutput: 'standalone' | 'export' | undefined
ComponentMod: AppPageModule | AppRouteModule
isRoutePPREnabled: boolean
+ partialFallbacksEnabled?: boolean
buildId: string
rootParamKeys: readonly string[]
}): Promise {
@@ -780,6 +828,18 @@ export async function buildAppStaticPaths({
store,
isRoutePPREnabled
)
+ const generatedParamNames = new Set()
+ for (const params of routeParams) {
+ for (const paramName of Object.keys(params)) {
+ generatedParamNames.add(paramName)
+ }
+ }
+ const prerenderablePathSegments = pathnameRouteParamSegments.map(
+ (segment) => ({
+ paramName: segment.paramName,
+ hasGenerateStaticParams: generatedParamNames.has(segment.paramName),
+ })
+ )
await afterRunner.executeAfter()
@@ -1000,7 +1060,11 @@ export async function buildAppStaticPaths({
// Now we have to set the throwOnEmptyStaticShell for each of the routes.
if (prerenderedRoutes && cacheComponents) {
- assignErrorIfEmpty(prerenderedRoutes, pathnameRouteParamSegments)
+ assignStaticShellMetadata(
+ prerenderedRoutes,
+ prerenderablePathSegments,
+ partialFallbacksEnabled
+ )
}
return { fallbackMode, prerenderedRoutes }
diff --git a/packages/next/src/build/static-paths/types.ts b/packages/next/src/build/static-paths/types.ts
index 0afe7a6b29a7a..93e2d8881c81f 100644
--- a/packages/next/src/build/static-paths/types.ts
+++ b/packages/next/src/build/static-paths/types.ts
@@ -9,6 +9,7 @@ type StaticPrerenderedRoute = {
readonly fallbackRouteParams: undefined
readonly fallbackMode: FallbackMode | undefined
readonly fallbackRootParams: undefined
+ remainingPrerenderableParams?: undefined
/**
* When enabled, the route will be rendered with diagnostics enabled which
@@ -42,6 +43,7 @@ type FallbackPrerenderedRoute = {
readonly fallbackRouteParams: readonly FallbackRouteParam[]
readonly fallbackMode: FallbackMode | undefined
readonly fallbackRootParams: readonly string[]
+ remainingPrerenderableParams?: readonly FallbackRouteParam[]
/**
* When enabled, the route will be rendered with diagnostics enabled which
diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts
index 8d8e5e1be79b5..b95e44f1e7033 100644
--- a/packages/next/src/build/templates/app-page.ts
+++ b/packages/next/src/build/templates/app-page.ts
@@ -1,5 +1,6 @@
import type { LoaderTree } from '../../server/lib/app-dir-module'
import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { FallbackRouteParam } from '../static-paths/types'
import {
AppPageRouteModule,
@@ -101,6 +102,10 @@ import { RedirectStatusCode } from '../../client/components/redirect-status-code
import { InvariantError } from '../../shared/lib/invariant-error'
import { scheduleOnNextTick } from '../../lib/scheduler'
import { isInterceptionRouteAppPath } from '../../shared/lib/router/utils/interception-routes'
+import {
+ getParamProperties,
+ getSegmentParam,
+} from '../../shared/lib/router/utils/get-segment-param'
export * from '../../server/app-render/entry-base' with { 'turbopack-transition': 'next-server-utility' }
@@ -122,6 +127,72 @@ export const routeModule = new AppPageRouteModule({
relativeProjectDir: process.env.__NEXT_RELATIVE_PROJECT_DIR || '',
})
+function buildDynamicSegmentPlaceholder(
+ param: Pick
+): string {
+ const { repeat, optional } = getParamProperties(param.paramType)
+
+ if (optional) {
+ return `[[...${param.paramName}]]`
+ }
+
+ if (repeat) {
+ return `[...${param.paramName}]`
+ }
+
+ return `[${param.paramName}]`
+}
+
+/**
+ * Builds the cache key for the most complete prerenderable shell we can derive
+ * from the shell that matched this request. Only params that can still be
+ * filled by `generateStaticParams` are substituted; fully dynamic params stay
+ * as placeholders so a request like `/c/foo` can complete `/[one]/[two]` into
+ * `/c/[two]` rather than `/c/foo`.
+ */
+function buildCompletedShellCacheKey(
+ fallbackPathname: string,
+ remainingPrerenderableParams: readonly FallbackRouteParam[],
+ params: Record | undefined
+): string {
+ const prerenderableParamsByName = new Map(
+ remainingPrerenderableParams.map((param) => [param.paramName, param])
+ )
+
+ return (
+ fallbackPathname
+ .split('/')
+ .map((segment) => {
+ const segmentParam = getSegmentParam(segment)
+ if (!segmentParam) {
+ return segment
+ }
+
+ const remainingParam = prerenderableParamsByName.get(
+ segmentParam.paramName
+ )
+ if (!remainingParam) {
+ return segment
+ }
+
+ const value = params?.[remainingParam.paramName]
+ if (!value) {
+ return segment
+ }
+
+ const encodedValue = Array.isArray(value)
+ ? value.map((item) => encodeURIComponent(item)).join('/')
+ : encodeURIComponent(value)
+
+ return segment.replace(
+ buildDynamicSegmentPlaceholder(remainingParam),
+ encodedValue
+ )
+ })
+ .join('/') || '/'
+ )
+}
+
export async function handler(
req: IncomingMessage,
res: ServerResponse,
@@ -199,12 +270,13 @@ export async function handler(
// treat the pathname as dynamic. Currently, there's a bug in the PPR
// implementation that incorrectly leaves %%drp placeholders in the output of
// parallel routes. This is addressed with cacheComponents.
- const prerenderInfo =
+ const prerenderMatch =
nextConfig.experimental.ppr &&
!nextConfig.cacheComponents &&
isInterceptionRouteAppPath(resolvedPathname)
? null
: routeModule.match(resolvedPathname, prerenderManifest)
+ const prerenderInfo = prerenderMatch?.route ?? null
const isPrerendered = !!prerenderManifest.routes[resolvedPathname]
@@ -475,6 +547,11 @@ export async function handler(
// When bots request PPR page, perform the full dynamic rendering.
// This applies to both DOM bots (like Googlebot) and HTML-limited bots.
const shouldWaitOnAllReady = Boolean(botType) && isRoutePPREnabled
+ const remainingPrerenderableParams =
+ prerenderInfo?.remainingPrerenderableParams ?? []
+ const hasUnresolvedRootFallbackParams =
+ prerenderInfo?.fallback === null &&
+ (prerenderInfo.fallbackRootParams?.length ?? 0) > 0
let ssgCacheKey: string | null = null
if (
@@ -485,15 +562,53 @@ export async function handler(
!minimalPostponed &&
!isDynamicRSCRequest
) {
- ssgCacheKey = resolvedPathname
+ // For normal SSG routes we cache by the fully resolved pathname. For
+ // partial fallbacks we instead derive the cache key from the shell
+ // that matched this request so `/prefix/[one]/[two]` can specialize into
+ // `/prefix/c/[two]` without promoting all the way to `/prefix/c/foo`.
+ const fallbackPathname = prerenderMatch
+ ? typeof prerenderInfo?.fallback === 'string'
+ ? prerenderInfo.fallback
+ : prerenderMatch.source
+ : null
+
+ if (
+ nextConfig.experimental.partialFallbacks === true &&
+ fallbackPathname &&
+ prerenderInfo?.fallbackRouteParams &&
+ !hasUnresolvedRootFallbackParams
+ ) {
+ if (remainingPrerenderableParams.length > 0) {
+ const completedShellCacheKey = buildCompletedShellCacheKey(
+ fallbackPathname,
+ remainingPrerenderableParams,
+ params
+ )
+
+ // If applying the current request params doesn't make the shell any
+ // more complete, then this shell is already at its most complete
+ // form and should remain shared rather than creating a new cache entry.
+ ssgCacheKey =
+ completedShellCacheKey !== fallbackPathname
+ ? completedShellCacheKey
+ : null
+ }
+ } else {
+ ssgCacheKey = resolvedPathname
+ }
}
// the staticPathKey differs from ssgCacheKey since
// ssgCacheKey is null in dev since we're always in "dynamic"
- // mode in dev to bypass the cache, but we still need to honor
- // dynamicParams = false in dev mode
+ // mode in dev to bypass the cache. It can also be null for partial
+ // fallback shells that should remain shared and must not create a
+ // param-specific ISR entry, but we still need to honor fallback handling.
let staticPathKey = ssgCacheKey
- if (!staticPathKey && routeModule.isDev) {
+ if (
+ !staticPathKey &&
+ (routeModule.isDev ||
+ (isSSG && pageIsDynamic && prerenderInfo?.fallbackRouteParams))
+ ) {
staticPathKey = resolvedPathname
}
@@ -535,6 +650,17 @@ export async function handler(
const isWrappedByNextServer = Boolean(
routerServerContext?.isWrappedByNextServer
)
+ const remainingFallbackRouteParams =
+ nextConfig.experimental.partialFallbacks === true &&
+ remainingPrerenderableParams.length > 0
+ ? (prerenderInfo?.fallbackRouteParams?.filter(
+ (param) =>
+ !remainingPrerenderableParams.some(
+ (prerenderableParam) =>
+ prerenderableParam.paramName === param.paramName
+ )
+ ) ?? [])
+ : []
const render404 = async () => {
// TODO: should route-module itself handle rendering the 404
@@ -875,6 +1001,22 @@ export async function handler(
fallbackMode = parseFallbackField(prerenderInfo.fallback)
}
+ if (
+ nextConfig.experimental.partialFallbacks === true &&
+ prerenderInfo?.fallback === null &&
+ !hasUnresolvedRootFallbackParams &&
+ remainingPrerenderableParams.length > 0
+ ) {
+ // Generic source shells without unresolved root params don't have a
+ // concrete fallback file of their own, so they're marked as blocking.
+ // When we can complete the shell into a more specific
+ // prerendered shell for this request, treat it like a prerender
+ // fallback so we can serve that shell instead of blocking on the full
+ // route. Root-param shells stay blocking, since unknown root branches
+ // should not inherit a shell from another generated branch.
+ fallbackMode = FallbackMode.PRERENDER
+ }
+
// When serving a HTML bot request, we want to serve a blocking render and
// not the prerendered page. This ensures that the correct content is served
// to the bot in the head.
@@ -977,8 +1119,12 @@ export async function handler(
// We pass `undefined` as rendering a fallback isn't resumed
// here.
postponed: undefined,
+ // Always serve the shell that matched this request
+ // immediately. If there are still prerenderable params left,
+ // the background path below will complete the shell into a
+ // more specific cache entry for later requests.
fallbackRouteParams,
- forceStaticRender: false,
+ forceStaticRender: true,
}),
waitUntil: ctx.waitUntil,
isMinimalMode,
@@ -992,6 +1138,9 @@ export async function handler(
if (
!isMinimalMode &&
isRoutePPREnabled &&
+ // Match the build-time contract: only fallback shells that can
+ // still be completed with prerenderable params should upgrade.
+ remainingPrerenderableParams.length > 0 &&
nextConfig.experimental.partialFallbacks === true &&
ssgCacheKey &&
incrementalCache &&
@@ -1013,6 +1162,11 @@ export async function handler(
const responseCache = routeModule.getResponseCache(req)
try {
+ // Only the params that were just specialized should be
+ // removed from the fallback render. Any remaining fallback
+ // params stay deferred so the revalidated result is a more
+ // specific shell (e.g. `/prefix/c/[two]`), not a fully
+ // concrete route (`/prefix/c/foo`).
await responseCache.revalidate(
ssgCacheKey,
incrementalCache,
@@ -1021,9 +1175,13 @@ export async function handler(
(c) => {
return doRender({
span: c.span,
- // Route shell render should not use the fallback params.
postponed: undefined,
- fallbackRouteParams: null,
+ fallbackRouteParams:
+ remainingFallbackRouteParams.length > 0
+ ? createOpaqueFallbackRouteParams(
+ remainingFallbackRouteParams
+ )
+ : null,
forceStaticRender: true,
})
},
diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts
index c0b19a54db8a9..359e223888130 100644
--- a/packages/next/src/build/utils.ts
+++ b/packages/next/src/build/utils.ts
@@ -162,7 +162,7 @@ export function isInstrumentationHookFilename(file?: string | null) {
)
}
-const filterAndSortList = (
+export const filterAndSortList = (
list: ReadonlyArray,
routeType: ROUTER_TYPE,
hasCustomApp: boolean
@@ -667,6 +667,7 @@ export async function isPageStatic({
cacheHandlers,
cacheLifeProfiles,
pprConfig,
+ partialFallbacksEnabled,
buildId,
clientAssetToken,
sriEnabled,
@@ -694,6 +695,7 @@ export async function isPageStatic({
}
nextConfigOutput: 'standalone' | 'export' | undefined
pprConfig: ExperimentalPPRConfig | undefined
+ partialFallbacksEnabled: boolean
buildId: string
clientAssetToken: string
sriEnabled: boolean
@@ -857,6 +859,7 @@ export async function isPageStatic({
ComponentMod,
nextConfigOutput,
isRoutePPREnabled,
+ partialFallbacksEnabled,
buildId,
rootParamKeys,
}))
diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts
index ae3c99723cfd4..2de5a1d2612c2 100644
--- a/packages/next/src/server/dev/next-dev-server.ts
+++ b/packages/next/src/server/dev/next-dev-server.ts
@@ -799,6 +799,8 @@ export default class DevServer extends Server {
pathname,
config: {
pprConfig: this.nextConfig.experimental.ppr,
+ partialFallbacks:
+ this.nextConfig.experimental.partialFallbacks === true,
configFileName,
cacheComponents: Boolean(this.nextConfig.cacheComponents),
},
diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts
index 67a65cee0c4e1..aa13b89c4d938 100644
--- a/packages/next/src/server/dev/static-paths-worker.ts
+++ b/packages/next/src/server/dev/static-paths-worker.ts
@@ -30,6 +30,7 @@ import { parseAppRoute } from '../../shared/lib/router/routes/app'
type RuntimeConfig = {
pprConfig: ExperimentalPPRConfig | undefined
+ partialFallbacks: boolean
configFileName: string
cacheComponents: boolean
}
@@ -147,6 +148,7 @@ export async function loadStaticPaths({
ComponentMod: components.ComponentMod,
nextConfigOutput,
isRoutePPREnabled,
+ partialFallbacksEnabled: config.partialFallbacks,
buildId,
authInterrupts,
rootParamKeys,
diff --git a/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.test.ts b/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.test.ts
index 92a1849d625f8..a2f8a8931892a 100644
--- a/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.test.ts
+++ b/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.test.ts
@@ -72,7 +72,10 @@ describe('PrerenderManifestMatcher', () => {
const result = matcher.match('/products/123')
- expect(result).toBe(specificRoute)
+ expect(result).toEqual({
+ source: '/products/[id]',
+ route: specificRoute,
+ })
})
it('should handle when the fallbackSourceRoute is not set', () => {
@@ -88,7 +91,59 @@ describe('PrerenderManifestMatcher', () => {
const result = matcher.match('/products/123')
- expect(result).toBe(route)
+ expect(result).toEqual({
+ source: '/products/[id]',
+ route,
+ })
+ })
+
+ it('should match unknown root branches against the generic source shell', () => {
+ const generatedRootRoute = createMockDynamicRoute({
+ fallbackSourceRoute: '/root-gsp/[lang]/[slug]',
+ routeRegex: '^/root-gsp/en/([^/]+?)(?:/)?$',
+ fallbackRootParams: [],
+ fallbackRouteParams: [
+ {
+ paramName: 'slug',
+ paramType: 'dynamic',
+ },
+ ],
+ })
+
+ const genericRootRoute = createMockDynamicRoute({
+ fallbackSourceRoute: '/root-gsp/[lang]/[slug]',
+ routeRegex: '^/root-gsp/([^/]+?)/([^/]+?)(?:/)?$',
+ fallbackRootParams: ['lang'],
+ fallbackRouteParams: [
+ {
+ paramName: 'lang',
+ paramType: 'dynamic',
+ },
+ {
+ paramName: 'slug',
+ paramType: 'dynamic',
+ },
+ ],
+ })
+
+ const manifest = createMockPrerenderManifest({
+ '/root-gsp/en/[slug]': generatedRootRoute,
+ '/root-gsp/[lang]/[slug]': genericRootRoute,
+ })
+
+ const matcher = new PrerenderManifestMatcher(
+ '/root-gsp/[lang]/[slug]',
+ manifest
+ )
+
+ expect(matcher.match('/root-gsp/en/two')).toEqual({
+ source: '/root-gsp/en/[slug]',
+ route: generatedRootRoute,
+ })
+ expect(matcher.match('/root-gsp/fr/two')).toEqual({
+ source: '/root-gsp/[lang]/[slug]',
+ route: genericRootRoute,
+ })
})
})
diff --git a/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.ts b/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.ts
index 3ba42deae3b6b..959a522507353 100644
--- a/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.ts
+++ b/packages/next/src/server/route-modules/app-page/helpers/prerender-manifest-matcher.ts
@@ -30,6 +30,11 @@ type Matcher = {
route: DeepReadonly
}
+export type PrerenderManifestMatch = {
+ source: string
+ route: DeepReadonly
+}
+
/**
* A matcher for the prerender manifest.
*
@@ -57,9 +62,7 @@ export class PrerenderManifestMatcher {
* @param pathname - The pathname to match.
* @returns The dynamic route that matches the pathname.
*/
- public match(
- pathname: string
- ): DeepReadonly | null {
+ public match(pathname: string): PrerenderManifestMatch | null {
// Iterate over the matchers. They're already in the correct order of
// specificity as they were inserted into the prerender manifest that way
// and iterating over them with Object.entries guarantees that.
@@ -71,7 +74,10 @@ export class PrerenderManifestMatcher {
const match = matcher.matcher(pathname)
if (match) {
- return matcher.route
+ return {
+ source: matcher.source,
+ route: matcher.route,
+ }
}
}
diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json
index 428f7cf5bc0a3..d80f114545b81 100644
--- a/packages/react-refresh-utils/package.json
+++ b/packages/react-refresh-utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/react-refresh-utils",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"description": "An experimental package providing utilities for React Refresh.",
"repository": {
"url": "vercel/next.js",
diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json
index d74ee5ff41c6e..4bcfd498f8498 100644
--- a/packages/third-parties/package.json
+++ b/packages/third-parties/package.json
@@ -1,6 +1,6 @@
{
"name": "@next/third-parties",
- "version": "16.2.0-canary.96",
+ "version": "16.2.0-canary.97",
"repository": {
"url": "vercel/next.js",
"directory": "packages/third-parties"
@@ -26,7 +26,7 @@
"third-party-capital": "1.0.20"
},
"devDependencies": {
- "next": "16.2.0-canary.96",
+ "next": "16.2.0-canary.97",
"outdent": "0.8.0",
"prettier": "2.5.1",
"typescript": "5.9.2"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a2ebf7fce621f..6dc98490cf3f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1017,7 +1017,7 @@ importers:
packages/eslint-config-next:
dependencies:
'@next/eslint-plugin-next':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../eslint-plugin-next
eslint:
specifier: '>=9.0.0'
@@ -1094,7 +1094,7 @@ importers:
packages/next:
dependencies:
'@next/env':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../next-env
'@swc/helpers':
specifier: 0.5.15
@@ -1219,19 +1219,19 @@ importers:
specifier: 1.2.0
version: 1.2.0
'@next/font':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../font
'@next/polyfill-module':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../next-polyfill-module
'@next/polyfill-nomodule':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../next-polyfill-nomodule
'@next/react-refresh-utils':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../react-refresh-utils
'@next/swc':
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../next-swc
'@opentelemetry/api':
specifier: 1.6.0
@@ -1956,7 +1956,7 @@ importers:
version: 1.0.20
devDependencies:
next:
- specifier: 16.2.0-canary.96
+ specifier: 16.2.0-canary.97
version: link:../next
outdent:
specifier: 0.8.0
diff --git a/scripts/LaunchAgents/build.turbo.compress.plist b/scripts/LaunchAgents/build.turbo.compress.plist
deleted file mode 100644
index 29af95c7ee0a2..0000000000000
--- a/scripts/LaunchAgents/build.turbo.compress.plist
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
- Label
- build.turbo.compress
-
- StandardOutPath
- /tmp/build.turbo.compress/log
- StandardErrorPath
- /tmp/build/turbo.compress/error
-
- EnvironmentVariables
-
- PATH
- /opt/homebrew/bin:/opt/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
-
-
- ProgramArguments
-
- /bin/sh
- -c
- cd $(defaults read build.turbo.repopath -string) && ./scripts/macos-compress.sh
-
-
- StartInterval
- 10800
-
-
diff --git a/scripts/LaunchAgents/install-macos-agents.sh b/scripts/LaunchAgents/install-macos-agents.sh
deleted file mode 100755
index edd9bb6b1dc07..0000000000000
--- a/scripts/LaunchAgents/install-macos-agents.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env bash
-#
-# Optionally automates compressing target/ and other directories.
-# Only intended for and needed on macOS.
-
-set -e
-
-SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
-NEXT_DIR=$(realpath "$SCRIPT_DIR/../..")
-
-defaults write build.turbo.repopath -string "$NEXT_DIR"
-
-for filename in "$SCRIPT_DIR"/*.plist; do
- PLIST_FILE="$HOME/Library/LaunchAgents/$(basename "$filename")"
- ln -sf "$filename" "$PLIST_FILE"
- launchctl unload "$PLIST_FILE" || true
- launchctl load "$PLIST_FILE"
-done
diff --git a/scripts/macos-compress.sh b/scripts/macos-compress.sh
deleted file mode 100755
index 90c14bb30615d..0000000000000
--- a/scripts/macos-compress.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/usr/bin/env bash
-#
-# Optionally automates compressing target/ and other directories.
-# Only intended for and needed on macOS.
-#
-
-set -e
-
-SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
-NEXT_DIR=$(realpath "$SCRIPT_DIR/..")
-
-# Basic timestamp prefix for logging
-PS4='+ $(date "+%Y-%m-%d +%H:%M:%S") '
-
-set -x
-
-if ! command -v afsctool &> /dev/null; then
- echo "afsctool is required. Install it with 'brew install afsctool'."
- exit 1
-fi
-
-afsctool -c "$NEXT_DIR/target" "$NEXT_DIR/node_modules"
diff --git a/test/e2e/app-dir-export/test/utils.ts b/test/e2e/app-dir-export/test/utils.ts
index 30494b1335f5d..35ceb5803ecbc 100644
--- a/test/e2e/app-dir-export/test/utils.ts
+++ b/test/e2e/app-dir-export/test/utils.ts
@@ -30,9 +30,13 @@ export const expectedWhenTrailingSlashTrue = [
// Turbopack and plain next.js have different hash output for the file name
// Turbopack will output favicon in the _next/static/media folder
...(process.env.IS_TURBOPACK_TEST
- ? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)]
+ ? [
+ expect.stringMatching(
+ /_next\/static\/media\/favicon\.[0-9a-z_.~-]+\.ico/
+ ),
+ ]
: []),
- expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/),
+ expect.stringMatching(/_next\/static\/media\/test\.[0-9a-z_.~-]+\.png/),
expect.stringMatching(/_next\/static\/[A-Za-z0-9_-]+\/_buildManifest.js/),
...(process.env.IS_TURBOPACK_TEST
? [
@@ -109,9 +113,13 @@ const expectedWhenTrailingSlashFalse = [
'__next._tree.txt',
// Turbopack will output favicon in the _next/static/media folder
...(process.env.IS_TURBOPACK_TEST
- ? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)]
+ ? [
+ expect.stringMatching(
+ /_next\/static\/media\/favicon\.[0-9a-z_.~-]+\.ico/
+ ),
+ ]
: []),
- expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/),
+ expect.stringMatching(/_next\/static\/media\/test\.[0-9a-z_.~-]+\.png/),
expect.stringMatching(/_next\/static\/[A-Za-z0-9_-]+\/_buildManifest.js/),
...(process.env.IS_TURBOPACK_TEST
? [
diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts
index 706bb84c57fd3..71d200f3f5298 100644
--- a/test/e2e/app-dir/app-static/app-static.test.ts
+++ b/test/e2e/app-dir/app-static/app-static.test.ts
@@ -2873,6 +2873,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/articles\\/([^\\/]+?)(?:\\/)?$",
@@ -2900,6 +2901,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": false,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/blog\\/([^\\/]+?)(?:\\/)?$",
@@ -2927,6 +2929,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$",
@@ -2954,6 +2957,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/dynamic\\-error\\/([^\\/]+?)(?:\\/)?$",
@@ -2981,6 +2985,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/force\\-static\\/([^\\/]+?)(?:\\/)?$",
@@ -3008,6 +3013,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": false,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/gen\\-params\\-catch\\-all\\-unique\\/(.+?)(?:\\/)?$",
@@ -3035,6 +3041,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/gen\\-params\\-dynamic\\-revalidate\\/([^\\/]+?)(?:\\/)?$",
@@ -3062,6 +3069,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/hooks\\/use\\-pathname\\/([^\\/]+?)(?:\\/)?$",
@@ -3089,6 +3097,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": false,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-lang\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$",
@@ -3116,6 +3125,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": false,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-slug\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$",
@@ -3143,6 +3153,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": false,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/partial\\-params\\-false\\/([^\\/]+?)\\/static(?:\\/)?$",
@@ -3170,6 +3181,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/prerendered\\-not\\-found\\/([^\\/]+?)(?:\\/)?$",
@@ -3197,6 +3209,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/ssg\\-draft\\-mode(?:\\/(.+?))?(?:\\/)?$",
@@ -3224,6 +3237,7 @@ describe('app-dir static/dynamic handling', () => {
},
],
"fallback": null,
+ "fallbackRootParams": [],
"fallbackRouteParams": [],
"prefetchDataRoute": null,
"routeRegex": "^\\/static\\-to\\-dynamic\\-error\\-forced\\/([^\\/]+?)(?:\\/)?$",
diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts
index 38126809adb3a..139740122cb9f 100644
--- a/test/e2e/app-dir/app/index.test.ts
+++ b/test/e2e/app-dir/app/index.test.ts
@@ -451,7 +451,7 @@ describe('app dir - basic', () => {
const html = await next.render('/dashboard/index')
expect(html).toMatch(
process.env.IS_TURBOPACK_TEST
- ? /