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 - ? /