From 0247e34bf9301c4ef7d14b6913210df137da1b05 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Mon, 24 Aug 2026 10:41:34 +0200
Subject: [PATCH 01/15] [test] Capture the dynamic routes a build passes to an
adapter (#97719)
We are about to reduce the number of these entries, and each of those
changes should arrive with a diff that shows which ones it removes or
merges. That is what these fixtures record. The existing behavioral
suites stay responsible for regressions, and they do detect a routing
change of this kind, e.g. a build that dropped the `.rsc` entries of
fallback shells would fail a root param case in
`segment-cache/prefetch-app-shell`. These snapshots add the part those
suites may not distinguish, since an entry that collapses into a less
specific match can still serve a response that renders the same content.
One fixture covers Cache Components with root params, the shape that
grows with the number of root param combinations. The other covers what
does not depend on Cache Components: the `.rsc` and plain entry pair
that every dynamic app page receives, a route handler, pages that share
one shape and differ in a static last segment, and a `fallback: false`
pages router route whose plain entry carries a preview bypass condition
that its `.rsc` sibling does not. It also holds a proxy next to static
pages router pages, which adds one entry per page and is the only case
here whose count grows with the number of pages rather than with the
number of route shapes. A third test builds the first fixture again
under a base path and asserts the prefix on every entry.
The projection stays narrow so that unrelated build output leaves it
alone. Both fixtures pin `cacheComponents` and `generateBuildId`,
because CI would otherwise vary them.
---
test/cache-components-tests-manifest.json | 1 +
.../app/[lang]/[slug]/page.tsx | 13 ++
.../app/[lang]/fallback-shell/[slug]/page.tsx | 19 +++
.../cache-components/app/[lang]/layout.tsx | 17 ++
.../cache-components/app/[lang]/page.tsx | 3 +
.../cache-components/app/[lang]/ppr/page.tsx | 12 ++
.../app/[lang]/static/page.tsx | 3 +
.../cache-components/my-adapter.mjs | 12 ++
.../cache-components/next.config.js | 19 +++
.../dynamic-routes-base-path.test.ts | 46 ++++++
.../dynamic-routes-cache-components.test.ts | 155 ++++++++++++++++++
.../dynamic-routes-legacy.test.ts | 100 +++++++++++
.../dynamic-routes-snapshot.ts | 55 +++++++
.../legacy/app/api/data/route.ts | 3 +
.../legacy/app/blog/[slug]/page.tsx | 13 ++
.../legacy/app/docs/[lang]/accounts/page.tsx | 3 +
.../legacy/app/docs/[lang]/functions/page.tsx | 3 +
.../legacy/app/docs/[lang]/guide/page.tsx | 3 +
.../legacy/app/layout.tsx | 7 +
.../legacy/my-adapter.mjs | 12 ++
.../legacy/next.config.js | 16 ++
.../legacy/pages/legacy/[id].tsx | 13 ++
.../legacy/pages/static-one.tsx | 3 +
.../legacy/pages/static-two.tsx | 3 +
.../adapter-dynamic-routes/legacy/proxy.ts | 5 +
25 files changed, 539 insertions(+)
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs
create mode 100644 test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts
diff --git a/test/cache-components-tests-manifest.json b/test/cache-components-tests-manifest.json
index b4a73639e014..ea7094ef1f5d 100644
--- a/test/cache-components-tests-manifest.json
+++ b/test/cache-components-tests-manifest.json
@@ -349,6 +349,7 @@
"test/production/app-dir/actions-tree-shaking/reexport/reexport.test.ts",
"test/production/app-dir/actions-tree-shaking/shared-module-actions/shared-module-actions-edge.test.ts",
"test/production/app-dir/actions-tree-shaking/use-effect-actions/use-effect-actions-edge.test.ts",
+ "test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts",
"test/production/app-dir/app-fetch-build-cache/app-fetch-build-cache.test.ts",
"test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts",
"test/production/app-dir/build-output/index.test.ts",
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx
new file mode 100644
index 000000000000..09251644c65a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/[slug]/page.tsx
@@ -0,0 +1,13 @@
+export function generateStaticParams() {
+ return [{ slug: 'one' }]
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+
+ return {slug}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx
new file mode 100644
index 000000000000..0155d589a8ca
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/fallback-shell/[slug]/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+
+export function generateStaticParams() {
+ return [{ slug: 'two' }]
+}
+
+export default function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ return (
+ loading
}>
+ {params.then(({ slug }) => (
+ {slug}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx
new file mode 100644
index 000000000000..98294175deb0
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/layout.tsx
@@ -0,0 +1,17 @@
+import { lang } from 'next/root-params'
+
+export function generateStaticParams() {
+ return [{ lang: 'en' }, { lang: 'de' }]
+}
+
+export default async function Root({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx
new file mode 100644
index 000000000000..ff7159d9149f
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return hello world
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx
new file mode 100644
index 000000000000..ad313fcdb6da
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/ppr/page.tsx
@@ -0,0 +1,12 @@
+import { cookies } from 'next/headers'
+import { Suspense } from 'react'
+
+export default function Page() {
+ return (
+ loading}>
+ {cookies().then(() => (
+ ppr
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx
new file mode 100644
index 000000000000..6c4b84538ac5
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/app/[lang]/static/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs
new file mode 100644
index 000000000000..0aa9bfb874ae
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/my-adapter.mjs
@@ -0,0 +1,12 @@
+import fs from 'fs/promises'
+
+/** @type {import('next').NextAdapter} */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js b/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js
new file mode 100644
index 000000000000..aff9b9b0b3ab
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/cache-components/next.config.js
@@ -0,0 +1,19 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // A build ID that reaches an entry changes the snapshot on every run. A
+ // fixed build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+// `dynamic-routes-base-path.test.ts` sets this variable and builds the fixture a
+// second time under a base path. The value arrives through `nextTestSetup`'s
+// `env`, so it reaches a local build and a deployed build alike.
+if (process.env.BASE_PATH) {
+ nextConfig.basePath = process.env.BASE_PATH
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
new file mode 100644
index 000000000000..d36514494746
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
@@ -0,0 +1,46 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import { type AdapterRouting } from './dynamic-routes-snapshot'
+
+const basePath = '/base'
+
+// This suite builds the Cache Components fixture a second time under a base
+// path.
+//
+// A base path belongs to the request, not to the route. The build writes
+// artifacts under the route's own path. The adapter prefixes the entries that
+// match incoming requests.
+//
+// A collapse rewrites the source regex and the destination of an entry, and
+// both carry the prefix. A collapse can therefore drop the prefix or add it
+// twice. This suite asserts the prefix as a property of every entry. It does
+// not snapshot the table a second time.
+describe(`adapter dynamic routes (cache components, base path ${basePath})`, () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'cache-components'),
+ env: { BASE_PATH: basePath },
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // A build ID that reaches an entry then changes the assertions on every
+ // run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('prefixes every entry with the base path', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ // A base path prefixes the entries. It does not add or remove any.
+ expect(routing.dynamicRoutes).toHaveLength(27)
+
+ for (const route of routing.dynamicRoutes) {
+ expect(route.sourceRegex.startsWith(`^${basePath}`)).toBe(true)
+ expect(route.destination.startsWith(`${basePath}/`)).toBe(true)
+
+ // The prefix appears exactly once. An entry that carries the prefix
+ // twice still starts with it, so a check on the start alone accepts
+ // that entry. The two occurrences also do not have to be adjacent.
+ expect(route.sourceRegex).toIncludeRepeated(basePath, 1)
+ expect(route.destination).toIncludeRepeated(basePath, 1)
+ }
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
new file mode 100644
index 000000000000..78e76b8e60e6
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -0,0 +1,155 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes that a build passes to an adapter for
+// a Cache Components app. The root layout of the fixture returns two root
+// params.
+//
+// Each entry in the snapshot becomes one route in the routes document of a
+// deployment. The snapshot covers the routes that this app shape contributes.
+// A deployment also carries a fixed set of adapter routes, and it carries one
+// route for each rewrite that the app config declares.
+//
+// A route without a dynamic segment contributes no entry. A request for that
+// route matches an output during the filesystem check, so it needs no
+// rewrite.
+//
+// The fixture holds the shape that grows with the number of root param
+// combinations. `generateStaticParams` on the root layout produces one
+// fallback shell for each combination. Each manifest entry then produces
+// three adapter entries:
+//
+// - A dedicated segment route.
+// - An `.rsc` route.
+// - A plain route.
+describe('adapter dynamic routes (cache components)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'cache-components'),
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // A build ID that reaches an entry then changes the assertions on every
+ // run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "27 entries
+
+ /[lang]
+ ^[/]?/(?[^/]+?)\\.segments/\\$d\\$lang(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /[lang].segments/$d$lang$segment?nxtPlang=$nxtPlang
+
+ /de/fallback-shell/[slug]
+ ^[/]?/de/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /de/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPslug=$nxtPslug
+
+ /en/fallback-shell/[slug]
+ ^[/]?/en/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /en/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPslug=$nxtPslug
+
+ /[lang]/fallback-shell/[slug]
+ ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang]/ppr
+ ^[/]?/(?[^/]+?)/ppr\\.segments/\\$d\\$lang/ppr(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/ppr.segments/$d$lang/ppr$segment?nxtPlang=$nxtPlang
+
+ /[lang]/static
+ ^[/]?/(?[^/]+?)/static\\.segments/\\$d\\$lang/static(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/static.segments/$d$lang/static$segment?nxtPlang=$nxtPlang
+
+ /de/[slug]
+ ^[/]?/de/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /de/[slug].segments/$d$lang/$d$slug$segment?nxtPslug=$nxtPslug
+
+ /en/[slug]
+ ^[/]?/en/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /en/[slug].segments/$d$lang/$d$slug$segment?nxtPslug=$nxtPslug
+
+ /[lang]/[slug]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/[slug].segments/$d$lang/$d$slug$segment?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang].rsc
+ ^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /[lang]$rscSuffix?nxtPlang=$nxtPlang
+
+ /[lang]
+ ^[/]?/(?[^/]+?)(?:/)?$
+ -> /[lang]?nxtPlang=$nxtPlang
+
+ /de/fallback-shell/[slug].rsc
+ ^[/]?/de/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /de/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /de/fallback-shell/[slug]
+ ^[/]?/de/fallback\\-shell/(?[^/]+?)(?:/)?$
+ -> /de/fallback-shell/[slug]?nxtPslug=$nxtPslug
+
+ /en/fallback-shell/[slug].rsc
+ ^[/]?/en/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /en/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /en/fallback-shell/[slug]
+ ^[/]?/en/fallback\\-shell/(?[^/]+?)(?:/)?$
+ -> /en/fallback-shell/[slug]?nxtPslug=$nxtPslug
+
+ /[lang]/fallback-shell/[slug].rsc
+ ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/fallback-shell/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang]/fallback-shell/[slug]
+ ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?:/)?$
+ -> /[lang]/fallback-shell/[slug]?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang]/ppr.rsc
+ ^[/]?/(?[^/]+?)/ppr(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/ppr$rscSuffix?nxtPlang=$nxtPlang
+
+ /[lang]/ppr
+ ^[/]?/(?[^/]+?)/ppr(?:/)?$
+ -> /[lang]/ppr?nxtPlang=$nxtPlang
+
+ /[lang]/static.rsc
+ ^[/]?/(?[^/]+?)/static(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/static$rscSuffix?nxtPlang=$nxtPlang
+
+ /[lang]/static
+ ^[/]?/(?[^/]+?)/static(?:/)?$
+ -> /[lang]/static?nxtPlang=$nxtPlang
+
+ /de/[slug].rsc
+ ^[/]?/de/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /de/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /de/[slug]
+ ^[/]?/de/(?[^/]+?)(?:/)?$
+ -> /de/[slug]?nxtPslug=$nxtPslug
+
+ /en/[slug].rsc
+ ^[/]?/en/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /en/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /en/[slug]
+ ^[/]?/en/(?[^/]+?)(?:/)?$
+ -> /en/[slug]?nxtPslug=$nxtPslug
+
+ /[lang]/[slug].rsc
+ ^[/]?/(?[^/]+?)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /[lang]/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+
+ /[lang]/[slug]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)(?:/)?$
+ -> /[lang]/[slug]?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
new file mode 100644
index 000000000000..9370ded958ce
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
@@ -0,0 +1,100 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes that a build passes to an adapter for
+// an app router project without Cache Components. The fixture also holds one
+// pages router route.
+//
+// The fixture covers the parts of this output that do not depend on Cache
+// Components:
+//
+// - The `.rsc` entry and the plain entry that each dynamic app page receives.
+// - The entries that a route handler receives.
+// - Several pages that share one shape and differ in a static last segment.
+// - A pages router route that sets `fallback: false`.
+// - Two static pages router pages, next to a proxy.
+//
+// The `fallback: false` route decides whether a merge is possible. Its plain
+// entry carries a preview bypass condition. Its `.rsc` entry does not carry
+// that condition.
+//
+// A proxy next to a pages router adds one entry for each static pages router
+// page. That entry maps the `_next/data` URL of the page to the page itself.
+// The count of those entries grows with the number of pages rather than with
+// the number of route shapes.
+describe('adapter dynamic routes (legacy)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'legacy'),
+ // The fixture sets `generateBuildId`, and this option lets that value
+ // take effect. The harness otherwise assigns a new build ID for each run.
+ // The source regex of a pages router data route holds the build ID.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "13 entries
+
+ /legacy/[id]
+ ^/_next/data/test\\-build\\-id[/]?/legacy/(?[^/]+?)\\.json(?:/)?$
+ -> /_next/data/test-build-id/legacy/[id].json?nxtPid=$nxtPid
+ [has cookie __prerender_bypass, has cookie __next_preview_data]
+
+ /static-one
+ ^/_next/data/test\\-build\\-id[/]?/static\\-one\\.json(?:/)?$
+ -> /static-one
+
+ /static-two
+ ^/_next/data/test\\-build\\-id[/]?/static\\-two\\.json(?:/)?$
+ -> /static-two
+
+ /blog/[slug].rsc
+ ^[/]?/blog/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /blog/[slug]$rscSuffix?nxtPslug=$nxtPslug
+
+ /blog/[slug]
+ ^[/]?/blog/(?[^/]+?)(?:/)?$
+ -> /blog/[slug]?nxtPslug=$nxtPslug
+
+ /docs/[lang]/accounts.rsc
+ ^[/]?/docs/(?[^/]+?)/accounts(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /docs/[lang]/accounts$rscSuffix?nxtPlang=$nxtPlang
+
+ /docs/[lang]/accounts
+ ^[/]?/docs/(?[^/]+?)/accounts(?:/)?$
+ -> /docs/[lang]/accounts?nxtPlang=$nxtPlang
+
+ /docs/[lang]/functions.rsc
+ ^[/]?/docs/(?[^/]+?)/functions(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /docs/[lang]/functions$rscSuffix?nxtPlang=$nxtPlang
+
+ /docs/[lang]/functions
+ ^[/]?/docs/(?[^/]+?)/functions(?:/)?$
+ -> /docs/[lang]/functions?nxtPlang=$nxtPlang
+
+ /docs/[lang]/guide.rsc
+ ^[/]?/docs/(?[^/]+?)/guide(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /docs/[lang]/guide$rscSuffix?nxtPlang=$nxtPlang
+
+ /docs/[lang]/guide
+ ^[/]?/docs/(?[^/]+?)/guide(?:/)?$
+ -> /docs/[lang]/guide?nxtPlang=$nxtPlang
+
+ /legacy/[id].rsc
+ ^[/]?/legacy/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
+ -> /legacy/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /legacy/[id]
+ ^[/]?/legacy/(?[^/]+?)(?:/)?$
+ -> /legacy/[id]?nxtPid=$nxtPid
+ [has cookie __prerender_bypass, has cookie __next_preview_data]"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts
new file mode 100644
index 000000000000..df662333a150
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-snapshot.ts
@@ -0,0 +1,55 @@
+type RouteCondition = {
+ type: string
+ key: string
+ value?: string
+}
+
+export type DynamicRouteEntry = {
+ source: string
+ sourceRegex: string
+ destination: string
+ has?: RouteCondition[]
+ missing?: RouteCondition[]
+}
+
+export type AdapterRouting = {
+ dynamicRoutes: DynamicRouteEntry[]
+}
+
+/**
+ * Formats the dynamic routes as one block per entry.
+ *
+ * The output carries three things:
+ *
+ * - The entry count.
+ * - The fields that a collapse rewrites: `sourceRegex` and `destination`.
+ * - The conditions that allow a merge: `has` and `missing`.
+ *
+ * The output omits every other field of the adapter payload. An unrelated
+ * change to the build output then leaves the snapshot alone.
+ *
+ * The entries keep the order that the build emits. That order is part of the
+ * contract:
+ *
+ * - A fallback shell comes before its source page.
+ * - A static segment comes before a dynamic segment in the same position.
+ */
+export function serializeDynamicRoutes(routes: DynamicRouteEntry[]): string {
+ const blocks = routes.map((route) => {
+ const conditions = [
+ ...(route.has ?? []).map(
+ (condition) => `has ${condition.type} ${condition.key}`
+ ),
+ ...(route.missing ?? []).map(
+ (condition) => `missing ${condition.type} ${condition.key}`
+ ),
+ ]
+
+ const conditionLine =
+ conditions.length > 0 ? `\n [${conditions.join(', ')}]` : ''
+
+ return `${route.source}\n ${route.sourceRegex}\n -> ${route.destination}${conditionLine}`
+ })
+
+ return `${routes.length} entries\n\n${blocks.join('\n\n')}`
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts b/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts
new file mode 100644
index 000000000000..fda0065b4b08
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/api/data/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ from: 'route-handler' })
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx
new file mode 100644
index 000000000000..09251644c65a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/blog/[slug]/page.tsx
@@ -0,0 +1,13 @@
+export function generateStaticParams() {
+ return [{ slug: 'one' }]
+}
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}) {
+ const { slug } = await params
+
+ return {slug}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx
new file mode 100644
index 000000000000..d9994085d47d
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/accounts/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return accounts
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx
new file mode 100644
index 000000000000..a9d4f6cf4b99
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/functions/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return functions
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx
new file mode 100644
index 000000000000..f653a2cab7cc
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/docs/[lang]/guide/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return guide
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx
new file mode 100644
index 000000000000..e7077399c03c
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs
new file mode 100644
index 000000000000..0aa9bfb874ae
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/my-adapter.mjs
@@ -0,0 +1,12 @@
+import fs from 'fs/promises'
+
+/** @type {import('next').NextAdapter} */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js b/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js
new file mode 100644
index 000000000000..e44df5ff9034
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js
@@ -0,0 +1,16 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ // This value is explicit, not omitted. CI exports
+ // `__NEXT_CACHE_COMPONENTS=true` for the Cache Components matrices, and
+ // that variable overrides a config that omits the field. An omitted field
+ // would let that matrix turn Cache Components on for this fixture.
+ cacheComponents: false,
+ // The source regex of a pages router data route holds the build ID. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx
new file mode 100644
index 000000000000..58c7f055fbeb
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/legacy/[id].tsx
@@ -0,0 +1,13 @@
+import type { GetStaticPaths, GetStaticProps } from 'next'
+
+export const getStaticPaths: GetStaticPaths = () => {
+ return { paths: [{ params: { id: '1' } }], fallback: false }
+}
+
+export const getStaticProps: GetStaticProps<{ id: string }> = ({ params }) => {
+ return { props: { id: String(params?.id) } }
+}
+
+export default function Page({ id }: { id: string }) {
+ return legacy {id}
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx
new file mode 100644
index 000000000000..739deac0de00
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-one.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static one
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx
new file mode 100644
index 000000000000..3a3cd2a133eb
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/pages/static-two.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return static two
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts b/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts
new file mode 100644
index 000000000000..e6b66cc23e2c
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/legacy/proxy.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server'
+
+export default function proxy() {
+ return NextResponse.next()
+}
From 7678e6e953967c85d87c76e36cb5ad06c78956ff Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Mon, 24 Aug 2026 10:41:35 +0200
Subject: [PATCH 02/15] Stop emitting a redundant route per prefetch segment
(#97720)
For a dynamic app page that has a fallback shell, say `/[lang]/[slug]`
with a shell for the root param value `de`, the build emits three routes
to an adapter. Simplified, with the request pattern on the left and the
artifact it resolves to on the right:
```diff
- /de/.segments/$d$lang/$d$slug -> /de/[slug].segments/$d$lang/$d$slug
/de/<.rsc|.segments/*.segment.rsc> -> /de/[slug]
/de/ -> /de/[slug]
```
This change removes the first one. The second already covers it: its
suffix group accepts `.segments/.segment.rsc` as well as `.rsc`,
and it copies the matched suffix into the destination, so a per-segment
request resolves to the same artifact either way.
That second route is also the only one that ever answered `_tree` and
`_full` requests, because a per-segment route pins one literal segment
path in its regex. A `_tree` prefetch for `/de/` resolves to
`/de/[slug].segments/_tree.segment.rsc`, which the per-segment route
cannot produce. So this removes a duplicate, not a mechanism.
Only fallback shells emit per-segment routes, because their artifacts
sit under an unresolved param and need a rewrite to reach. Segment
artifacts for a concrete prerendered path need none, since a request for
that path matches them directly. Apps with many shells therefore lose
close to a third of their routes. We measured an in-progress feature
branch of the v0 chat app, which enumerates precomputed flag, locale and
device permutations in `generateStaticParams` for a top-level dynamic
segment and so multiplies every route below it. Building that branch
before and after the change removes 32% of its routes, adds none, and
changes nothing else once the build ID is normalized.
This also adds `experimental.collapseAdapterRoutes`. It defaults to
`true`, and it controls this collapse together with the ones that follow
in this stack. A build that sets it to `false` emits the same route
table as a build without any change in this stack.
`prefetchSegmentDataRoutes` stays in `routes-manifest.json`. A build
that does not use the adapter reads the field from that manifest and
derives the same routes from it. This change therefore leaves that path
alone.
**Verified upstack with a [full deploy test
run](https://github.com/vercel/next.js/actions/runs/32540912007).**
---
.../next/src/build/adapter/build-complete.ts | 41 +++++++++++--------
packages/next/src/server/config-schema.ts | 1 +
packages/next/src/server/config-shared.ts | 13 ++++++
.../dynamic-routes-base-path.test.ts | 2 +-
.../dynamic-routes-cache-components.test.ts | 41 +------------------
5 files changed, 42 insertions(+), 56 deletions(-)
diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts
index f9d295ce8331..e5356ed346d3 100644
--- a/packages/next/src/build/adapter/build-complete.ts
+++ b/packages/next/src/build/adapter/build-complete.ts
@@ -2122,6 +2122,10 @@ export async function handleBuildComplete({
route.page
) + getDestinationQuery(route.routeKeys)
+ // This route serves two kinds of request for the page: a request for the
+ // `.rsc` payload, and a per-segment prefetch request. The suffix group
+ // accepts both forms, and the destination copies the matched suffix, so
+ // each request resolves to the artifact that it asks for.
if (appPageKeys && appPageKeys.length > 0) {
dynamicRoutes.push({
source: route.page + '.rsc',
@@ -2147,22 +2151,27 @@ export async function handleBuildComplete({
missing: undefined,
})
- for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
- dynamicSegmentRoutes.push({
- source: route.page,
- sourceRegex: segmentRoute.source.replace(
- '^',
- `^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
- ),
- destination: path.posix.join(
- '/',
- config.basePath,
- segmentRoute.destination +
- getDestinationQuery(segmentRoute.routeKeys)
- ),
- has: undefined,
- missing: undefined,
- })
+ // The `.rsc` route above resolves a per-segment request on its own. A
+ // build that turns the collapse off emits a dedicated route for each
+ // segment, and the table lists those before that `.rsc` route.
+ if (!config.experimental.collapseAdapterRoutes) {
+ for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
+ dynamicSegmentRoutes.push({
+ source: route.page,
+ sourceRegex: segmentRoute.source.replace(
+ '^',
+ `^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
+ ),
+ destination: path.posix.join(
+ '/',
+ config.basePath,
+ segmentRoute.destination +
+ getDestinationQuery(segmentRoute.routeKeys)
+ ),
+ has: undefined,
+ missing: undefined,
+ })
+ }
}
}
diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts
index 5c211ee47c9d..c453267be294 100644
--- a/packages/next/src/server/config-schema.ts
+++ b/packages/next/src/server/config-schema.ts
@@ -197,6 +197,7 @@ export const experimentalSchema = {
after: z.boolean().optional(),
appNavFailHandling: z.boolean().optional(),
coldCacheBadge: z.boolean().optional(),
+ collapseAdapterRoutes: z.boolean().optional(),
preloadEntriesOnStart: z.boolean().optional(),
allowedRevalidateHeaderKeys: z.array(z.string()).optional(),
staleTimes: z
diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts
index 0d3f6ddcb5d2..2fb907ca3cf3 100644
--- a/packages/next/src/server/config-shared.ts
+++ b/packages/next/src/server/config-shared.ts
@@ -498,6 +498,18 @@ export interface ExperimentalConfig {
* regardless of this flag.
*/
coldCacheBadge?: boolean
+ /**
+ * Whether a build may serve several dynamic routes from one entry in the
+ * route table that it passes to an adapter. Several routes of an app can
+ * differ only in a part that a single pattern also matches, and one entry for
+ * them keeps the table smaller.
+ *
+ * A collapsed entry resolves each request to the same output as the entries
+ * that it replaces.
+ *
+ * @default true
+ */
+ collapseAdapterRoutes?: boolean
useSkewCookie?: boolean
/** @deprecated use top-level `cacheHandlers` instead */
cacheHandlers?: NextConfig['cacheHandlers']
@@ -2229,6 +2241,7 @@ export const defaultConfig = Object.freeze({
adapterPath: process.env.NEXT_ADAPTER_PATH || undefined,
experimental: {
coldCacheBadge: false,
+ collapseAdapterRoutes: true,
devValidationWorker: true,
useSkewCookie: false,
cssChunking: true,
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
index d36514494746..5824c7403e7c 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
@@ -30,7 +30,7 @@ describe(`adapter dynamic routes (cache components, base path ${basePath})`, ()
const routing: AdapterRouting = await next.readJSON('build-complete.json')
// A base path prefixes the entries. It does not add or remove any.
- expect(routing.dynamicRoutes).toHaveLength(27)
+ expect(routing.dynamicRoutes).toHaveLength(18)
for (const route of routing.dynamicRoutes) {
expect(route.sourceRegex.startsWith(`^${basePath}`)).toBe(true)
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
index 78e76b8e60e6..70933486395c 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -21,9 +21,8 @@ import {
// The fixture holds the shape that grows with the number of root param
// combinations. `generateStaticParams` on the root layout produces one
// fallback shell for each combination. Each manifest entry then produces
-// three adapter entries:
+// two adapter entries:
//
-// - A dedicated segment route.
// - An `.rsc` route.
// - A plain route.
describe('adapter dynamic routes (cache components)', () => {
@@ -41,43 +40,7 @@ describe('adapter dynamic routes (cache components)', () => {
expect(serializeDynamicRoutes(routing.dynamicRoutes))
.toMatchInlineSnapshot(`
- "27 entries
-
- /[lang]
- ^[/]?/(?[^/]+?)\\.segments/\\$d\\$lang(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /[lang].segments/$d$lang$segment?nxtPlang=$nxtPlang
-
- /de/fallback-shell/[slug]
- ^[/]?/de/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /de/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPslug=$nxtPslug
-
- /en/fallback-shell/[slug]
- ^[/]?/en/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /en/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPslug=$nxtPslug
-
- /[lang]/fallback-shell/[slug]
- ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)\\.segments/\\$d\\$lang/fallback\\-shell/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /[lang]/fallback-shell/[slug].segments/$d$lang/fallback-shell/$d$slug$segment?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
-
- /[lang]/ppr
- ^[/]?/(?[^/]+?)/ppr\\.segments/\\$d\\$lang/ppr(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /[lang]/ppr.segments/$d$lang/ppr$segment?nxtPlang=$nxtPlang
-
- /[lang]/static
- ^[/]?/(?[^/]+?)/static\\.segments/\\$d\\$lang/static(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /[lang]/static.segments/$d$lang/static$segment?nxtPlang=$nxtPlang
-
- /de/[slug]
- ^[/]?/de/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /de/[slug].segments/$d$lang/$d$slug$segment?nxtPslug=$nxtPslug
-
- /en/[slug]
- ^[/]?/en/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /en/[slug].segments/$d$lang/$d$slug$segment?nxtPslug=$nxtPslug
-
- /[lang]/[slug]
- ^[/]?/(?[^/]+?)/(?[^/]+?)\\.segments/\\$d\\$lang/\\$d\\$slug(?/__PAGE__\\.segment\\.rsc|\\.segment\\.rsc)(?:/)?$
- -> /[lang]/[slug].segments/$d$lang/$d$slug$segment?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+ "18 entries
/[lang].rsc
^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
From 45d3aa06cf9635f955d534b30bf0371eed07a513 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Mon, 24 Aug 2026 10:41:35 +0200
Subject: [PATCH 03/15] Stop emitting a separate route entry for a dynamic
route's RSC form (#97726)
A dynamic app page in an app with an app directory needs two routes
today. One matches a request for the page, and one matches a request for
its `.rsc` payload or a per-segment prefetch. Simplified, with the
request pattern on the left and the artifact it resolves to on the
right:
```diff
- /de/<.rsc|.segments/*.segment.rsc> -> /de/[slug]
- /de/ -> /de/[slug]
+ /de/<.rsc|.segments/*.segment.rsc|> -> /de/[slug]
```
One entry now serves both, because the suffix group gains an empty final
alternative. The group therefore always matches. It captures an empty
string for a request that carries no suffix, and the destination copies
whatever it captured, so a plain request resolves to the page and a
suffixed request resolves to the artifact it asks for. The parameter is
matched lazily, so a request for `/de/other.rsc` still prefers the
shorter parameter and the `.rsc` suffix over a parameter that swallows
the suffix.
The empty alternative is deliberate, and an optional group would not be
equivalent. A consumer of these entries can resolve the placeholders in
a destination from the match result rather than from the pattern. A
group that does not take part in the match is then absent from that
result, and the literal text `$rscSuffix` survives into the destination.
A group that always takes part avoids depending on how any one consumer
treats an absent key, and the cost of getting it wrong is a 404 on every
plain navigation to every dynamic route.
One case keeps two entries. A pages router route with `fallback: false`
requires the preview cookies on the request for the page, and not on the
request for its `.rsc` payload. An entry carries one set of conditions
and cannot express that difference, so the merge applies only when both
forms agree on their conditions.
Every app that has an app directory therefore halves the entries for its
dynamic routes, whether or not it uses Cache Components. We measured the
same in-progress feature branch of the v0 chat app as the previous
change in this stack, which enumerates precomputed flag, locale and
device permutations in `generateStaticParams` for a top-level dynamic
segment and so multiplies every route below it. Every dynamic entry on
that branch merges. Building it before and after the change removes 48%
of the routes that the previous change left, replaces each merged pair
with a single entry, and changes nothing else once the build ID is
normalized. Across both changes that branch loses 65% of its routes.
This collapse follows `experimental.collapseAdapterRoutes`, which the
previous change in this stack added. A build that sets it to `false`
emits a separate entry for each form.
**Verified with a [full deploy test
run](https://github.com/vercel/next.js/actions/runs/32540912007).**
---
.../next/src/build/adapter/build-complete.ts | 90 ++++++++++++++-----
.../dynamic-routes-base-path.test.ts | 2 +-
.../dynamic-routes-cache-components.test.ts | 74 ++++-----------
.../dynamic-routes-legacy.test.ts | 34 ++-----
4 files changed, 96 insertions(+), 104 deletions(-)
diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts
index e5356ed346d3..683480b5496c 100644
--- a/packages/next/src/build/adapter/build-complete.ts
+++ b/packages/next/src/build/adapter/build-complete.ts
@@ -2122,38 +2122,82 @@ export async function handleBuildComplete({
route.page
) + getDestinationQuery(route.routeKeys)
- // This route serves two kinds of request for the page: a request for the
- // `.rsc` payload, and a per-segment prefetch request. The suffix group
- // accepts both forms, and the destination copies the matched suffix, so
- // each request resolves to the artifact that it asks for.
- if (appPageKeys && appPageKeys.length > 0) {
+ const hasAppPages = Boolean(appPageKeys && appPageKeys.length > 0)
+
+ const suffixedHas =
+ isFallbackFalse && !pageKeys.includes(route.page)
+ ? fallbackFalseHasCondition
+ : undefined
+ const plainHas = isFallbackFalse ? fallbackFalseHasCondition : undefined
+
+ // A single entry can serve both forms of the request only when both carry
+ // the same conditions. A pages router route with `fallback: false` is the
+ // one case where they differ: it requires the preview cookies on the
+ // plain form, and not on the suffixed form. An entry holds one set of
+ // conditions, so that case keeps a separate entry per form.
+ const canMergeSuffixedAndPlain =
+ config.experimental.collapseAdapterRoutes &&
+ hasAppPages &&
+ suffixedHas === plainHas
+
+ if (canMergeSuffixedAndPlain) {
+ // One entry serves every form of a request for this page:
+ //
+ // - The document at the page path.
+ // - The `.rsc` payload.
+ // - A per-segment prefetch.
+ //
+ // The suffix group ends with an empty alternative. The group therefore
+ // always matches, and it captures an empty string for a request that
+ // carries no suffix. The destination copies what the group captured.
+ //
+ // An optional group is unsafe here. An adapter, or the router that
+ // consumes its output, can resolve the placeholders in a destination
+ // from the match result rather than from the pattern. A group that does
+ // not match is then absent from that result, and the literal text
+ // `$rscSuffix` stays in the destination.
dynamicRoutes.push({
- source: route.page + '.rsc',
+ source: route.page,
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
- '(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
+ '(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$'
),
destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
- has:
- isFallbackFalse && !pageKeys.includes(route.page)
- ? fallbackFalseHasCondition
- : undefined,
+ has: plainHas,
missing: undefined,
})
- }
+ } else {
+ // This route serves two kinds of request for the page: a request for
+ // the `.rsc` payload, and a per-segment prefetch request. The suffix
+ // group accepts both forms, and the destination copies the matched
+ // suffix, so each request resolves to the artifact that it asks for.
+ if (hasAppPages) {
+ dynamicRoutes.push({
+ source: route.page + '.rsc',
+ sourceRegex: sourceRegex.replace(
+ new RegExp(escapeStringRegexp('(?:/)?$')),
+ '(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
+ ),
+ destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
+ has: suffixedHas,
+ missing: undefined,
+ })
+ }
- // needs basePath and locale handling if pages router
- dynamicRoutes.push({
- source: route.page,
- sourceRegex,
- destination,
- has: isFallbackFalse ? fallbackFalseHasCondition : undefined,
- missing: undefined,
- })
+ // needs basePath and locale handling if pages router
+ dynamicRoutes.push({
+ source: route.page,
+ sourceRegex,
+ destination,
+ has: plainHas,
+ missing: undefined,
+ })
+ }
- // The `.rsc` route above resolves a per-segment request on its own. A
- // build that turns the collapse off emits a dedicated route for each
- // segment, and the table lists those before that `.rsc` route.
+ // The entry above resolves a per-segment request on its own, because its
+ // suffix group accepts a segment path. A build that turns the collapse
+ // off emits a dedicated route for each segment, and the table lists those
+ // before that entry.
if (!config.experimental.collapseAdapterRoutes) {
for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
dynamicSegmentRoutes.push({
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
index 5824c7403e7c..aacf699f4b04 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
@@ -30,7 +30,7 @@ describe(`adapter dynamic routes (cache components, base path ${basePath})`, ()
const routing: AdapterRouting = await next.readJSON('build-complete.json')
// A base path prefixes the entries. It does not add or remove any.
- expect(routing.dynamicRoutes).toHaveLength(18)
+ expect(routing.dynamicRoutes).toHaveLength(9)
for (const route of routing.dynamicRoutes) {
expect(route.sourceRegex.startsWith(`^${basePath}`)).toBe(true)
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
index 70933486395c..d14ddd9c8187 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -40,79 +40,43 @@ describe('adapter dynamic routes (cache components)', () => {
expect(serializeDynamicRoutes(routing.dynamicRoutes))
.toMatchInlineSnapshot(`
- "18 entries
-
- /[lang].rsc
- ^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /[lang]$rscSuffix?nxtPlang=$nxtPlang
+ "9 entries
/[lang]
- ^[/]?/(?[^/]+?)(?:/)?$
- -> /[lang]?nxtPlang=$nxtPlang
-
- /de/fallback-shell/[slug].rsc
- ^[/]?/de/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /de/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+ ^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]$rscSuffix?nxtPlang=$nxtPlang
/de/fallback-shell/[slug]
- ^[/]?/de/fallback\\-shell/(?[^/]+?)(?:/)?$
- -> /de/fallback-shell/[slug]?nxtPslug=$nxtPslug
-
- /en/fallback-shell/[slug].rsc
- ^[/]?/en/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /en/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+ ^[/]?/de/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /de/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
/en/fallback-shell/[slug]
- ^[/]?/en/fallback\\-shell/(?[^/]+?)(?:/)?$
- -> /en/fallback-shell/[slug]?nxtPslug=$nxtPslug
-
- /[lang]/fallback-shell/[slug].rsc
- ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /[lang]/fallback-shell/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+ ^[/]?/en/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /en/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
/[lang]/fallback-shell/[slug]
- ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?:/)?$
- -> /[lang]/fallback-shell/[slug]?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
-
- /[lang]/ppr.rsc
- ^[/]?/(?[^/]+?)/ppr(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /[lang]/ppr$rscSuffix?nxtPlang=$nxtPlang
+ ^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/fallback-shell/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
/[lang]/ppr
- ^[/]?/(?[^/]+?)/ppr(?:/)?$
- -> /[lang]/ppr?nxtPlang=$nxtPlang
-
- /[lang]/static.rsc
- ^[/]?/(?[^/]+?)/static(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /[lang]/static$rscSuffix?nxtPlang=$nxtPlang
+ ^[/]?/(?[^/]+?)/ppr(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/ppr$rscSuffix?nxtPlang=$nxtPlang
/[lang]/static
- ^[/]?/(?[^/]+?)/static(?:/)?$
- -> /[lang]/static?nxtPlang=$nxtPlang
-
- /de/[slug].rsc
- ^[/]?/de/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /de/[slug]$rscSuffix?nxtPslug=$nxtPslug
+ ^[/]?/(?[^/]+?)/static(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/static$rscSuffix?nxtPlang=$nxtPlang
/de/[slug]
- ^[/]?/de/(?[^/]+?)(?:/)?$
- -> /de/[slug]?nxtPslug=$nxtPslug
-
- /en/[slug].rsc
- ^[/]?/en/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /en/[slug]$rscSuffix?nxtPslug=$nxtPslug
+ ^[/]?/de/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /de/[slug]$rscSuffix?nxtPslug=$nxtPslug
/en/[slug]
- ^[/]?/en/(?[^/]+?)(?:/)?$
- -> /en/[slug]?nxtPslug=$nxtPslug
-
- /[lang]/[slug].rsc
- ^[/]?/(?[^/]+?)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /[lang]/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug
+ ^[/]?/en/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /en/[slug]$rscSuffix?nxtPslug=$nxtPslug
/[lang]/[slug]
- ^[/]?/(?[^/]+?)/(?[^/]+?)(?:/)?$
- -> /[lang]/[slug]?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug"
+ ^[/]?/(?[^/]+?)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[lang]/[slug]$rscSuffix?nxtPlang=$nxtPlang&nxtPslug=$nxtPslug"
`)
})
})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
index 9370ded958ce..445ec8127724 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-legacy.test.ts
@@ -40,7 +40,7 @@ describe('adapter dynamic routes (legacy)', () => {
expect(serializeDynamicRoutes(routing.dynamicRoutes))
.toMatchInlineSnapshot(`
- "13 entries
+ "9 entries
/legacy/[id]
^/_next/data/test\\-build\\-id[/]?/legacy/(?[^/]+?)\\.json(?:/)?$
@@ -55,37 +55,21 @@ describe('adapter dynamic routes (legacy)', () => {
^/_next/data/test\\-build\\-id[/]?/static\\-two\\.json(?:/)?$
-> /static-two
- /blog/[slug].rsc
- ^[/]?/blog/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /blog/[slug]$rscSuffix?nxtPslug=$nxtPslug
-
/blog/[slug]
- ^[/]?/blog/(?[^/]+?)(?:/)?$
- -> /blog/[slug]?nxtPslug=$nxtPslug
-
- /docs/[lang]/accounts.rsc
- ^[/]?/docs/(?[^/]+?)/accounts(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /docs/[lang]/accounts$rscSuffix?nxtPlang=$nxtPlang
+ ^[/]?/blog/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /blog/[slug]$rscSuffix?nxtPslug=$nxtPslug
/docs/[lang]/accounts
- ^[/]?/docs/(?[^/]+?)/accounts(?:/)?$
- -> /docs/[lang]/accounts?nxtPlang=$nxtPlang
-
- /docs/[lang]/functions.rsc
- ^[/]?/docs/(?[^/]+?)/functions(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /docs/[lang]/functions$rscSuffix?nxtPlang=$nxtPlang
+ ^[/]?/docs/(?[^/]+?)/accounts(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/accounts$rscSuffix?nxtPlang=$nxtPlang
/docs/[lang]/functions
- ^[/]?/docs/(?[^/]+?)/functions(?:/)?$
- -> /docs/[lang]/functions?nxtPlang=$nxtPlang
-
- /docs/[lang]/guide.rsc
- ^[/]?/docs/(?[^/]+?)/guide(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
- -> /docs/[lang]/guide$rscSuffix?nxtPlang=$nxtPlang
+ ^[/]?/docs/(?[^/]+?)/functions(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/functions$rscSuffix?nxtPlang=$nxtPlang
/docs/[lang]/guide
- ^[/]?/docs/(?[^/]+?)/guide(?:/)?$
- -> /docs/[lang]/guide?nxtPlang=$nxtPlang
+ ^[/]?/docs/(?[^/]+?)/guide(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /docs/[lang]/guide$rscSuffix?nxtPlang=$nxtPlang
/legacy/[id].rsc
^[/]?/legacy/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$
From 91d378dbb65d02cce1008485787f00b90f4b8412 Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Mon, 24 Aug 2026 10:41:36 +0200
Subject: [PATCH 04/15] [test] Capture the route table for apps with several
param shapes (#97728)
The existing fixtures take a single root param, so they do not cover the
shapes that the fallback shell entries can take. This adds fixtures and
suites for three more shapes, and pins the entries that each one
produces.
The first has a root layout that takes two root params, and returns
three combinations of them rather than the full product of four. An
entry that held each root param position separately would also match
`sparse/de`, a combination the build never prerendered, and a request
for it would resolve to an output that does not exist. One combination
is `acme.one-two,three`, which carries the characters a regex treats as
special, and the snapshot shows the build escaping them.
The second builds that same app with a single combination, so each
source page has one fallback shell.
The third has no root params at all. Its root layout sits above the
dynamic segments, so `team` and `locale` are ordinary dynamic params
that `generateStaticParams` enumerates on a nested layout. The build
then produces two shapes of entry for one source page: entries that
resolve both params, such as `/sparse/en/posts/[id]`, and entries that
resolve only the first, such as `/sparse/[locale]/posts/[id]`. The order
of those entries carries the behavior, because a request for
`/sparse/en/posts/1` has to reach the output that resolves both params
rather than the one that resolves only the first. The snapshot pins that
order.
Two comments on the Cache Components suite were wrong, and this corrects
them. That fixture takes one root param with two values, and not two
root params. Its entries also no longer come in pairs, because one entry
serves the request for the page, the request for its `.rsc` payload, and
a per-segment prefetch.
---
.../dynamic-routes-cache-components.test.ts | 19 ++---
.../dynamic-routes-no-root-params.test.ts | 69 +++++++++++++++++++
...namic-routes-shell-prefixes-single.test.ts | 40 +++++++++++
.../dynamic-routes-shell-prefixes.test.ts | 51 ++++++++++++++
.../app/[team]/[locale]/layout.tsx | 18 +++++
.../app/[team]/[locale]/posts/[id]/page.tsx | 13 ++++
.../no-root-params/app/layout.tsx | 9 +++
.../no-root-params/my-adapter.mjs | 14 ++++
.../no-root-params/next.config.js | 12 ++++
.../app/[team]/[locale]/layout.tsx | 34 +++++++++
.../app/[team]/[locale]/posts/[id]/page.tsx | 13 ++++
.../shell-prefixes/my-adapter.mjs | 14 ++++
.../shell-prefixes/next.config.js | 12 ++++
13 files changed, 309 insertions(+), 9 deletions(-)
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts
create mode 100644 test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs
create mode 100644 test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js
create mode 100644 test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx
create mode 100644 test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs
create mode 100644 test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
index d14ddd9c8187..be8ea8980716 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -5,9 +5,9 @@ import {
type AdapterRouting,
} from './dynamic-routes-snapshot'
-// This suite pins the dynamic routes that a build passes to an adapter for
-// a Cache Components app. The root layout of the fixture returns two root
-// params.
+// This suite pins the dynamic routes that a build passes to an adapter for a
+// Cache Components app. The root layout of the fixture takes one root param and
+// returns two values for it.
//
// Each entry in the snapshot becomes one route in the routes document of a
// deployment. The snapshot covers the routes that this app shape contributes.
@@ -18,13 +18,14 @@ import {
// route matches an output during the filesystem check, so it needs no
// rewrite.
//
-// The fixture holds the shape that grows with the number of root param
-// combinations. `generateStaticParams` on the root layout produces one
-// fallback shell for each combination. Each manifest entry then produces
-// two adapter entries:
+// The fixture holds the shape that grows with the number of root param values.
+// `generateStaticParams` on the root layout produces one fallback shell for
+// each value. Each shell contributes one entry, and that entry serves three
+// kinds of request:
//
-// - An `.rsc` route.
-// - A plain route.
+// - A request for the page.
+// - A request for its `.rsc` payload.
+// - A per-segment prefetch.
describe('adapter dynamic routes (cache components)', () => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'cache-components'),
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts
new file mode 100644
index 000000000000..912cd2c5c4c5
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-no-root-params.test.ts
@@ -0,0 +1,69 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes for an app that has no root params. The
+// root layout sits above the dynamic segments, so `team` and `locale` are
+// ordinary dynamic params, and `generateStaticParams` on a nested layout
+// enumerates them.
+//
+// The build produces two shapes of entry for one source page here:
+//
+// - An entry that resolves both params, such as `/sparse/en/posts/[id]`.
+// - An entry that resolves only `team`, such as `/sparse/[locale]/posts/[id]`.
+//
+// The two shapes have different paths after the resolved segments, so the
+// entries stay as they are. One entry serves one shape of path, and an
+// alternation cannot hold both shapes at once.
+//
+// The order of the entries carries the behavior. An entry that resolves both
+// params comes before an entry that resolves one, so a request for
+// `/sparse/en/posts/1` reaches the output for `/sparse/en/posts/[id]` and not
+// the one for `/sparse/[locale]/posts/[id]`. A collapse that grouped the second
+// shape across teams would move it ahead of the first shape and change which
+// output a request reaches.
+describe('adapter dynamic routes (no root params)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'no-root-params'),
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "6 entries
+
+ /acme.one-two,three/de/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/de/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/de/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /acme.one-two,three/en/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /acme.one-two,three/[locale]/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/[locale]/posts/[id]$rscSuffix?nxtPlocale=$nxtPlocale&nxtPid=$nxtPid
+
+ /sparse/en/posts/[id]
+ ^[/]?/sparse/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /sparse/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /sparse/[locale]/posts/[id]
+ ^[/]?/sparse/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /sparse/[locale]/posts/[id]$rscSuffix?nxtPlocale=$nxtPlocale&nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts
new file mode 100644
index 000000000000..2d7c58651208
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes-single.test.ts
@@ -0,0 +1,40 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite builds the shell prefixes fixture with one combination of root
+// param values, so each source page has one fallback shell.
+//
+// An alternation of one combination saves no entry, and it would replace a
+// literal path with a capture group for no gain. The entry for the shell
+// therefore keeps the combination as it is.
+describe('adapter dynamic routes (shell prefixes, one combination)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'shell-prefixes'),
+ env: { SINGLE_COMBINATION: '1' },
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('keeps the combination in the entry for a lone shell', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "2 entries
+
+ /acme.one-two,three/en/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts
new file mode 100644
index 000000000000..6d41d9b5c579
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-shell-prefixes.test.ts
@@ -0,0 +1,51 @@
+import path from 'path'
+import { nextTestSetup } from 'e2e-utils'
+import {
+ serializeDynamicRoutes,
+ type AdapterRouting,
+} from './dynamic-routes-snapshot'
+
+// This suite pins the dynamic routes for an app whose root layout takes two
+// root params.
+//
+// The fixture returns three combinations of the two root params, and not the
+// full product of four. The build produces one fallback shell for each of the
+// three, and each shell contributes one entry. No entry matches the fourth
+// combination, `sparse/de`, because the build produces no output for it.
+//
+// One combination contains `.`, `-` and `,`. A regex treats those characters as
+// special, and the patterns below escape them.
+describe('adapter dynamic routes (shell prefixes)', () => {
+ const { next } = nextTestSetup({
+ files: path.join(__dirname, 'shell-prefixes'),
+ // The fixture sets `generateBuildId`, and this option lets that value take
+ // effect. The harness otherwise assigns a new build ID for each run. A
+ // build ID that reaches an entry then changes the assertions on every run.
+ disableAutoSkewProtection: true,
+ })
+
+ it('emits the expected dynamic routes', async () => {
+ const routing: AdapterRouting = await next.readJSON('build-complete.json')
+
+ expect(serializeDynamicRoutes(routing.dynamicRoutes))
+ .toMatchInlineSnapshot(`
+ "4 entries
+
+ /acme.one-two,three/de/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/de/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/de/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /acme.one-two,three/en/posts/[id]
+ ^[/]?/acme\\.one\\-two,three/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /acme.one-two,three/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /sparse/en/posts/[id]
+ ^[/]?/sparse/en/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /sparse/en/posts/[id]$rscSuffix?nxtPid=$nxtPid
+
+ /[team]/[locale]/posts/[id]
+ ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /[team]/[locale]/posts/[id]$rscSuffix?nxtPteam=$nxtPteam&nxtPlocale=$nxtPlocale&nxtPid=$nxtPid"
+ `)
+ })
+})
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx
new file mode 100644
index 000000000000..3667053a881a
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/layout.tsx
@@ -0,0 +1,18 @@
+// This layout is not the root layout, so `team` and `locale` are ordinary
+// dynamic params rather than root params. It returns three combinations of
+// them, and not the full product of four.
+export function generateStaticParams() {
+ return [
+ { team: 'acme.one-two,three', locale: 'en' },
+ { team: 'acme.one-two,three', locale: 'de' },
+ { team: 'sparse', locale: 'en' },
+ ]
+}
+
+export default function TeamLocaleLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return <>{children}>
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx
new file mode 100644
index 000000000000..c171c3a946aa
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/[team]/[locale]/posts/[id]/page.tsx
@@ -0,0 +1,13 @@
+import { Suspense } from 'react'
+
+// This page does not resolve `id`. The build therefore produces one fallback
+// shell for each root param combination.
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
+ return (
+ loading}>
+ {params.then(({ id }) => (
+ {id}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx
new file mode 100644
index 000000000000..a5e5ca4e0333
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/app/layout.tsx
@@ -0,0 +1,9 @@
+// The root layout is not inside a dynamic segment, so `team` and `locale` are
+// ordinary dynamic params and not root params.
+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs
new file mode 100644
index 000000000000..f49ffcc84320
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/my-adapter.mjs
@@ -0,0 +1,14 @@
+import fs from 'fs/promises'
+
+/**
+ * @type {import('next').NextAdapter}
+ */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js b/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js
new file mode 100644
index 000000000000..0e051ae8a961
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/no-root-params/next.config.js
@@ -0,0 +1,12 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // A build ID that reaches an entry changes the snapshot on every run. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx
new file mode 100644
index 000000000000..c9da36f35539
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/layout.tsx
@@ -0,0 +1,34 @@
+import { locale, team } from 'next/root-params'
+
+// This layout takes two root params. `generateStaticParams` returns three
+// combinations of them, and not the full product of four. The build therefore
+// produces no output for the `sparse` team with the `de` locale.
+//
+// One team value contains `.`, `-` and `,`. A regex treats those characters as
+// special.
+//
+// `SINGLE_COMBINATION` reduces the list to one combination. A suite sets it to
+// build the same app with one fallback shell per source page.
+export function generateStaticParams() {
+ if (process.env.SINGLE_COMBINATION) {
+ return [{ team: 'acme.one-two,three', locale: 'en' }]
+ }
+
+ return [
+ { team: 'acme.one-two,three', locale: 'en' },
+ { team: 'acme.one-two,three', locale: 'de' },
+ { team: 'sparse', locale: 'en' },
+ ]
+}
+
+export default async function Root({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx
new file mode 100644
index 000000000000..c171c3a946aa
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/app/[team]/[locale]/posts/[id]/page.tsx
@@ -0,0 +1,13 @@
+import { Suspense } from 'react'
+
+// This page does not resolve `id`. The build therefore produces one fallback
+// shell for each root param combination.
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
+ return (
+ loading}>
+ {params.then(({ id }) => (
+ {id}
+ ))}
+
+ )
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs
new file mode 100644
index 000000000000..f49ffcc84320
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/my-adapter.mjs
@@ -0,0 +1,14 @@
+import fs from 'fs/promises'
+
+/**
+ * @type {import('next').NextAdapter}
+ */
+export default {
+ name: 'route-table-probe',
+ async onBuildComplete(ctx) {
+ await fs.writeFile(
+ 'build-complete.json',
+ JSON.stringify(ctx.routing, null, 2)
+ )
+ },
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js
new file mode 100644
index 000000000000..0e051ae8a961
--- /dev/null
+++ b/test/production/app-dir/adapter-dynamic-routes/shell-prefixes/next.config.js
@@ -0,0 +1,12 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ cacheComponents: true,
+ // A build ID that reaches an entry changes the snapshot on every run. A fixed
+ // build ID keeps the snapshot independent of the run.
+ generateBuildId: () => 'test-build-id',
+ adapterPath: require.resolve('./my-adapter.mjs'),
+}
+
+module.exports = nextConfig
From 6f1fb7f73a93e1b5ea753e15b03a122d34f608bb Mon Sep 17 00:00:00 2001
From: Hendrik Liebau
Date: Mon, 24 Aug 2026 10:41:36 +0200
Subject: [PATCH 05/15] Serve a run of fallback shells from one route entry
(#97738)
A fallback shell repeats the whole path of its source page and resolves
the leading params to concrete values. Take `/[team]/[locale]/[slug]`,
where the build prerenders three combinations of the two leading params.
It emits one entry per combination, and the entries differ only in that
leading part of the path:
```diff
- /acme/en/ -> /acme/en/[slug]
- /acme/de/ -> /acme/de/[slug]
- /globex/en/ -> /globex/en/[slug]
+ /(?acme/en|acme/de|globex/en)/ -> /$shellPrefix/[slug]
```
One entry now serves them all. Its pattern lists the leading part of
each shell path as an alternative, and its destination copies whichever
one matched.
Those alternatives are complete, and that matters. A pattern that
offered a choice per param instead, `(acme|globex)/(en|de)`, would also
match `globex/de`. The build never prerendered that combination, so a
request for it would resolve to an output that does not exist, and it
would then fall through to whichever route claims the rewritten path.
An entry serves neighbours in the manifest, and only those. It takes the
position of the first shell that it replaces, so every replaced shell
keeps its place relative to the routes around it. Any other route
between two shells ends the run, because an entry that reached across it
would move ahead of a route that a request matches first. The shells of
a run also have to agree on `fallback: false`, because an entry carries
one set of conditions.
A source page can therefore hold several runs, and a shell can belong to
none. That happens when the build resolves a different number of params
for neighbouring shells, which leaves them with different paths after
the resolved part.
This removes the multiplier that the number of prerendered combinations
applies to every route below the resolved params. We measured the same
in-progress feature branch of the v0 chat app as the previous changes in
this stack. Building it before and after this change removes 87% of the
routes that those changes left. Across the stack that branch loses 95%
of its routes.
This collapse follows `experimental.collapseAdapterRoutes`, which an
earlier change in this stack added. A build that sets it to `false`
emits one entry per shell.
**Verified with a [full deploy test
run](https://github.com/vercel/next.js/actions/runs/32577936779).**
---
.../next/src/build/adapter/build-complete.ts | 50 +++-
.../build/adapter/fallback-shell-runs.test.ts | 104 +++++++
.../src/build/adapter/fallback-shell-runs.ts | 263 ++++++++++++++++++
.../dynamic-routes-base-path.test.ts | 2 +-
.../dynamic-routes-cache-components.test.ts | 22 +-
.../dynamic-routes-no-root-params.test.ts | 27 +-
.../dynamic-routes-shell-prefixes.test.ts | 16 +-
7 files changed, 437 insertions(+), 47 deletions(-)
create mode 100644 packages/next/src/build/adapter/fallback-shell-runs.test.ts
create mode 100644 packages/next/src/build/adapter/fallback-shell-runs.ts
diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts
index 683480b5496c..2652682e0b37 100644
--- a/packages/next/src/build/adapter/build-complete.ts
+++ b/packages/next/src/build/adapter/build-complete.ts
@@ -56,6 +56,7 @@ import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
import { sortSortableRoutes } from '../../shared/lib/router/utils/sortable-routes'
import { defaultOverrides } from '../../server/require-hook'
import { generateRoutesManifest } from '../generate-routes-manifest'
+import { collectFallbackShellRuns } from './fallback-shell-runs'
import { Bundler } from '../../lib/bundler'
import { resolveCacheHandlerPathToFilesystem } from '../../lib/format-dynamic-import-path'
import { InvariantError } from '../../shared/lib/invariant-error'
@@ -2100,7 +2101,24 @@ export async function handleBuildComplete({
},
]
+ // Without this collapse the loop below emits one entry per shell.
+ const fallbackShellRuns = config.experimental.collapseAdapterRoutes
+ ? collectFallbackShellRuns(
+ routesManifest.dynamicRoutes,
+ (page) => prerenderManifest.dynamicRoutes[page]?.fallback === false
+ )
+ : undefined
+
for (const route of routesManifest.dynamicRoutes) {
+ // An earlier entry in this loop serves this shell.
+ if (fallbackShellRuns?.replacedPages.has(route.page)) {
+ continue
+ }
+
+ const fallbackShellRun = fallbackShellRuns?.byRepresentativePage.get(
+ route.page
+ )
+
const shouldLocalize = Boolean(config.i18n)
const routeRegex = getNamedRouteRegex(route.page, {
@@ -2110,7 +2128,29 @@ export async function handleBuildComplete({
const isFallbackFalse =
prerenderManifest.dynamicRoutes[route.page]?.fallback === false
- const sourceRegex = routeRegex.namedRegex.replace(
+ // An entry for a whole run of shells matches every prefix in that run.
+ // The destination copies the prefix that matched.
+ //
+ // This replacement runs on the pattern for the page, and `sourceRegex`
+ // below prefixes the result with the base path and the locale group. That
+ // order is deliberate. The search text anchors at `^`, and here that
+ // anchor is the start of the page path. On `sourceRegex` the same anchor
+ // is the start of the base path. A replacement there would match a base
+ // path such as `/de/x`, and it would rewrite that base path instead of
+ // the page path.
+ const pagePattern = fallbackShellRun
+ ? routeRegex.namedRegex.replace(
+ `^/${escapeStringRegexp(fallbackShellRun.prefixes[0])}/`,
+ `^/(?${fallbackShellRun.prefixes
+ .map((prefix) => escapeStringRegexp(prefix))
+ .join('|')})/`
+ )
+ : routeRegex.namedRegex
+ const pagePath = fallbackShellRun
+ ? path.posix.join('/', '$shellPrefix', fallbackShellRun.tail)
+ : route.page
+
+ const sourceRegex = pagePattern.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?${shouldLocalize ? '(?[^/]{1,})' : ''}`
)
@@ -2119,7 +2159,7 @@ export async function handleBuildComplete({
'/',
config.basePath,
shouldLocalize ? '/$nextLocale' : '',
- route.page
+ pagePath
) + getDestinationQuery(route.routeKeys)
const hasAppPages = Boolean(appPageKeys && appPageKeys.length > 0)
@@ -2157,7 +2197,7 @@ export async function handleBuildComplete({
// not match is then absent from that result, and the literal text
// `$rscSuffix` stays in the destination.
dynamicRoutes.push({
- source: route.page,
+ source: pagePath,
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$'
@@ -2173,7 +2213,7 @@ export async function handleBuildComplete({
// suffix, so each request resolves to the artifact that it asks for.
if (hasAppPages) {
dynamicRoutes.push({
- source: route.page + '.rsc',
+ source: pagePath + '.rsc',
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
@@ -2186,7 +2226,7 @@ export async function handleBuildComplete({
// needs basePath and locale handling if pages router
dynamicRoutes.push({
- source: route.page,
+ source: pagePath,
sourceRegex,
destination,
has: plainHas,
diff --git a/packages/next/src/build/adapter/fallback-shell-runs.test.ts b/packages/next/src/build/adapter/fallback-shell-runs.test.ts
new file mode 100644
index 000000000000..36d1f056a470
--- /dev/null
+++ b/packages/next/src/build/adapter/fallback-shell-runs.test.ts
@@ -0,0 +1,104 @@
+import { pageToRoute } from '../utils'
+import { collectFallbackShellRuns } from './fallback-shell-runs'
+import type { RoutesManifest } from '..'
+
+// The suites in `test/production/app-dir/adapter-dynamic-routes` cover the
+// shapes that a build produces, and they pin the entries that come out of them.
+// Most cases below cover something those suites cannot: inputs that make this
+// function decline. A build does not produce those inputs, and a guard that
+// stopped declining would collapse shells that are not safe to collapse, which
+// no snapshot would catch.
+//
+// The first case is a successful collapse, so that the file also shows the
+// shape of a result.
+
+/**
+ * A fallback shell, which the build derives from a source page.
+ */
+function shell(page: string, sourcePage: string) {
+ return pageToRoute(page, sourcePage)
+}
+
+/**
+ * Any other dynamic route, which carries no source page.
+ */
+function plain(page: string) {
+ return pageToRoute(page, undefined)
+}
+
+function collect(
+ routes: ReturnType[],
+ fallbackFalsePages: string[] = []
+) {
+ const result = collectFallbackShellRuns(
+ routes as RoutesManifest['dynamicRoutes'],
+ (page) => fallbackFalsePages.includes(page)
+ )
+
+ return {
+ runs: Object.fromEntries(result.byRepresentativePage),
+ replaced: [...result.replacedPages],
+ }
+}
+
+describe('collectFallbackShellRuns', () => {
+ it('collapses a run of shells that share a source page', () => {
+ expect(
+ collect([
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ plain('/[lang]/posts/[id]'),
+ ])
+ ).toEqual({
+ runs: {
+ '/de/posts/[id]': { prefixes: ['de', 'en'], tail: 'posts/[id]' },
+ },
+ replaced: ['/en/posts/[id]'],
+ })
+ })
+
+ it('keeps shells that another route separates', () => {
+ // The entry would take the position of the first shell, so it would move
+ // ahead of the route between the two.
+ expect(
+ collect([
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ plain('/other/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ ])
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells that disagree on `fallback: false`', () => {
+ // One entry carries one set of conditions, so it cannot serve both.
+ expect(
+ collect(
+ [
+ shell('/de/posts/[id]', '/[lang]/posts/[id]'),
+ shell('/en/posts/[id]', '/[lang]/posts/[id]'),
+ ],
+ ['/en/posts/[id]']
+ )
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells whose resolved segment is not the first one', () => {
+ // The prefix of a shell starts at the first segment, so `/docs` in front of
+ // the resolved segment puts these outside what the caller can rewrite.
+ expect(
+ collect([
+ shell('/docs/de/posts/[id]', '/docs/[lang]/posts/[id]'),
+ shell('/docs/en/posts/[id]', '/docs/[lang]/posts/[id]'),
+ ])
+ ).toEqual({ runs: {}, replaced: [] })
+ })
+
+ it('keeps shells that resolve every segment', () => {
+ // The pattern for `/de` ends after the prefix, so it has no slash for the
+ // caller to replace. A build cannot produce this shape, because it derives
+ // a fallback shell only from a prerender that leaves a param unresolved.
+ expect(collect([shell('/de', '/[lang]'), shell('/en', '/[lang]')])).toEqual(
+ { runs: {}, replaced: [] }
+ )
+ })
+})
diff --git a/packages/next/src/build/adapter/fallback-shell-runs.ts b/packages/next/src/build/adapter/fallback-shell-runs.ts
new file mode 100644
index 000000000000..89942220a6e5
--- /dev/null
+++ b/packages/next/src/build/adapter/fallback-shell-runs.ts
@@ -0,0 +1,263 @@
+/**
+ * One entry in the route table that a build passes to an adapter can serve
+ * several fallback shells.
+ *
+ * A fallback shell repeats the whole path of its source page, and it resolves
+ * the leading params of that page to concrete values. Those values form the
+ * prefix of the shell path, so the shells of one source page differ only in
+ * that prefix. A pattern can list several prefixes as alternatives, which lets
+ * one entry match them all.
+ *
+ * The shells that one entry serves are a run: a stretch of neighbours in the
+ * manifest. Adjacency is what makes a run safe to serve from one entry. That
+ * entry takes the position of the first shell of the run, which this file
+ * calls the representative, so every shell that the entry replaces keeps its
+ * place relative to the routes around it.
+ */
+
+import type { RoutesManifest } from '..'
+import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
+import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
+
+export type FallbackShellRun = {
+ /**
+ * The prefix of each shell of the run, in the order that the manifest lists
+ * them. The pattern of the entry holds these as alternatives, and the first
+ * one belongs to the representative.
+ */
+ prefixes: readonly string[]
+ /**
+ * The path that follows the prefix, without a leading slash. Every shell of
+ * the run shares it.
+ */
+ tail: string
+}
+
+export type FallbackShellRuns = {
+ /**
+ * The runs, keyed by the page of the representative of each.
+ */
+ byRepresentativePage: Map
+ /**
+ * The pages of the shells that a run serves, apart from the representative
+ * that keys it. The caller emits no entry of its own for these.
+ */
+ replacedPages: Set
+}
+
+/**
+ * A run while this file still collects it. The exported shape holds only what
+ * the caller needs in order to emit the entry.
+ */
+type PendingRun = {
+ /**
+ * The source page that every shell of the run repeats.
+ */
+ sourcePage: string
+ /**
+ * The path that every shell of the run has after its prefix.
+ */
+ tail: string
+ /**
+ * Whether the shells have `fallback: false`. Every shell of the run agrees on
+ * this value.
+ */
+ isFallbackFalse: boolean
+ /**
+ * The shells of the run, in the order that the manifest lists them.
+ */
+ shells: Array<{
+ /**
+ * The page of the shell.
+ */
+ page: string
+ /**
+ * The leading part of that page, which holds the values that the shell
+ * resolves for the params of the source page.
+ */
+ prefix: string
+ }>
+}
+
+/**
+ * Splits the page of a fallback shell into the prefix that holds its resolved
+ * param values, and the path that follows.
+ *
+ * Returns undefined when the page resolves nothing, or when the resolved
+ * segments are not consecutive from the first segment onwards.
+ */
+function splitShellPage(
+ page: string,
+ sourcePage: string
+): { prefix: string; tail: string } | undefined {
+ const pageSegments = page.split('/')
+ const sourceSegments = sourcePage.split('/')
+ if (pageSegments.length !== sourceSegments.length) {
+ return undefined
+ }
+
+ const resolved: number[] = []
+ for (let index = 0; index < pageSegments.length; index++) {
+ if (pageSegments[index] !== sourceSegments[index]) {
+ resolved.push(index)
+ }
+ }
+ if (resolved.length === 0) {
+ return undefined
+ }
+
+ // A shell resolves the leading params of its source page, so the resolved
+ // segments are consecutive and the first of them is the first segment of the
+ // path. `split` returns an empty string at index 0, so they start at index 1.
+ for (let position = 0; position < resolved.length; position++) {
+ if (resolved[position] !== position + 1) {
+ return undefined
+ }
+ }
+
+ // The source page declares a param at each resolved position, and the shell
+ // holds a value there. Anything else means the two pages differ for another
+ // reason, and the leading segments are not a prefix of resolved values.
+ for (const index of resolved) {
+ if (
+ !sourceSegments[index].startsWith('[') ||
+ pageSegments[index].startsWith('[')
+ ) {
+ return undefined
+ }
+ }
+
+ const tailStart = resolved.length + 1
+ return {
+ prefix: pageSegments.slice(1, tailStart).join('/'),
+ tail: pageSegments.slice(tailStart).join('/'),
+ }
+}
+
+/**
+ * Collects the runs of fallback shells that one entry can serve.
+ *
+ * The shells of a run are neighbours in the manifest that agree on:
+ *
+ * - The source page.
+ * - The path that follows the prefix.
+ * - The value of `fallback: false`.
+ *
+ * They have to be neighbours because the entry takes the position of the first
+ * shell of the run. Every shell that the entry replaces then keeps its place
+ * relative to the routes around it. Any other route between two shells ends the
+ * run, because an entry that reached across it would move ahead of a route that
+ * a request matches first.
+ *
+ * They have to agree on `fallback: false` because an entry carries one set of
+ * conditions.
+ *
+ * The caller builds one pattern for a run, and it lists the prefixes of the run
+ * as complete alternatives. For a source page `/[team]/[locale]/posts/[id]`
+ * with shells for `acme/en`, `acme/de` and `globex/en`, that pattern holds:
+ *
+ * ```
+ * (?acme/en|acme/de|globex/en)
+ * ```
+ *
+ * A pattern that offered a choice per param instead, such as
+ * `(acme|globex)/(en|de)`, would also match `globex/de`. The build never
+ * prerendered that pair, so a request for it would resolve to an output that
+ * does not exist, and it would then fall through to whichever route claims the
+ * rewritten path.
+ *
+ * A shell can belong to no run, and one source page can hold several runs. That
+ * happens when the build resolves a different number of params for neighbouring
+ * shells, because their prefixes then have different lengths and the paths that
+ * follow them differ.
+ *
+ * A run of only one shell has a single prefix, so an entry for it would match
+ * what the entry for that shell already matches. This function leaves such a
+ * shell out of the result.
+ */
+export function collectFallbackShellRuns(
+ dynamicRoutes: RoutesManifest['dynamicRoutes'],
+ hasFallbackFalse: (page: string) => boolean
+): FallbackShellRuns {
+ const runs: PendingRun[] = []
+ let current: PendingRun | undefined
+
+ for (const route of dynamicRoutes) {
+ // `pageToRoute` sets `sourcePage` only when the build passes it a source
+ // page, and the build does that for a fallback shell. The field therefore
+ // names the page whose path this shell repeats, and it is absent on every
+ // other route.
+ const { sourcePage } = route
+ const split =
+ sourcePage && sourcePage !== route.page
+ ? splitShellPage(route.page, sourcePage)
+ : undefined
+
+ // This route is not a shell that a run can hold, so it ends the run in
+ // progress.
+ if (!sourcePage || !split) {
+ current = undefined
+ continue
+ }
+
+ const isFallbackFalse = hasFallbackFalse(route.page)
+
+ if (
+ current &&
+ (current.sourcePage !== sourcePage ||
+ current.tail !== split.tail ||
+ current.isFallbackFalse !== isFallbackFalse)
+ ) {
+ current = undefined
+ }
+
+ if (!current) {
+ current = {
+ sourcePage,
+ tail: split.tail,
+ isFallbackFalse,
+ shells: [],
+ }
+ runs.push(current)
+ }
+
+ current.shells.push({ page: route.page, prefix: split.prefix })
+ }
+
+ const byRepresentativePage = new Map()
+ const replacedPages = new Set()
+
+ for (const run of runs) {
+ if (run.shells.length < 2) {
+ continue
+ }
+
+ // The caller replaces the escaped prefix at the start of the pattern for
+ // the representative, so this function keeps the run only when that pattern
+ // starts with the prefix.
+ //
+ // Two things make that true today. `getNamedRouteRegex` escapes each
+ // segment the same way, and a fallback shell keeps at least one param
+ // unresolved, so a slash always follows its prefix. This check holds the
+ // run back if either stops being true.
+ const [representative, ...replaced] = run.shells
+ const { namedRegex } = getNamedRouteRegex(representative.page, {
+ prefixRouteKeys: true,
+ })
+ if (
+ !namedRegex.startsWith(`^/${escapeStringRegexp(representative.prefix)}/`)
+ ) {
+ continue
+ }
+
+ byRepresentativePage.set(representative.page, {
+ prefixes: run.shells.map((shell) => shell.prefix),
+ tail: run.tail,
+ })
+ for (const shell of replaced) {
+ replacedPages.add(shell.page)
+ }
+ }
+
+ return { byRepresentativePage, replacedPages }
+}
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
index aacf699f4b04..080ce96def6e 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-base-path.test.ts
@@ -30,7 +30,7 @@ describe(`adapter dynamic routes (cache components, base path ${basePath})`, ()
const routing: AdapterRouting = await next.readJSON('build-complete.json')
// A base path prefixes the entries. It does not add or remove any.
- expect(routing.dynamicRoutes).toHaveLength(9)
+ expect(routing.dynamicRoutes).toHaveLength(7)
for (const route of routing.dynamicRoutes) {
expect(route.sourceRegex.startsWith(`^${basePath}`)).toBe(true)
diff --git a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
index be8ea8980716..6999f9be4de9 100644
--- a/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
+++ b/test/production/app-dir/adapter-dynamic-routes/dynamic-routes-cache-components.test.ts
@@ -41,19 +41,15 @@ describe('adapter dynamic routes (cache components)', () => {
expect(serializeDynamicRoutes(routing.dynamicRoutes))
.toMatchInlineSnapshot(`
- "9 entries
+ "7 entries
/[lang]
^[/]?/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
-> /[lang]$rscSuffix?nxtPlang=$nxtPlang
- /de/fallback-shell/[slug]
- ^[/]?/de/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
- -> /de/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
-
- /en/fallback-shell/[slug]
- ^[/]?/en/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
- -> /en/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
+ /$shellPrefix/fallback-shell/[slug]
+ ^[/]?/(?de|en)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
+ -> /$shellPrefix/fallback-shell/[slug]$rscSuffix?nxtPslug=$nxtPslug
/[lang]/fallback-shell/[slug]
^[/]?/(?[^/]+?)/fallback\\-shell/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
@@ -67,13 +63,9 @@ describe('adapter dynamic routes (cache components)', () => {
^[/]?/(?[^/]+?)/static(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$
-> /[lang]/static$rscSuffix?nxtPlang=$nxtPlang
- /de/[slug]
- ^[/]?/de/(?[^/]+?)(?