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/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ - -> /de/[slug]$rscSuffix?nxtPslug=$nxtPslug - - /en/[slug] - ^[/]?/en/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ - -> /en/[slug]$rscSuffix?nxtPslug=$nxtPslug + /$shellPrefix/[slug] + ^[/]?/(?de|en)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ + -> /$shellPrefix/[slug]$rscSuffix?nxtPslug=$nxtPslug /[lang]/[slug] ^[/]?/(?[^/]+?)/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ 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 index 912cd2c5c4c5..2cc553a0d35c 100644 --- 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 @@ -15,16 +15,19 @@ import { // - 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 two shapes alternate, so only neighbours of one shape collapse into a +// single entry. The two entries for the `acme.one-two,three` team that resolve +// both params are such a pair. The entry for the `sparse` team that resolves +// both params has an entry of the other shape on each side, so it stays as it +// is. // // 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. +// the one for `/sparse/[locale]/posts/[id]`. A collapsed entry takes the +// position of the first entry that it replaces, and a run of neighbours stops +// at any route of another shape, so every replaced entry keeps its place +// relative to the routes around it. describe('adapter dynamic routes (no root params)', () => { const { next } = nextTestSetup({ files: path.join(__dirname, 'no-root-params'), @@ -39,15 +42,11 @@ describe('adapter dynamic routes (no root params)', () => { expect(serializeDynamicRoutes(routing.dynamicRoutes)) .toMatchInlineSnapshot(` - "6 entries + "5 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 + /$shellPrefix/posts/[id] + ^[/]?/(?acme\\.one\\-two,three/de|acme\\.one\\-two,three/en)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ + -> /$shellPrefix/posts/[id]$rscSuffix?nxtPid=$nxtPid /acme.one-two,three/[locale]/posts/[id] ^[/]?/acme\\.one\\-two,three/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ 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 index 6d41d9b5c579..5135f84e59a3 100644 --- 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 @@ -29,19 +29,11 @@ describe('adapter dynamic routes (shell prefixes)', () => { expect(serializeDynamicRoutes(routing.dynamicRoutes)) .toMatchInlineSnapshot(` - "4 entries + "2 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 + /$shellPrefix/posts/[id] + ^[/]?/(?acme\\.one\\-two,three/de|acme\\.one\\-two,three/en|sparse/en)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ + -> /$shellPrefix/posts/[id]$rscSuffix?nxtPid=$nxtPid /[team]/[locale]/posts/[id] ^[/]?/(?[^/]+?)/(?[^/]+?)/posts/(?[^/]+?)(?\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$ From d69a7041f37c59230b43735aea662e144d85322d Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 24 Aug 2026 10:41:37 +0200 Subject: [PATCH 06/15] Turn off the adapter route collapses by default (#97774) `experimental.collapseAdapterRoutes` now defaults to `false`, so a build keeps one entry per route unless the project opts in. The changes below it in this stack stay as they are, and a project that sets the option to `true` gets the route table that they produce. The default starts off so that we can dogfood the behavior on selected apps before every build gets it. The default moves back to `true` once enough apps have run with it. The four fixtures under `test/production/app-dir/adapter-dynamic-routes` set the option, because their snapshots pin the collapsed table. A build of the Cache Components fixture without the option emits the 27 entries that the first commit in this stack recorded, which is what the option now turns off. --- packages/next/src/server/config-shared.ts | 6 ++++-- .../adapter-dynamic-routes/cache-components/next.config.js | 3 +++ .../app-dir/adapter-dynamic-routes/legacy/next.config.js | 3 +++ .../adapter-dynamic-routes/no-root-params/next.config.js | 3 +++ .../adapter-dynamic-routes/shell-prefixes/next.config.js | 3 +++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 2fb907ca3cf3..bcf2f2d1cef3 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -507,7 +507,9 @@ export interface ExperimentalConfig { * A collapsed entry resolves each request to the same output as the entries * that it replaces. * - * @default true + * The default is `false`, so a build keeps one entry per route. + * + * @default false */ collapseAdapterRoutes?: boolean useSkewCookie?: boolean @@ -2241,7 +2243,7 @@ export const defaultConfig = Object.freeze({ adapterPath: process.env.NEXT_ADAPTER_PATH || undefined, experimental: { coldCacheBadge: false, - collapseAdapterRoutes: true, + collapseAdapterRoutes: false, devValidationWorker: true, useSkewCookie: false, cssChunking: true, 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 index aff9b9b0b3ab..7a959dd71b06 100644 --- 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 @@ -3,6 +3,9 @@ */ const nextConfig = { cacheComponents: true, + // The option is off by default. These snapshots pin the collapsed route + // table, so the fixture enables it. + experimental: { collapseAdapterRoutes: 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', 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 index e44df5ff9034..d9fbaae44df9 100644 --- a/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js +++ b/test/production/app-dir/adapter-dynamic-routes/legacy/next.config.js @@ -7,6 +7,9 @@ const nextConfig = { // 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 option is off by default. These snapshots pin the collapsed route + // table, so the fixture enables it. + experimental: { collapseAdapterRoutes: true }, // 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', 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 index 0e051ae8a961..b5d103f83f69 100644 --- 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 @@ -3,6 +3,9 @@ */ const nextConfig = { cacheComponents: true, + // The option is off by default. These snapshots pin the collapsed route + // table, so the fixture enables it. + experimental: { collapseAdapterRoutes: 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', 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 index 0e051ae8a961..b5d103f83f69 100644 --- 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 @@ -3,6 +3,9 @@ */ const nextConfig = { cacheComponents: true, + // The option is off by default. These snapshots pin the collapsed route + // table, so the fixture enables it. + experimental: { collapseAdapterRoutes: 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', From 4c4d523084e53bcb03e1f5bebada74dc4c773aec Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:33:46 +0200 Subject: [PATCH 07/15] Migrate remaining async blocks to async closures (#97701) **View diff without whitespaces** Fix remaining cases of https://github.com/vercel/next.js/pull/97666 - Mostly replacing async blocks with async closures - And second commit: some more making closures sync where possible --- crates/next-api/src/module_graph.rs | 3 +-- crates/next-api/src/versioned_content_map.rs | 8 ++------ crates/next-core/src/emit.rs | 6 +----- crates/next-core/src/next_config.rs | 5 ++--- .../turbo-tasks-backend/benches/overhead.rs | 4 ++-- .../turbo-tasks-backend/tests/collectibles.rs | 14 ++++++------- .../crates/turbo-tasks-backend/tests/debug.rs | 6 +++--- .../tests/derive_value_to_string.rs | 14 ++++++------- .../turbo-tasks-backend/tests/detached.rs | 4 ++-- .../tests/dirty_in_progress.rs | 2 +- .../turbo-tasks-backend/tests/effects.rs | 2 +- .../tests/emptied_cells.rs | 2 +- .../tests/emptied_cells_session_dependent.rs | 2 +- .../tests/filter_unused_args.rs | 10 +++++----- .../tests/hashed_cell_mode.rs | 4 ++-- .../turbo-tasks-backend/tests/immutable.rs | 2 +- .../turbo-tasks-backend/tests/invalidation.rs | 4 ++-- .../turbo-tasks-backend/tests/operation_vc.rs | 2 +- .../tests/random_change.rs | 2 +- .../tests/read_ref_cell.rs | 2 +- .../turbo-tasks-backend/tests/recompute.rs | 6 +++--- .../tests/recompute_collectibles.rs | 2 +- .../turbo-tasks-backend/tests/resolved_vc.rs | 4 ++-- .../tests/shrink_to_fit.rs | 2 +- .../tests/top_level_task_consistency.rs | 14 ++++++------- .../tests/trait_ref_cell.rs | 2 +- .../tests/trait_ref_cell_mode.rs | 4 ++-- .../turbo-tasks-backend/tests/turbofmt.rs | 4 ++-- .../crates/turbo-tasks-fetch/tests/fetch.rs | 20 +++++++++---------- turbopack/crates/turbo-tasks-fs/src/disk.rs | 4 ++-- .../tests/task_input.rs | 5 ++--- .../tests/value_debug.rs | 2 +- .../crates/turbo-tasks/src/completion.rs | 10 +++++----- .../crates/turbo-tasks/src/task/task_input.rs | 4 ++-- .../turbopack-core/src/chunk/chunk_group.rs | 3 +-- .../turbopack-core/src/module_graph/mod.rs | 3 +-- .../module_graph/style_groups_graph/mod.rs | 7 +++---- turbopack/crates/turbopack-core/src/output.rs | 4 ++-- .../crates/turbopack-css/src/chunk/mod.rs | 5 ++--- .../turbopack-test-utils/src/snapshot.rs | 8 ++------ 40 files changed, 95 insertions(+), 116 deletions(-) diff --git a/crates/next-api/src/module_graph.rs b/crates/next-api/src/module_graph.rs index 58568a60afff..1a2de21544b6 100644 --- a/crates/next-api/src/module_graph.rs +++ b/crates/next-api/src/module_graph.rs @@ -300,11 +300,10 @@ impl ServerActionsGraphs { let result = self .0 .iter() - .map(async |graph| { + .map(|graph| { graph .get_server_actions_for_endpoint(entry, rsc_asset_context) .owned() - .await }) .try_flat_join() .await?; diff --git a/crates/next-api/src/versioned_content_map.rs b/crates/next-api/src/versioned_content_map.rs index b6edcb316b39..863621d28830 100644 --- a/crates/next-api/src/versioned_content_map.rs +++ b/crates/next-api/src/versioned_content_map.rs @@ -295,12 +295,8 @@ impl VersionedContentMap { }; let keys = keys .into_iter() - .map(|path| { - let root = root.clone(); - async move { Ok(root.get_path_to(&path).map(RcStr::from)) } - }) - .try_flat_join() - .await?; + .filter_map(|path| root.get_path_to(&path).map(RcStr::from)) + .collect(); Ok(Vc::cell(keys)) } diff --git a/crates/next-core/src/emit.rs b/crates/next-core/src/emit.rs index bb894b4ff86f..4bdecc721881 100644 --- a/crates/next-core/src/emit.rs +++ b/crates/next-core/src/emit.rs @@ -105,11 +105,7 @@ pub async fn emit_assets( let first = iter.next().unwrap(); let ext: RcStr = path.extension().unwrap_or_default().into(); let conflicts = iter - .map(async |next| { - assets_diff(*next, *first, ext.clone(), node_root.clone()) - .owned() - .await - }) + .map(|next| assets_diff(*next, *first, ext.clone(), node_root.clone()).owned()) .try_flat_join() .await?; if let Some(detail) = conflicts.into_iter().next() { diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index be73ae1f64b8..e60364275766 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1883,12 +1883,11 @@ impl OutputFileTracingIncludesExcludes { .iter() .flat_map(|pattern| pattern.iter()) .filter_map(|pattern| pattern.as_str()) - .map(async |pattern_str| { + .map(|pattern_str| { let (glob, root) = relativize_glob(pattern_str, &project_path)?; Ok((RcStr::from(glob), root)) }) - .try_join() - .await?; + .collect::>>()?; Ok((route_pattern, file_patterns)) }) .try_join() diff --git a/turbopack/crates/turbo-tasks-backend/benches/overhead.rs b/turbopack/crates/turbo-tasks-backend/benches/overhead.rs index 0072c4f677cb..358b81481532 100644 --- a/turbopack/crates/turbo-tasks-backend/benches/overhead.rs +++ b/turbopack/crates/turbo-tasks-backend/benches/overhead.rs @@ -68,7 +68,7 @@ pub fn overhead(c: &mut Criterion) { } start.elapsed() }) - .then(|r| async { r.unwrap() }) + .then(async |r| r.unwrap()) }); }); @@ -113,7 +113,7 @@ pub fn overhead(c: &mut Criterion) { while futures.next().await.is_some() {} start.elapsed() }) - .then(|r| async { r.unwrap() }) + .then(async |r| r.unwrap()) }); }, ); diff --git a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs index d9dd58894e17..81130f1e4ed5 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/collectibles.rs @@ -19,7 +19,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_transitive_emitting() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!("")); let result_val = result_op.connect().strongly_consistent().await?; @@ -38,7 +38,7 @@ async fn test_transitive_emitting() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_transitive_emitting_indirect() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!("")); let collectibles_op = my_transitive_emitting_function_collectibles(rcstr!(""), rcstr!("")); @@ -57,7 +57,7 @@ async fn test_transitive_emitting_indirect() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_multi_emitting() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let result_op = my_multi_emitting_function(); let result_val = result_op.connect().strongly_consistent().await?; @@ -76,7 +76,7 @@ async fn test_multi_emitting() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn taking_collectibles() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let result_op = my_collecting_function(); let result_val = result_op.connect().strongly_consistent().await?; let list = result_op.take_collectibles::>(); @@ -92,7 +92,7 @@ async fn taking_collectibles() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn taking_collectibles_extra_layer() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let result_op = my_collecting_function_indirect(); let result_val = result_op.connect().strongly_consistent().await?; let list = result_op.take_collectibles::>(); @@ -108,7 +108,7 @@ async fn taking_collectibles_extra_layer() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn taking_collectibles_parallel() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let result_op = my_transitive_emitting_function(rcstr!(""), rcstr!("a")); let result_val = result_op.connect().strongly_consistent().await?; let list = result_op.take_collectibles::>(); @@ -150,7 +150,7 @@ async fn taking_collectibles_parallel() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn taking_collectibles_with_resolve() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let result_op = my_transitive_emitting_function_with_resolve(rcstr!("resolve")); result_op.connect().strongly_consistent().await?; let list = result_op.take_collectibles::>(); diff --git a/turbopack/crates/turbo-tasks-backend/tests/debug.rs b/turbopack/crates/turbo-tasks-backend/tests/debug.rs index 340d690919f6..4810c52474bb 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/debug.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/debug.rs @@ -155,7 +155,7 @@ async fn test_struct_transparent_debug() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_option_debug() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let a = StructWithOption { option: None }.resolved_cell(); assert_eq!( format!( @@ -189,7 +189,7 @@ async fn test_struct_option_debug() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_vec_debug() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let a = StructWithVec { vec: Vec::new() }.resolved_cell(); assert_eq!( format!( @@ -223,7 +223,7 @@ async fn test_struct_vec_debug() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_ignore_debug() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let a = StructWithIgnore { dont_ignore: 42, ignore: Mutex::new(()), diff --git a/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs b/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs index d1611692ad5c..e752c8ca58d7 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/derive_value_to_string.rs @@ -103,7 +103,7 @@ enum MixedEnum { /// No attribute: delegates to Display::to_string(self). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_display_delegation() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let v: ResolvedVc> = ResolvedVc::upcast(SimpleDisplay(42).resolved_cell()); assert_eq!( @@ -119,7 +119,7 @@ async fn test_display_delegation() { /// FormatAutoFields on structs: named fields, positional fields, and constant strings. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_format_strings() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let v1: ResolvedVc> = ResolvedVc::upcast( NamedFields { name: "foo".into(), @@ -155,7 +155,7 @@ async fn test_struct_format_strings() { /// DirectExpr form: single expression delegation. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_direct_expr() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let v: ResolvedVc> = ResolvedVc::upcast( DirectExpr { name: "hello".into(), @@ -176,7 +176,7 @@ async fn test_struct_direct_expr() { /// FormatExprs on structs: format string with explicit expressions, including Vc delegation. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_struct_format_exprs() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let v1: ResolvedVc> = ResolvedVc::upcast( FormatExprs { name: "test".into(), @@ -210,7 +210,7 @@ async fn test_struct_format_exprs() { /// Enum with per-variant auto-field format strings and default variant names. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_enum_variants() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { // Per-variant attributes assert_eq!( &*to_string_operation(ResolvedVc::upcast(Kind::Module.resolved_cell())) @@ -261,7 +261,7 @@ async fn test_enum_variants() { /// Enum with mixed forms: constant literal, Vc delegation, and format exprs. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_mixed_enum() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { assert_eq!( &*to_string_operation(ResolvedVc::upcast(MixedEnum::Literal.resolved_cell())) .read_strongly_consistent() @@ -330,7 +330,7 @@ enum TortureEnum { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_torture_enum() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let named_resolved = NamedFields { name: "x".into(), count: 1, diff --git a/turbopack/crates/turbo-tasks-backend/tests/detached.rs b/turbopack/crates/turbo-tasks-backend/tests/detached.rs index 28106567c28a..8ca612f1c546 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/detached.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/detached.rs @@ -17,7 +17,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_spawns_detached() -> anyhow::Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { println!("test_spawns_detached"); // HACK: The watch channel we use has an incorrect implementation of `TraceRawVcs`, just // disable GC for the test so this can't cause any problems. @@ -86,7 +86,7 @@ async fn spawns_detached( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_spawns_detached_changing() -> anyhow::Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); // HACK: The watch channel we use has an incorrect implementation of `TraceRawVcs` prevent_gc(); diff --git a/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs b/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs index 6a95c75e1701..61906dd8c0e1 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/dirty_in_progress.rs @@ -13,7 +13,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_dirty_in_progress() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let cases = [ (1, 3, 2, 2, ""), (11, 13, 12, 42, "12"), diff --git a/turbopack/crates/turbo-tasks-backend/tests/effects.rs b/turbopack/crates/turbo-tasks-backend/tests/effects.rs index dc02eb17256c..1ae31195980d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/effects.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/effects.rs @@ -128,7 +128,7 @@ impl CapturedEffect for TestEffectCaptured { async fn apply(&self) -> Result<(), ApplyError> { let body = if self.content { - Some(|| async { + Some(async || { self.shared.total_applies.fetch_add(1, Ordering::Relaxed); *self .shared diff --git a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs index 7f42e9e7d739..fb3d041480db 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells.rs @@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_emptied_cells() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { let input_op = get_state_operation(); let input_vc = input_op.resolve().strongly_consistent().await?; let input = input_op.read_strongly_consistent().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs index 3630de941c65..5df1a5bf9a5b 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/emptied_cells_session_dependent.rs @@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_emptied_cells_session_dependent() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { let input_op = get_state_operation(); let input_vc = input_op.resolve().strongly_consistent().await?; let input = input_op.read_strongly_consistent().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs b/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs index b7d214d65b84..9c4aade7046d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/filter_unused_args.rs @@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_filtered_impl_method_args() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let uses_arg = UsesArg(0).cell(); @@ -40,7 +40,7 @@ async fn test_filtered_impl_method_args() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_filtered_trait_method_args() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let uses_arg = UsesArg(0).cell(); @@ -70,7 +70,7 @@ async fn test_filtered_trait_method_args() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_filtered_impl_method_self() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let uses_arg = UsesArg(0).cell(); @@ -106,7 +106,7 @@ async fn test_filtered_impl_method_self() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_filtered_trait_method_self() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let uses_arg = UsesArg(0).cell(); @@ -142,7 +142,7 @@ async fn test_filtered_trait_method_self() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_filtered_plain_method_args() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); assert_eq!( diff --git a/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs b/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs index 94ee2556f390..d7464ea6591a 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/hashed_cell_mode.rs @@ -72,7 +72,7 @@ async fn consume_hashed(input: ResolvedVc) -> Result> { /// Test 1: When the value changes, the consumer SHOULD be invalidated and re-execute. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_hashed_cell_mode_change_triggers_invalidation() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { let state_op = create_state_operation(); let state_vc = state_op.resolve().strongly_consistent().await?; let state = state_op.read_strongly_consistent().await?; @@ -103,7 +103,7 @@ async fn test_hashed_cell_mode_change_triggers_invalidation() { /// With `serialization = "hash"`, the consumer should not be re-executed. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_hashed_cell_mode_equal_value_no_invalidation() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { let state_op = create_state_operation(); let state_vc = state_op.resolve().strongly_consistent().await?; let state = state_op.read_strongly_consistent().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs index d46f5adadbab..ca7502720609 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/immutable.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/immutable.rs @@ -10,7 +10,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_hidden_mutate() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = *create_input().to_resolved().await?; input.await?.state.set(1); diff --git a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs index e834f17fa1e7..558825881439 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/invalidation.rs @@ -19,7 +19,7 @@ fn create_state_operation() -> Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_invalidation_map() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { let state_op = create_state_operation(); let state_vc = state_op.resolve().strongly_consistent().await?; let state = state_op.read_strongly_consistent().await?; @@ -99,7 +99,7 @@ async fn get_value(map: OperationVc, key: String) -> Result Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_use_operations() -> Result<()> { - run(®ISTRATION, || async { + run(®ISTRATION, async || { assert_eq!(*use_operations().read_strongly_consistent().await?, 42); Ok(()) }) diff --git a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs index 27b128735878..a51d1e678968 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/random_change.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/random_change.rs @@ -11,7 +11,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_random_change() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let state_op = make_state_operation(); let state_vc = state_op.resolve().strongly_consistent().await?; let state = state_op.read_strongly_consistent().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs b/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs index 83c816a23d20..eb1242fc4bac 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/read_ref_cell.rs @@ -15,7 +15,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_read_ref() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let counter = Counter::cell(Counter { value: Mutex::new((0, Default::default())), diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs index 220c0772c296..4920d62b67e1 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute.rs @@ -12,7 +12,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = ChangingInput { state: State::new(1), @@ -63,7 +63,7 @@ async fn recompute() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn immutable_analysis() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = ChangingInput { state: State::new(1), @@ -149,7 +149,7 @@ async fn compute2(input: Vc) -> Result> { /// This tests the basic dependency propagation through a simple two-task chain. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute_dependency() { - run(®ISTRATION, || async { + run(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = *get_dependency_input().to_resolved().await?; // Reset state to 1 at the start of each iteration (important for multi-run tests) diff --git a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs index ba296a62146b..49da89de293d 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/recompute_collectibles.rs @@ -14,7 +14,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recompute() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = *ChangingInput::new(1).to_resolved().await?; let input2 = *ChangingInput::new(2).to_resolved().await?; diff --git a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs index fe87601a6276..e53ce97f2860 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/resolved_vc.rs @@ -118,7 +118,7 @@ async fn test_into_future() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sidecast() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let concrete_value = ImplementsAAndB.resolved_cell(); let as_a = ResolvedVc::upcast::>(concrete_value); let as_b = ResolvedVc::try_sidecast::>(as_a); @@ -132,7 +132,7 @@ async fn test_sidecast() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_downcast() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let as_base: Vc> = Vc::upcast(ImplementsSubImplemented.cell()); let ref_base = as_base.into_trait_ref().await?; // The concrete value implements SubImplemented, so downcasting the already-read diff --git a/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs b/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs index cd6c62251f9d..314f052e7ef0 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/shrink_to_fit.rs @@ -13,7 +13,7 @@ struct Wrapper(Vec); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_shrink_to_fit() -> Result<()> { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn capacity_operation(wrapper: ResolvedVc) -> Result> { Ok(Vc::cell(wrapper.await?.capacity())) diff --git a/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs b/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs index 4acf9ee08e34..3e59ef4e03ec 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/top_level_task_consistency.rs @@ -26,7 +26,7 @@ async fn returns_value_operation() -> Result> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[should_panic] async fn test_eventual_read_in_top_level_task_fails() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { // This should fail because we're in a top-level task (run_once) // and doing an eventually consistent read (default .await) returns_value_operation().connect().await @@ -37,7 +37,7 @@ async fn test_eventual_read_in_top_level_task_fails() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_cell_read_in_top_level_task_succeeds() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { let cell = returns_value_operation() .resolve() .strongly_consistent() @@ -52,7 +52,7 @@ async fn test_cell_read_in_top_level_task_succeeds() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_manual_mark_unmark_top_level_task() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { // We're in a top-level task initially, but let's unmark it unmark_top_level_task_may_leak_eventually_consistent_state(); @@ -83,9 +83,7 @@ async fn test_manual_mark_top_level_task_causes_error() { Ok(Value { value: 42 }.cell()) } - run_once(®ISTRATION, || async { - operation().read_strongly_consistent().await - }) - .await - .unwrap() + run_once(®ISTRATION, || operation().read_strongly_consistent()) + .await + .unwrap() } diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs index f0d0710d2137..dd44ed3706e8 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs @@ -15,7 +15,7 @@ static REGISTRATION: Registration = register!(); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn trait_ref() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let counter = Counter::cell(Counter { value: Mutex::new((0, Default::default())), diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs index 055d23a36f6c..3418c4dd7971 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs @@ -13,7 +13,7 @@ static REGISTRATION: Registration = register!(); // value is equal. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_shared_cell_mode() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = CellIdSelector { value: 42, @@ -49,7 +49,7 @@ async fn test_trait_ref_shared_cell_mode() { // value is equal. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trait_ref_new_cell_mode() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { unmark_top_level_task_may_leak_eventually_consistent_state(); let input = CellIdSelector { value: 42, diff --git a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs index c7936bca43c9..c4255c2a1784 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/turbofmt.rs @@ -29,7 +29,7 @@ async fn turbobail_operation(value: ResolvedVc) -> anyhow::Result Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn errors_on_failed_connection() { let _guard = GLOBAL_TEST_LOCK.lock().await; - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::value] struct FetchOutput( ReadRef, @@ -204,12 +204,10 @@ async fn errors_on_failed_connection() { let err_kind = err.kind.await?; let err_url = err.url.owned().await?; - let issue_vc = err_vc.to_issue(IssueSeverity::Error, get_issue_context().owned().await?); + let issue_vc = + err_vc.to_issue(IssueSeverity::Error, get_issue_context().owned().await?); let issue = issue_vc.await?; - let issue_description = issue - .description() - .await? - .expect("description is not None"); + let issue_description = issue.description().await?.expect("description is not None"); Ok(FetchOutput(err_kind, err_url, issue, issue_description).cell()) } @@ -219,16 +217,18 @@ async fn errors_on_failed_connection() { // Other values (e.g. domain name, reserved IP address block) may result in long timeouts. let url = rcstr!("http://127.0.0.1:0/foo.woff"); let FetchOutput(err_kind, err_url, issue, issue_description) = - &*fetch_operation(url.clone()).read_strongly_consistent().await?; + &*fetch_operation(url.clone()) + .read_strongly_consistent() + .await?; assert!(matches!(**err_kind, FetchErrorKind::Connect)); assert_eq!(*err_url, url); assert_eq!(issue.severity(), IssueSeverity::Error); assert_eq!( - issue_description.to_unstyled_string(), - "There was an issue establishing a connection while requesting http://127.0.0.1:0/foo.woff" - ); + issue_description.to_unstyled_string(), + "There was an issue establishing a connection while requesting http://127.0.0.1:0/foo.woff" + ); anyhow::Ok(()) }) .await diff --git a/turbopack/crates/turbo-tasks-fs/src/disk.rs b/turbopack/crates/turbo-tasks-fs/src/disk.rs index 3db12ac5546f..cdbd4df0cbab 100644 --- a/turbopack/crates/turbo-tasks-fs/src/disk.rs +++ b/turbopack/crates/turbo-tasks-fs/src/disk.rs @@ -1144,7 +1144,7 @@ impl FileSystem for DiskFileSystem { async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> { let body = self.content.as_ref().map(|content| { - || async { self.apply_inner(content).await.map_err(AnyhowWrapper::from) } + async || self.apply_inner(content).await.map_err(AnyhowWrapper::from) }); self.inner .effect_state_storage @@ -1347,7 +1347,7 @@ impl FileSystem for DiskFileSystem { async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> { let body = self.content.as_ref().map(|content| { - || async { self.apply_inner(content).await.map_err(AnyhowWrapper::from) } + async || self.apply_inner(content).await.map_err(AnyhowWrapper::from) }); self.inner .effect_state_storage diff --git a/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs b/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs index 83c460b33721..1e3f9df9e131 100644 --- a/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs +++ b/turbopack/crates/turbo-tasks-macros-tests/tests/task_input.rs @@ -22,7 +22,7 @@ fn one_unnamed_field(input: OneUnnamedField) -> Vc { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tests() { - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn equality_operation() -> Result> { Ok(Vc::cell(ReadRef::ptr_eq( @@ -30,8 +30,7 @@ async fn tests() { &Completion::immutable().await?, ))) } - equality_operation().read_strongly_consistent().await?; - anyhow::Ok(()) + equality_operation().read_strongly_consistent().await }) .await .unwrap() diff --git a/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs b/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs index 5e6c49045c07..93fc619b94e3 100644 --- a/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs +++ b/turbopack/crates/turbo-tasks-macros-tests/tests/value_debug.rs @@ -21,7 +21,7 @@ async fn ignored_indexes() { i32, ); - run_once(®ISTRATION, || async { + run_once(®ISTRATION, async || { #[turbo_tasks::function(operation, root)] async fn value_debug_format_operation() -> Result> { let input = IgnoredIndexes(-1, 2, -3); diff --git a/turbopack/crates/turbo-tasks/src/completion.rs b/turbopack/crates/turbo-tasks/src/completion.rs index f6adc63b1745..a3507de970ef 100644 --- a/turbopack/crates/turbo-tasks/src/completion.rs +++ b/turbopack/crates/turbo-tasks/src/completion.rs @@ -61,11 +61,11 @@ impl Completions { } else { self.0 .iter() - .map(|&c| async move { - // Wraps the completion in a new completion. This makes it cheaper to restore - // since it doesn't need to restore the original task resp task chain. - wrap(*c).await?; - Ok(()) + .map(|&c| { + // Wraps the completion in a new completion. This makes it cheaper to + // restore since it doesn't need to restore the + // original task resp task chain. + wrap(*c) }) .try_join() .await?; diff --git a/turbopack/crates/turbo-tasks/src/task/task_input.rs b/turbopack/crates/turbo-tasks/src/task/task_input.rs index d45e21d99b05..3755af62db33 100644 --- a/turbopack/crates/turbo-tasks/src/task/task_input.rs +++ b/turbopack/crates/turbo-tasks/src/task/task_input.rs @@ -509,8 +509,8 @@ where { fn resolve_input(&self) -> impl Future> + Send + '_ { self.as_ref().map_either( - |l| async move { anyhow::Ok(Self(Either::Left(l.resolve_input().await?))) }, - |r| async move { anyhow::Ok(Self(Either::Right(r.resolve_input().await?))) }, + async |l| anyhow::Ok(Self(Either::Left(l.resolve_input().await?))), + async |r| anyhow::Ok(Self(Either::Right(r.resolve_input().await?))), ) } diff --git a/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs b/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs index 332fd9213dc9..8b118567e53a 100644 --- a/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs +++ b/turbopack/crates/turbopack-core/src/chunk/chunk_group.rs @@ -116,11 +116,10 @@ pub async fn make_chunk_group( let async_loaders = async_modules .iter() .copied() - .map(async |module| { + .map(|module| { chunking_context .async_loader_chunk_item(*module, *module_graph, async_availability_info) .to_resolved() - .await }) .try_join() .await?; diff --git a/turbopack/crates/turbopack-core/src/module_graph/mod.rs b/turbopack/crates/turbopack-core/src/module_graph/mod.rs index 6d8480a32125..29d9f3878454 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/mod.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/mod.rs @@ -751,14 +751,13 @@ impl ImportTracer for ModuleGraphImportTracer { // import/require/dynamic-import?) let path = path .into_iter() - .map(async |n| { + .map(|n| { graph .graph .node_weight(n) .unwrap() // This is safe since `astar`` only returns indices from the graph .module() .ident() - .await }) .try_join() .await?; diff --git a/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs b/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs index 51f98ace474f..f415c81f9dcb 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/style_groups_graph/mod.rs @@ -401,7 +401,7 @@ async fn collect_chunk_groups( // order. let mut ids: Vec = Vec::new(); let mut seen: FxHashSet = FxHashSet::default(); - let mut handle_module = async |module| -> Result<()> { + let mut handle_module = |module| { let id_slot = match module_id_map.entry(module) { Entry::Occupied(e) => *e.get(), Entry::Vacant(e) => { @@ -420,19 +420,18 @@ async fn collect_chunk_groups( { ids.push(id); } - Ok(()) }; for item in items_in_postorder { match item { ModuleOrBatch::Batch(batch) => { for &module in &batch.await?.modules { - handle_module(module).await?; + handle_module(module); } } ModuleOrBatch::Module(module) => { if let Some(chunkable_module) = ResolvedVc::try_downcast(module) { - handle_module(chunkable_module).await?; + handle_module(chunkable_module); } } ModuleOrBatch::None(_) => {} diff --git a/turbopack/crates/turbopack-core/src/output.rs b/turbopack/crates/turbopack-core/src/output.rs index 76eb209cb2f8..528feae62751 100644 --- a/turbopack/crates/turbopack-core/src/output.rs +++ b/turbopack/crates/turbopack-core/src/output.rs @@ -278,8 +278,8 @@ pub async fn expand_output_assets( inner_output_assets: bool, ) -> Result>>> { let edges = AdjacencyMap::new() - .visit(inputs, async |input| { - get_referenced_assets(inner_output_assets, input).await + .visit(inputs, |input| { + get_referenced_assets(inner_output_assets, input) }) .await .completed()? diff --git a/turbopack/crates/turbopack-css/src/chunk/mod.rs b/turbopack/crates/turbopack-css/src/chunk/mod.rs index b198347cc7d4..bddee48b2565 100644 --- a/turbopack/crates/turbopack-css/src/chunk/mod.rs +++ b/turbopack/crates/turbopack-css/src/chunk/mod.rs @@ -549,7 +549,7 @@ impl ChunkType for CssChunkType { let content = CssChunkContent { chunk_items: chunk_items .iter() - .map(async |ChunkItemWithAsyncModuleInfo { chunk_item, .. }| { + .map(|ChunkItemWithAsyncModuleInfo { chunk_item, .. }| { let Some(chunk_item) = ResolvedVc::try_downcast::>(*chunk_item) else { @@ -558,8 +558,7 @@ impl ChunkType for CssChunkType { // CSS doesn't need to care about async_info, so we can discard it Ok(chunk_item) }) - .try_join() - .await?, + .collect::>>()?, } .cell(); Ok(Vc::upcast(CssChunk::new(*chunking_context, content))) diff --git a/turbopack/crates/turbopack-test-utils/src/snapshot.rs b/turbopack/crates/turbopack-test-utils/src/snapshot.rs index 06391d4a8d33..a12ddec77995 100644 --- a/turbopack/crates/turbopack-test-utils/src/snapshot.rs +++ b/turbopack/crates/turbopack-test-utils/src/snapshot.rs @@ -5,7 +5,7 @@ use regex::Regex; use rustc_hash::{FxHashMap, FxHashSet}; use similar::TextDiff; use turbo_rcstr::RcStr; -use turbo_tasks::{ReadRef, TryJoinIterExt, Vc}; +use turbo_tasks::{ReadRef, Vc}; use turbo_tasks_fs::{ DirectoryContent, DirectoryEntry, File, FileContent, FileSystemEntryType, FileSystemPath, }; @@ -187,11 +187,7 @@ async fn diff_paths( ) -> Result> { let mut map = left .iter() - .map(async |p| Ok((p.path.clone(), p.clone()))) - .try_join() - .await? - .iter() - .cloned() + .map(|p| (p.path.clone(), p.clone())) .collect::>(); for p in right { map.remove(&p.path); From 24f975677e6c628f469f56e5b11dc0b7be16496e Mon Sep 17 00:00:00 2001 From: "Eddy (Frontend Engineer)" <101346918+leejpsd@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:37:53 +0900 Subject: [PATCH 08/15] docs(adapters): document assetsHashes and routing.middlewareMatchers (#96536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While writing an adapter against `16.3.0-canary.107` I noticed the adapter docs are missing two fields that the shipped `NextAdapter` types already have. **`assetsHashes`** — every `PAGES` / `PAGES_API` / `APP_PAGE` / `APP_ROUTE` / `MIDDLEWARE` output carries it right next to `assets`, and it's declared with a doc comment in `build-complete.d.ts`, but none of the five shapes in Output Types mention it. **`routing.middlewareMatchers`** — dumping the `routing` object from a real `onBuildComplete` call gives: ``` afterFiles, beforeFiles, beforeMiddleware, dynamicRoutes, fallback, middlewareMatchers, onMatch, rsc, shouldNormalizeNextData ``` but the docs list eight of those nine, everywhere the interface appears: the Creating an Adapter snippet (which says "The interface is defined as follows"), the API Reference parameter list, and Routing Information. This one feels worth fixing soon — an adapter that does its own request matching from the documented fields alone has no way to decide when middleware should run. My guess for why nobody has hit it: if you pass `routes: routing` wholesale into `resolveRoutes` from `@next/routing`, everything works without ever looking at the field. The wording I added comes from the doc comments in the shipped types, not my own descriptions. `middlewareMatchers` is inserted where the type puts it (right after `beforeMiddleware`). For what it's worth: I checked the rest of the section against the same build while I was at it — the `output: 'export'` behavior, the prerender classification fields, `pprChain.headers`, the fallback fields, the immutable-assets flow, and the `@next/routing` params/result — and everything else matched the docs. These two were the only gaps I found. --- .../03-api-reference/07-adapters/02-creating-an-adapter.mdx | 1 + .../01-app/03-api-reference/07-adapters/03-api-reference.mdx | 1 + docs/01-app/03-api-reference/07-adapters/09-output-types.mdx | 5 +++++ .../03-api-reference/07-adapters/10-routing-information.mdx | 4 ++++ 4 files changed, 11 insertions(+) diff --git a/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx index d3e51f2db4fd..b3fcc849e57a 100644 --- a/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx +++ b/docs/01-app/03-api-reference/07-adapters/02-creating-an-adapter.mdx @@ -48,6 +48,7 @@ export interface NextAdapter { onBuildComplete?: (ctx: { routing: { beforeMiddleware: Array + middlewareMatchers: Array beforeFiles: Array afterFiles: Array dynamicRoutes: Array diff --git a/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx index beac22e568e4..b1a4b5516fa7 100644 --- a/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx +++ b/docs/01-app/03-api-reference/07-adapters/03-api-reference.mdx @@ -24,6 +24,7 @@ Called after the build process completes with detailed information about routes - `context.routing`: Object containing Next.js routing phases and metadata - `routing.beforeMiddleware`: Routes executed before middleware (includes header and redirect handling) + - `routing.middlewareMatchers`: Middleware matcher definitions for this build, used to decide whether middleware should be invoked for a given request - `routing.beforeFiles`: Rewrite routes checked before filesystem route matching - `routing.afterFiles`: Rewrite routes checked after filesystem route matching - `routing.dynamicRoutes`: Dynamic route matching table diff --git a/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx index d4e7a076ce1e..c2c01defc2c4 100644 --- a/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx +++ b/docs/01-app/03-api-reference/07-adapters/09-output-types.mdx @@ -30,6 +30,7 @@ React pages from the `pages/` directory: sourcePage: string // Original source file path in pages/ directory runtime: 'nodejs' | 'edge' assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) edgeRuntime?: { modulePath: string // Absolute path to the module registered in the edge runtime @@ -57,6 +58,7 @@ API routes from `pages/api/`: sourcePage: string // Original relative source file path runtime: 'nodejs' | 'edge' assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) edgeRuntime?: { modulePath: string // Absolute path to the module registered in the edge runtime @@ -84,6 +86,7 @@ React pages from the `app/` directory: sourcePage: string // Original relative source file path runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) edgeRuntime?: { modulePath: string // Absolute path to the module registered in the edge runtime @@ -111,6 +114,7 @@ API and metadata routes from the `app/` directory: sourcePage: string // Original relative source file path runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) edgeRuntime?: { modulePath: string // Absolute path to the module registered in the edge runtime @@ -218,6 +222,7 @@ See [Supporting immutable static assets](/docs/app/api-reference/adapters/immuta sourcePage: string // Always 'middleware' runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) + assetsHashes: Record // Content hashes of each `assets` entry (key: same as `assets`, value: content hash) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) edgeRuntime?: { modulePath: string // Absolute path to the module registered in the edge runtime diff --git a/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx index e061dc153618..7dcd2b96f746 100644 --- a/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx +++ b/docs/01-app/03-api-reference/07-adapters/10-routing-information.mdx @@ -9,6 +9,10 @@ The `routing` object in `onBuildComplete` provides complete routing information Routes applied before middleware execution. These include generated header and redirect behavior. +## `routing.middlewareMatchers` + +Middleware matcher definitions emitted for this build. Use these to decide whether middleware should be invoked for a given request. + ## `routing.beforeFiles` Rewrite routes checked before filesystem route matching. From cf5339d1be803a6676acf5399ff190fdfaf0db6f Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Mon, 24 Aug 2026 11:41:05 +0200 Subject: [PATCH 09/15] test: deflake basePath external navigation (#97611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The basePath external-navigation test depended on loading Google and recognizing its DOM before exercising browser history. That made the test sensitive to public network availability and third-party page changes; the reported failure was confirmed in the Safari/WebKit CI run on the initial attempt and both retries. For local and CI test-server runs, this replaces the public dependency with a minimal page served from a separate local origin, preserving the cross-origin, full-document navigation being tested. Deploy mode remains enabled and uses a deploy-accessible external target because deployed browsers cannot reach the test runner. Both paths now synchronize on browser origin and exact restored-page state instead of third-party DOM or a fixed sleep. ## Verification - Focused production/start-mode test passed three consecutive times in Firefox - Focused production/start-mode test passed in Chromium - Prettier and ESLint passed for the changed files - `pnpm build-all` - Not run: Safari/WebKit locally (Playwright’s unsupported Amazon Linux fallback requires incompatible Ubuntu system libraries; the Safari CI job is the definitive validation) - Not run: deploy-mode execution locally (deployment credentials unavailable; the deploy path remains enabled for CI) Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> --- test/e2e/basepath/basepath.test.ts | 71 +++++++++++++++----- test/e2e/basepath/pages/external-and-back.js | 13 ++-- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/test/e2e/basepath/basepath.test.ts b/test/e2e/basepath/basepath.test.ts index 5f75fc02f26b..77873570923f 100644 --- a/test/e2e/basepath/basepath.test.ts +++ b/test/e2e/basepath/basepath.test.ts @@ -1,4 +1,5 @@ import assert from 'assert' +import http from 'http' import cheerio from 'cheerio' import { nextTestSetup } from 'e2e-utils' import { @@ -7,7 +8,6 @@ import { fetchViaHTTP, getClientBuildManifestLoaderChunkUrlPath, renderViaHTTP, - waitFor, } from 'next-test-utils' describe('basePath', () => { @@ -57,21 +57,62 @@ describe('basePath', () => { }, }) + let externalServer: http.Server | undefined + let externalUrl = 'https://example.vercel.sh' + + beforeAll(async () => { + // Avoid third-party network and DOM dependencies when the browser can reach + // the test runner. Deployed tests cannot reach a server on this process. + if (isNextDeploy) return + + externalServer = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'text/html') + res.end('

external page

') + }) + + await new Promise((resolve, reject) => { + externalServer!.once('error', reject) + externalServer!.listen(0, '127.0.0.1', () => { + externalServer!.off('error', reject) + const address = externalServer!.address() + if (!address || typeof address === 'string') { + reject(new Error('Failed to determine the external server port')) + return + } + externalUrl = `http://127.0.0.1:${address.port}` + resolve() + }) + }) + }) + + afterAll(async () => { + if (!externalServer) return + + await new Promise((resolve, reject) => { + externalServer!.close((err) => (err ? reject(err) : resolve())) + }) + }) + it('should navigate to external site and back', async () => { - const browser = await next.browser(`${basePath}/external-and-back`) - const initialText = await browser.elementByCss('p').text() - expect(initialText).toBe('server') - - await browser - .elementByCss('a') - .click() - .waitForElementByCss('input', { state: 'attached' }) - .back() - .waitForElementByCss('p') - - await waitFor(1000) - const newText = await browser.elementByCss('p').text() - expect(newText).toBe('server') + const browser = await next.browser( + `${basePath}/external-and-back?external=${encodeURIComponent(externalUrl)}` + ) + const initialUrl = await browser.url() + expect(await browser.elementById('from').text()).toBe('server') + + await browser.elementByCss('a').click() + await browser.waitForCondition( + `window.location.origin !== ${JSON.stringify(new URL(initialUrl).origin)}` + ) + if (!isNextDeploy) { + await browser.waitForElementByCss('[data-external-page]') + } + + await browser.back() + await browser.waitForCondition( + `window.location.href === ${JSON.stringify(initialUrl)} && document.querySelector('#from')` + ) + expect(await browser.elementById('from').text()).toBe('server') }) if (process.env.BROWSER_NAME === 'safari') { diff --git a/test/e2e/basepath/pages/external-and-back.js b/test/e2e/basepath/pages/external-and-back.js index c2932502b2b6..68614a7e5d4d 100644 --- a/test/e2e/basepath/pages/external-and-back.js +++ b/test/e2e/basepath/pages/external-and-back.js @@ -1,12 +1,15 @@ -const Page = ({ from }) => ( +const Page = ({ from, external }) => (
-

{from}

- External link +

{from}

+ External link
) -Page.getInitialProps = () => { - return { from: typeof window === 'undefined' ? 'server' : 'client' } +Page.getInitialProps = ({ query }) => { + return { + from: typeof window === 'undefined' ? 'server' : 'client', + external: query.external || 'https://example.vercel.sh', + } } export default Page From a11f82c557e827a192794dff23feb96151811b63 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Mon, 24 Aug 2026 12:01:44 +0200 Subject: [PATCH 10/15] [turbopack] defer NFT module content hashes (#97773) ### What? Separate trace-graph module path collection from module content hashing. Exclusion filtering now requests only cached module identifiers, while full NFT data still layers content hashes on top when they are required. ### Why? Applying output-file-tracing exclusion globs needs module paths but not content hashes. Keeping these computations separate avoids requesting full-graph hashes solely to decide which modules should be skipped. This is an inspection experiment, not a demonstrated performance improvement. A seven-sample release A/B left the median compilation phase unchanged at 2700 ms. ### How? A private identifier-only graph task owns the existing DFS traversal. `traced_module_data_for_graph` reuses those identifiers and computes hashes for its full result. `traced_modules_for_entries` drops the unused hash-salt input and consumes only identifiers for glob matching. ### Verification - `cargo fmt -- --check` - `cargo check -p next-api` - `next-server.js.nft.json`: byte-identical SHA-256 `da4c5a494efbbb38eb966066115de96adb5d0ec693fba17bbed669c99cafae43` - `next-minimal-server.js.nft.json`: byte-identical SHA-256 `7c58d40dc2ecbe6ddd26dd0c602467d80f1a8ea3320e2c6e8e5f4d6be14156cd` - Not run: clippy, Rust test suite, or integration tests (inspection experiment) Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> --- crates/next-api/src/next_server_nft.rs | 1 - crates/next-api/src/nft.rs | 64 +++++++++++++++++--------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs index 7abd4b326a1f..d171159ba056 100644 --- a/crates/next-api/src/next_server_nft.rs +++ b/crates/next-api/src/next_server_nft.rs @@ -208,7 +208,6 @@ impl Asset for ServerNftJsonAsset { self.entries(), Some(self.ignores()), None, - hash_salt, ) .await? .iter() diff --git a/crates/next-api/src/nft.rs b/crates/next-api/src/nft.rs index 97fa40868705..2233da11ae90 100644 --- a/crates/next-api/src/nft.rs +++ b/crates/next-api/src/nft.rs @@ -89,7 +89,6 @@ pub async fn trace_endpoint( .await? .map(|v| *v), Some(next_config.config_file_path(project_path.clone())), - hash_salt, ) .await?; @@ -284,12 +283,11 @@ pub async fn traced_modules_for_entries( traced_entries: Vc, exclude_glob: Option>, forbidden_path: Option>, - hash_salt: Vc, ) -> Result> { let exclude_glob_and_module_idents = if let Some(exclude_glob) = exclude_glob { let exclude_glob = exclude_glob.await?; - let data = traced_module_data_for_graph(module_graph, traced_entries, hash_salt).await?; - Some((exclude_glob, data.idents.await?)) + let idents = traced_module_idents_for_graph(module_graph, traced_entries).await?; + Some((exclude_glob, idents)) } else { None }; @@ -388,13 +386,12 @@ pub struct TracedModuleData { pub hashes: ResolvedVc, } -/// This caches the paths for all modules in the graph so that we don't have to do it once per page. +/// This caches the paths for all modules in the graph without eagerly hashing their contents. #[turbo_tasks::function] -pub async fn traced_module_data_for_graph( +async fn traced_module_idents_for_graph( module_graph: Vc, traced_entries: Vc, - hash_salt: Vc, -) -> Result> { +) -> Result> { // This function is very similar to traced_modules_for_entries, but doesn't apply the glob and // is executed only once for the whole graph. let module_graph = module_graph.await?; @@ -423,30 +420,51 @@ pub async fn traced_module_data_for_graph( true, )?; - let (idents, hashes): (FxHashMap<_, _>, FxHashMap<_, _>) = traced_modules - .into_iter() + Ok(Vc::cell( + traced_modules + .into_iter() + .map(async |module| Ok((module, module.ident().await?))) + .try_join() + .await? + .into_iter() + .collect(), + )) +} + +/// This caches the paths and content hashes for all modules in the graph so that we don't have to +/// compute them once per page. +#[turbo_tasks::function] +pub async fn traced_module_data_for_graph( + module_graph: Vc, + traced_entries: Vc, + hash_salt: Vc, +) -> Result> { + let idents = traced_module_idents_for_graph(module_graph, traced_entries) + .to_resolved() + .await?; + let hashes = idents + .await? + .keys() + .copied() .map(async |module| { Ok(( - (module, module.ident().await?), - ( - module, - module - .source() - .await? - .context("NFT module has no content")? - .content() - .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) - .await?, - ), + module, + module + .source() + .await? + .context("NFT module has no content")? + .content() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + .await?, )) }) .try_join() .await? .into_iter() - .unzip(); + .collect(); Ok(TracedModuleData { - idents: ResolvedVc::cell(idents), + idents, hashes: ResolvedVc::cell(hashes), } .cell()) From a1cc1eafb31b60b91569b498633a2a975488c02b Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:05:28 +0200 Subject: [PATCH 11/15] Remove unused return values from getPageStaticInfo (#97661) Some cleanup, this was never used: - `config` from `getStaticInfoIncludingLayouts` - `getServerSideProps` and `getStaticProps` from pages router --- .../build/analysis/get-page-static-info.ts | 36 +++++--------- packages/next/src/build/entries.ts | 8 +--- .../get-static-info-including-layouts.ts | 3 +- test/unit/parse-page-static-info.test.ts | 48 ++++++------------- 4 files changed, 28 insertions(+), 67 deletions(-) diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 978e25084a36..12d65bf3ebf5 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -77,31 +77,26 @@ export type ProxyConfig = { unstable_allowDynamic?: string[] } -export interface AppPageStaticInfo { - type: PAGE_TYPES.APP - ssg?: boolean - ssr?: boolean +export interface SharedPageStaticInfo { rsc?: RSCModuleType - generateStaticParams?: boolean generateSitemaps?: boolean generateImageMetadata?: boolean middleware?: ProxyConfig + maxDuration: number | undefined + hadUnsupportedValue: boolean +} + +export interface AppPageStaticInfo extends SharedPageStaticInfo { + type: PAGE_TYPES.APP + ssg?: boolean + ssr?: boolean config: Omit | undefined runtime: AppSegmentConfig['runtime'] | undefined preferredRegion: AppSegmentConfig['preferredRegion'] | undefined - maxDuration: number | undefined - hadUnsupportedValue: boolean } -export interface PagesPageStaticInfo { +export interface PagesPageStaticInfo extends SharedPageStaticInfo { type: PAGE_TYPES.PAGES - getStaticProps?: boolean - getServerSideProps?: boolean - rsc?: RSCModuleType - generateStaticParams?: boolean - generateSitemaps?: boolean - generateImageMetadata?: boolean - middleware?: ProxyConfig config: | (Omit & { config?: Omit @@ -109,8 +104,6 @@ export interface PagesPageStaticInfo { | undefined runtime: PagesSegmentConfig['runtime'] | undefined preferredRegion: PagesSegmentConfigConfig['regions'] | undefined - maxDuration: number | undefined - hadUnsupportedValue: boolean } export type PageStaticInfo = AppPageStaticInfo | PagesPageStaticInfo @@ -755,7 +748,6 @@ export async function getAppPageStaticInfo({ rsc, generateImageMetadata, generateSitemaps, - generateStaticParams, config, middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig), runtime: config.runtime, @@ -791,11 +783,7 @@ export async function getPagesPageStaticInfo({ isDev, }) - const { getServerSideProps, getStaticProps, exports } = checkExports( - ast, - PagesSegmentConfigSchemaKeys, - page - ) + const { exports } = checkExports(ast, PagesSegmentConfigSchemaKeys, page) const { type: rsc } = getRSCModuleInformation(content, true) @@ -870,8 +858,6 @@ export async function getPagesPageStaticInfo({ return { type: PAGE_TYPES.PAGES, - getStaticProps, - getServerSideProps, rsc, config, middleware: parseMiddlewareConfig(page, exportedConfig.config, nextConfig), diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index a3734ea6df5e..74d9fe82c3da 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -4,11 +4,7 @@ import type { EdgeSSRLoaderQuery } from './webpack/loaders/next-edge-ssr-loader' import type { EdgeAppRouteLoaderQuery } from './webpack/loaders/next-edge-app-route-loader' import type { NextConfigComplete } from '../server/config-shared' import type { webpack } from 'next/dist/compiled/webpack/webpack' -import type { - ProxyConfig, - ProxyMatcher, - PageStaticInfo, -} from './analysis/get-page-static-info' +import type { ProxyConfig, ProxyMatcher } from './analysis/get-page-static-info' import type { LoadedEnvFiles } from '@next/env' import type { AppLoaderOptions } from './webpack/loaders/next-app-loader' @@ -478,7 +474,7 @@ export async function createEntrypoints( (absolutePagePath.startsWith(APP_DIR_ALIAS) || absolutePagePath.startsWith(appDir)) - const staticInfo: PageStaticInfo = await getStaticInfoIncludingLayouts({ + const staticInfo = await getStaticInfoIncludingLayouts({ isInsideAppDir, pageExtensions, pageFilePath, diff --git a/packages/next/src/build/get-static-info-including-layouts.ts b/packages/next/src/build/get-static-info-including-layouts.ts index 260bccb115ff..f5685624665c 100644 --- a/packages/next/src/build/get-static-info-including-layouts.ts +++ b/packages/next/src/build/get-static-info-including-layouts.ts @@ -31,7 +31,7 @@ export async function getStaticInfoIncludingLayouts({ config: NextConfigComplete isDev: boolean page: string -}): Promise { +}): Promise> { // TODO: sync types for pages: PAGE_TYPES, ROUTER_TYPE, 'app' | 'pages', etc. const pageType = isInsideAppDir ? PAGE_TYPES.APP : PAGE_TYPES.PAGES @@ -90,7 +90,6 @@ export async function getStaticInfoIncludingLayouts({ return { ...pageStaticInfo, - config, runtime: config.runtime, preferredRegion: config.preferredRegion, maxDuration: config.maxDuration, diff --git a/test/unit/parse-page-static-info.test.ts b/test/unit/parse-page-static-info.test.ts index 73f2f95c6593..8eb9cc20b475 100644 --- a/test/unit/parse-page-static-info.test.ts +++ b/test/unit/parse-page-static-info.test.ts @@ -14,31 +14,25 @@ describe('parse page static info', () => { await installBindings() }) it('should parse nodejs runtime correctly', async () => { - const { runtime, getServerSideProps, getStaticProps } = - await getPagesPageStaticInfo({ - page: 'nodejs-ssr', - pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - isDev: false, - }) + const { runtime } = await getPagesPageStaticInfo({ + page: 'nodejs-ssr', + pageFilePath: join(fixtureDir, 'page-runtime/nodejs-ssr.js'), + nextConfig: createNextConfig(), + pageType: PAGE_TYPES.PAGES, + isDev: false, + }) expect(runtime).toBe('nodejs') - expect(getServerSideProps).toBe(true) - expect(getStaticProps).toBe(false) }) it('should parse static runtime correctly', async () => { - const { runtime, getServerSideProps, getStaticProps } = - await getPagesPageStaticInfo({ - page: 'nodejs', - pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - isDev: false, - }) + const { runtime } = await getPagesPageStaticInfo({ + page: 'nodejs', + pageFilePath: join(fixtureDir, 'page-runtime/nodejs.js'), + nextConfig: createNextConfig(), + pageType: PAGE_TYPES.PAGES, + isDev: false, + }) expect(runtime).toBe('nodejs') - expect(getServerSideProps).toBe(false) - expect(getStaticProps).toBe(false) }) it('should parse edge runtime correctly', async () => { @@ -62,18 +56,4 @@ describe('parse page static info', () => { }) expect(runtime).toBe(undefined) }) - - it('should parse ssr info with variable exported gSSP correctly', async () => { - const { getServerSideProps, getStaticProps } = await getPagesPageStaticInfo( - { - page: 'ssr-variable-gssp', - pageFilePath: join(fixtureDir, 'page-runtime/ssr-variable-gssp.js'), - nextConfig: createNextConfig(), - pageType: PAGE_TYPES.PAGES, - isDev: false, - } - ) - expect(getStaticProps).toBe(false) - expect(getServerSideProps).toBe(true) - }) }) From c0dfeffa5322f944df4693280158bf5e9c0e54c2 Mon Sep 17 00:00:00 2001 From: MB Date: Mon, 24 Aug 2026 13:14:31 +0200 Subject: [PATCH 12/15] Add next/cache-handlers types entrypoint (#97592) ### What? Exposes the `cacheHandlers` types (`CacheHandler`, `CacheEntry`) as types-only exports from `next/cache`, so handlers can be checked against the real interface: ```ts import type { CacheHandler } from 'next/cache' ``` ### Why? Custom cache handler authors currently import `CacheHandler` and `CacheEntry` from `next/dist/server/lib/cache-handlers/types` (an internal path that can move between versions) or hand-copy the interfaces, which drift silently across releases. ### Motivation I'm working on a custom community cache handler and it'd be great to import the types directly and keep testing against the source of truth, rather than maintaining a hand-copied mirror that has to be re-checked on every Next.js release. Raised in discussion #96356. ### How? - Adds a type-only `export type { CacheHandler, CacheEntry }` to `packages/next/cache.d.ts`, re-exported from `./dist/server/lib/cache-handlers/types`. No new subpath or `package.json` `"files"` entries needed since `next/cache` already ships. - Docs: `cacheHandlers.mdx` now shows the public `next/cache` import instead of the GitHub source links. - Tests: a `satisfies CacheHandler` / `satisfies CacheEntry` fixture in the `typescript-basic` typechecking suite (runs `tsc` against the installed package), and the `use-cache-custom-handler` e2e fixture's JSDoc now uses the public import. (Originally proposed as a separate `next/cache-handlers` types-only subpath, following the `next/types` pattern moved the export into `next/cache` per review.) Related: #96356 closes #97781 (only created to run deploy tests) --- .../01-next-config-js/cacheHandlers.mdx | 8 +++++-- packages/next/cache.d.ts | 5 ++++ .../use-cache-custom-handler/handler.js | 2 +- .../cache-handlers/cache-handlers.ts | 24 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx index 1673c5297563..ff11a1617932 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/cacheHandlers.mdx @@ -71,7 +71,11 @@ Note that `'use cache: private'` does not use cache handlers and cannot be custo ## API Reference -A cache handler must implement the [`CacheHandler`](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/types.ts) interface with the following methods: +A cache handler must implement the `CacheHandler` interface with the following methods. The `CacheHandler` and `CacheEntry` types are exported from `next/cache`: + +```ts +import type { CacheHandler, CacheEntry } from 'next/cache' +``` ### `get()` @@ -227,7 +231,7 @@ const cacheHandler = { ## CacheEntry Type -The [`CacheEntry`](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/types.ts) object has the following structure: +The `CacheEntry` object (exported from `next/cache`) has the following structure: ```ts interface CacheEntry { diff --git a/packages/next/cache.d.ts b/packages/next/cache.d.ts index 3a3750edfb8e..3c126e76dd62 100644 --- a/packages/next/cache.d.ts +++ b/packages/next/cache.d.ts @@ -1,5 +1,10 @@ export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' +export type { + CacheHandler, + CacheEntry, +} from './dist/server/lib/cache-handlers/types' + export { revalidatePath, revalidateTag, diff --git a/test/e2e/app-dir/use-cache-custom-handler/handler.js b/test/e2e/app-dir/use-cache-custom-handler/handler.js index 4470fce06024..2d2c2a1ad3a6 100644 --- a/test/e2e/app-dir/use-cache-custom-handler/handler.js +++ b/test/e2e/app-dir/use-cache-custom-handler/handler.js @@ -6,7 +6,7 @@ const { AsyncLocalStorage } = require('node:async_hooks') const snapshot = AsyncLocalStorage.snapshot() /** - * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} + * @type {import('next/cache').CacheHandler} */ const cacheHandler = { async get(cacheKey, softTags) { diff --git a/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts b/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts new file mode 100644 index 000000000000..e17cc81b9bd0 --- /dev/null +++ b/test/production/typescript-basic/typechecking/cache-handlers/cache-handlers.ts @@ -0,0 +1,24 @@ +import type { CacheHandler, CacheEntry } from 'next/cache' + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions +;() => { + ;({ + value: new ReadableStream(), + tags: [], + stale: 0, + timestamp: 0, + expire: 0, + revalidate: 0, + }) satisfies CacheEntry + ;({ + async get(_cacheKey: string, _softTags: string[]) { + return undefined + }, + async set(_cacheKey: string, _pendingEntry: Promise) {}, + async refreshTags() {}, + async getExpiration(_tags: string[]) { + return 0 + }, + async updateTags(_tags: string[], _durations?: { expire?: number }) {}, + }) satisfies CacheHandler +} From 6970724fbab2bb9197b0fdad8790909050cd476b Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:43:30 +0000 Subject: [PATCH 13/15] v16.4.0-canary.4 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/devlow-bench/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 22 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lerna.json b/lerna.json index 7f24833a84a7..7779c377acf0 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.4.0-canary.3" + "version": "16.4.0-canary.4" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 85f3d6a0abfe..56e2f95ad2dc 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 5d61cac3a8eb..26959ddd7221 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 5b438d220f55..f726a319c559 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.4.0-canary.3", + "@next/eslint-plugin-next": "16.4.0-canary.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 95f60a0bbdb8..894c51f3c3b0 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index cc99d8426c19..ab065a37fd22 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 2f0277a80bf0..f79310dabd51 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index c591f4a364e5..906625c56ac8 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 0bf7b1cd2bd2..42812c1cab48 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index bb944f2819e6..8437f4c45de9 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 4e84ace8547e..05c1392038b3 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 3c8bd1aec5ee..fc2c5a9c7ff2 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 598cc9a1121e..992bac2aaad3 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 662bbf9959db..9dbe132b669d 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 7304cadd070a..1d1ce65154b0 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index 2eb53bc7a75b..11a2e9f0153d 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 610aaeacedba..8eec0f3e02c0 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 4b4f869dbc21..7153de2bc113 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index c60101857e3f..e73f7eeffa84 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.4.0-canary.3", + "@next/env": "16.4.0-canary.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.4.0-canary.3", - "@next/polyfill-module": "16.4.0-canary.3", - "@next/polyfill-nomodule": "16.4.0-canary.3", - "@next/react-refresh-utils": "16.4.0-canary.3", - "@next/swc": "16.4.0-canary.3", + "@next/font": "16.4.0-canary.4", + "@next/polyfill-module": "16.4.0-canary.4", + "@next/polyfill-nomodule": "16.4.0-canary.4", + "@next/react-refresh-utils": "16.4.0-canary.4", + "@next/swc": "16.4.0-canary.4", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index afad4acac438..5860ab9072f9 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 962df405dd22..9fd31dcbdd76 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.4.0-canary.3", + "version": "16.4.0-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.4.0-canary.3", + "next": "16.4.0-canary.4", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bba07d52d19f..0cd85d7e4f85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1024,7 +1024,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1107,7 +1107,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1228,19 +1228,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../font '@next/polyfill-module': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../react-refresh-utils '@next/swc': - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1983,7 +1983,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.4.0-canary.3 + specifier: 16.4.0-canary.4 version: link:../next outdent: specifier: 0.8.0 From 75c1f1f6e04cfb83895eceba911934205bff4916 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Mon, 24 Aug 2026 13:47:42 +0200 Subject: [PATCH 14/15] perf: split cold TurboMalloc accounting paths (#97767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Reduce the binary-size cost of TurboMalloc's allocation instrumentation while preserving its hot-path performance and accounting semantics. This adds a focused Criterion benchmark for TurboMalloc's raw allocation paths and moves only the uncommon global counter refill/spill operations behind cold, non-inlined helpers. Thread-local allocation counters, buffer checks, and common buffer adjustments remain inline at allocation sites. ### Why? Rust gives global allocator methods special inlining treatment. TurboMalloc's thread-local accounting was consequently duplicated across allocation and deallocation call sites in `next-swc`, contributing several megabytes of machine code. Moving the whole accounting path out of line recovered most of that space but made small allocations slower. Splitting only the rare atomic paths provides a smaller size reduction without that hot-path cost. ### How? The common path remains explicitly inlineable through `add`, `remove`, `update`, and the thread-local access helper. When the local buffer must be refilled or spilled, it calls a `#[cold] #[inline(never)]` helper that performs the global relaxed atomic update. The arithmetic, thresholds, target buffer values, counter increments, and atomic ordering are unchanged. The counter test now also covers realloc growth/spill and shrink/flush behavior. ### Benchmark results Linux x86_64 Intel Xeon, Rust 1.99.0-nightly / LLVM 22.1.8. Native artifacts used the repository's production release command. Direct application measurements used clean `bench/heavy-npm-deps` `next build --turbopack` runs, with 8 samples per arm and rotated/interleaved ordering. #### Binary size | alternative | raw artifact delta | `.text` delta | |---|---:|---:| | No instrumentation | −6,260,168 B (−4.23%) | −5,466,304 B (−7.69%) | | `inline(never)` on `add` / `remove` / `update` | −4,338,856 B (−2.93%) | −4,342,464 B (−6.11%) | | `inline(never)` on whole `GlobalAlloc` methods | −4,153,792 B (−2.80%) | −3,714,688 B (−5.23%) | | **Cold atomic refill/spill helpers (this PR)** | **−502,864 B (−0.34%)** | **−732,736 B (−1.03%)** | The current artifact was approximately 148.1 MB raw with 71.1 MB of `.text`. The retained split recovers 8% of the raw and 13% of the `.text` saving from removing instrumentation entirely. #### Direct `next build --turbopack` | alternative | mean effect vs current | bootstrap 95% CI | permutation p | |---|---:|---:|---:| | No instrumentation | −0.70% | [−2.32%, +1.12%] | 0.466 | | `inline(never)` on `add` / `remove` / `update` | **+3.53%** | **[+0.65%, +6.43%]** | 0.0448 | | `inline(never)` on whole `GlobalAlloc` methods | +1.02% | [−2.03%, +4.44%] | 0.586 | | **Cold atomic refill/spill helpers (this PR)** | **−0.18%** | **[−3.44%, +3.61%]** | **0.929** | No application-level difference was detected for the retained split or the whole-method placement. The helper-level noinline placement showed evidence of a modest regression. #### Focused allocator Criterion benchmark Percent changes are lower-is-better. Where two figures are shown, they are independent runs in reversed order. | alternative | alloc + dealloc | alloc + realloc + dealloc | |---|---:|---:| | No instrumentation | −43.4% | −36.1% | | `inline(never)` on `add` / `remove` / `update` | +5.5% / +7.5% | +3.0% / −8.1% (inconclusive) | | `inline(never)` on whole `GlobalAlloc` methods | **+19.4% / +21.4%** | **+2.9% / +6.4%** | | **Cold atomic refill/spill helpers (this PR)** | **−4.0% / −3.0%** | **−2.8% / −5.4%** | The full 28-cell turbo-tasks overhead suite was also run twice in reversed order. Large process-level effects changed magnitude or sign between runs, so no conclusion is drawn from that suite. ### Verification - `cargo fmt -- --check` - `cargo clippy -p turbo-tasks-malloc --all-targets -- -D warnings -A deprecated` - `cargo test -p turbo-tasks-malloc` - `cargo check -p turbo-tasks-malloc --all-targets` - `cargo bench -p turbo-tasks-malloc --bench allocation -- --test` - Release `next-swc` builds and Node smoke-loads for every measured arm --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> --- Cargo.lock | 1 + .../crates/turbo-tasks-malloc/Cargo.toml | 7 +++ .../turbo-tasks-malloc/benches/allocation.rs | 57 +++++++++++++++++++ .../crates/turbo-tasks-malloc/src/counter.rs | 57 ++++++++++++++----- 4 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 turbopack/crates/turbo-tasks-malloc/benches/allocation.rs diff --git a/Cargo.lock b/Cargo.lock index 888b1001fc3e..f1d010063fc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10326,6 +10326,7 @@ dependencies = [ name = "turbo-tasks-malloc" version = "0.1.0" dependencies = [ + "codspeed-criterion-compat", "libc", "libmimalloc-sys", "mimalloc", diff --git a/turbopack/crates/turbo-tasks-malloc/Cargo.toml b/turbopack/crates/turbo-tasks-malloc/Cargo.toml index ab01665e0aaf..2fff14ae6dd2 100644 --- a/turbopack/crates/turbo-tasks-malloc/Cargo.toml +++ b/turbopack/crates/turbo-tasks-malloc/Cargo.toml @@ -9,8 +9,15 @@ autobenches = false [lib] bench = false +[[bench]] +name = "allocation" +harness = false + [dependencies] +[dev-dependencies] +criterion = { workspace = true } + [target.'cfg(not(target_family = "wasm"))'.dependencies] libmimalloc-sys = { version = "0.1.44", features = [ "extended", diff --git a/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs b/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs new file mode 100644 index 000000000000..c4038556c066 --- /dev/null +++ b/turbopack/crates/turbo-tasks-malloc/benches/allocation.rs @@ -0,0 +1,57 @@ +//! Measures the cost of the raw allocation paths through [`TurboMalloc`], including its +//! thread-local allocation accounting. Useful when changing the allocator's instrumentation, since +//! that code runs on every allocation and deallocation in Turbopack. + +use std::alloc::{Layout, alloc, dealloc, realloc}; + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +#[global_allocator] +static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; + +const ALIGN: usize = 8; +const SIZE: usize = 64; +const LARGER_SIZE: usize = 128; + +fn make_layout(size: usize) -> Layout { + Layout::from_size_align(size, ALIGN).expect("valid layout") +} + +fn benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("turbo_malloc"); + + group.bench_function("alloc_dealloc", |b| { + b.iter(|| { + // `black_box` the size so the allocation can't be optimized away or folded. + let layout = make_layout(black_box(SIZE)); + // SAFETY: the layout has a non-zero size, and the pointer is freed with the same + // layout it was allocated with. + unsafe { + let ptr = alloc(layout); + assert!(!ptr.is_null(), "allocation failed"); + dealloc(black_box(ptr), layout); + } + }); + }); + + group.bench_function("alloc_realloc_dealloc", |b| { + b.iter(|| { + let layout = make_layout(black_box(SIZE)); + let new_size = black_box(LARGER_SIZE); + // SAFETY: `ptr` is allocated with `layout`, reallocated to `new_size` with the same + // alignment, and finally freed with the layout it then has. + unsafe { + let ptr = alloc(layout); + assert!(!ptr.is_null(), "allocation failed"); + let ptr = realloc(ptr, layout, new_size); + assert!(!ptr.is_null(), "reallocation failed"); + dealloc(black_box(ptr), make_layout(new_size)); + } + }); + }); + + group.finish(); +} + +criterion_group!(benches, benchmark); +criterion_main!(benches); diff --git a/turbopack/crates/turbo-tasks-malloc/src/counter.rs b/turbopack/crates/turbo-tasks-malloc/src/counter.rs index d5faff91bafa..a61e55c27232 100644 --- a/turbopack/crates/turbo-tasks-malloc/src/counter.rs +++ b/turbopack/crates/turbo-tasks-malloc/src/counter.rs @@ -36,29 +36,28 @@ impl ThreadLocalCounter { allocation_counters: AllocationCounters::new(), } } + #[inline(always)] fn add(&mut self, size: usize) { self.allocation_counters.allocations += size; self.allocation_counters.allocation_count += 1; if self.buffer >= size { self.buffer -= size; } else { - let offset = size - self.buffer + TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_add(offset, Ordering::Relaxed); + add_slow(self, size); } } + #[inline(always)] fn remove(&mut self, size: usize) { self.allocation_counters.deallocations += size; self.allocation_counters.deallocation_count += 1; self.buffer += size; if self.buffer > MAX_BUFFER { - let offset = self.buffer - TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_sub(offset, Ordering::Relaxed); + remove_slow(self); } } + #[inline(always)] fn update(&mut self, old_size: usize, new_size: usize) { self.allocation_counters.deallocations += old_size; self.allocation_counters.deallocation_count += 1; @@ -71,18 +70,14 @@ impl ThreadLocalCounter { if self.buffer >= size { self.buffer -= size; } else { - let offset = size - self.buffer + TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_add(offset, Ordering::Relaxed); + add_slow(self, size); } } std::cmp::Ordering::Greater => { let size = old_size - new_size; self.buffer += size; if self.buffer > MAX_BUFFER { - let offset = self.buffer - TARGET_BUFFER; - self.buffer = TARGET_BUFFER; - ALLOCATED.fetch_sub(offset, Ordering::Relaxed); + remove_slow(self); } } } @@ -97,6 +92,25 @@ impl ThreadLocalCounter { } } +// Keep the uncommon atomic updates out of the allocator's inlined hot path. +#[cold] +#[inline(never)] +fn add_slow(local: &mut ThreadLocalCounter, size: usize) { + debug_assert!(local.buffer < size); + let offset = size - local.buffer + TARGET_BUFFER; + local.buffer = TARGET_BUFFER; + ALLOCATED.fetch_add(offset, Ordering::Relaxed); +} + +#[cold] +#[inline(never)] +fn remove_slow(local: &mut ThreadLocalCounter) { + debug_assert!(local.buffer > MAX_BUFFER); + let offset = local.buffer - TARGET_BUFFER; + local.buffer = TARGET_BUFFER; + ALLOCATED.fetch_sub(offset, Ordering::Relaxed); +} + thread_local! { static LOCAL_COUNTER: UnsafeCell = const {UnsafeCell::new(ThreadLocalCounter::new())}; } @@ -113,6 +127,7 @@ pub fn reset_allocation_counters(start: AllocationCounters) { with_local_counter(|local| local.allocation_counters = start); } +#[inline(always)] fn with_local_counter(f: impl FnOnce(&mut ThreadLocalCounter) -> T) -> T { LOCAL_COUNTER.with(|local| { let ptr = local.get(); @@ -123,16 +138,19 @@ fn with_local_counter(f: impl FnOnce(&mut ThreadLocalCounter) -> T) -> T { } /// Adds some `size` to the global counter in a thread-local buffered way. +#[inline(always)] pub fn add(size: usize) { with_local_counter(|local| local.add(size)); } /// Removes some `size` to the global counter in a thread-local buffered way. +#[inline(always)] pub fn remove(size: usize) { with_local_counter(|local| local.remove(size)); } -/// Adds some `size` to the global counter in a thread-local buffered way. +/// Updates the global counter for a reallocation in a thread-local buffered way. +#[inline(always)] pub fn update(old_size: usize, new_size: usize) { with_local_counter(|local| local.update(old_size, new_size)); } @@ -172,5 +190,18 @@ mod tests { // this means the global counter should reduce by 100 + MAX_BUFFER expected -= MAX_BUFFER + 100; assert_eq!(get(), expected); + + update(100, 200); + // Small reallocations should use the buffer. + assert_eq!(get(), expected); + update(0, MAX_BUFFER); + // Growing beyond the buffer should require more buffer space. The prior small growth + // consumed another 100 bytes from the buffer. + expected += MAX_BUFFER + 100; + assert_eq!(get(), expected); + update(MAX_BUFFER + 1, 0); + // Shrinking beyond MAX_BUFFER should flush the excess. + expected -= MAX_BUFFER + 1; + assert_eq!(get(), expected); } } From 88483bdedc83b5c32d3c3f2fb2a0972069cd29e8 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Mon, 24 Aug 2026 06:03:55 -0700 Subject: [PATCH 15/15] Deduplicate the regress, wat/wasmparser and base64 dependencies (#97762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Collapses three duplicated crate dependencies, so each is built once instead of twice. **−150 KiB** of code Manifests and `Cargo.lock` only — no source changes, and none were needed. **`regress` → 0.11.1** (−133 KiB, the bulk of the win) **`wat` and `wasmparser` → the 239 family** (−13 KiB) **`base64` → one workspace declaration on 0.22** (−1.2 KiB) --- Cargo.lock | 43 +++++++-------------- Cargo.toml | 3 +- crates/next-core/Cargo.toml | 2 +- crates/next-custom-transforms/Cargo.toml | 2 +- turbopack/crates/turbopack-image/Cargo.toml | 2 +- turbopack/crates/turbopack-node/Cargo.toml | 2 +- turbopack/crates/turbopack-wasm/Cargo.toml | 8 +++- 7 files changed, 26 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1d010063fc5..d64935835bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3181,8 +3181,6 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", "serde", ] @@ -4973,7 +4971,7 @@ dependencies = [ "allsorts", "anyhow", "async-trait", - "base64 0.21.4", + "base64 0.22.1", "bincode 2.0.1", "either", "futures", @@ -4990,7 +4988,7 @@ dependencies = [ "qstring", "react_remove_properties", "regex", - "regress 0.10.4", + "regress", "remove_console", "rustc-hash 2.1.1", "serde", @@ -5033,7 +5031,7 @@ name = "next-custom-transforms" version = "0.0.0" dependencies = [ "anyhow", - "base64 0.21.4", + "base64 0.22.1", "bytes-str", "chrono", "easy-error", @@ -6596,16 +6594,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "regress" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" -dependencies = [ - "hashbrown 0.15.4", - "memchr", -] - [[package]] name = "regress" version = "0.11.1" @@ -7966,7 +7954,7 @@ dependencies = [ "indexmap 2.14.0", "once_cell", "regex", - "regress 0.11.1", + "regress", "rustc-hash 2.1.1", "serde", "serde_json", @@ -9983,7 +9971,7 @@ dependencies = [ "anyhow", "bincode 2.0.1", "regex", - "regress 0.10.4", + "regress", "turbo-tasks", ] @@ -10772,7 +10760,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.21.4", + "base64 0.22.1", "bincode 2.0.1", "image", "mime", @@ -10851,7 +10839,7 @@ dependencies = [ "anyhow", "async-stream", "async-trait", - "base64 0.21.4", + "base64 0.22.1", "bincode 2.0.1", "bytes", "const_format", @@ -11089,7 +11077,7 @@ dependencies = [ "turbo-tasks-hash", "turbopack-core", "turbopack-ecmascript", - "wasmparser 0.235.0", + "wasmparser 0.239.0", "wat", ] @@ -12067,10 +12055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161296c618fa2d63f6ed5fffd1112937e803cb9ec71b32b01a76321555660917" dependencies = [ "bitflags 2.9.1", - "hashbrown 0.15.4", "indexmap 2.14.0", - "semver", - "serde", ] [[package]] @@ -12307,24 +12292,24 @@ dependencies = [ [[package]] name = "wast" -version = "235.0.0" +version = "239.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eda4293f626c99021bb3a6fbe4fbbe90c0e31a5ace89b5f620af8925de72e13" +checksum = "9139176fe8a2590e0fb174cdcaf373b224cb93c3dde08e4297c1361d2ba1ea5d" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.1", - "wasm-encoder 0.235.0", + "wasm-encoder 0.239.0", ] [[package]] name = "wat" -version = "1.235.0" +version = "1.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e777e0327115793cb96ab220b98f85327ec3d11f34ec9e8d723264522ef206aa" +checksum = "3e1c941927d34709f255558166f8901a2005f8ab4a9650432e9281b7cc6f3b75" dependencies = [ - "wast 235.0.0", + "wast 239.0.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 094292699c0a..adf583452376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -251,6 +251,7 @@ allsorts = { version = "0.14.0", default-features = false, features = [ ] } anyhow = "1.0.100" async-trait = "0.1.64" +base64 = "0.22.1" bincode = { version = "2.0.1", features = ["serde"] } bitfield = "0.19.4" bumpalo = { version = "3.19.0", features = ["boxed", "collections", "allocator-api2"] } @@ -322,7 +323,7 @@ quote = "1.0.45" rand = "0.10.1" rayon = "1.10.0" regex = "1.12.3" -regress = "0.10.4" +regress = "0.11.1" reqwest = { version = "0.13.2", default-features = false } ringmap = "0.2.5" roaring = "0.11.4" diff --git a/crates/next-core/Cargo.toml b/crates/next-core/Cargo.toml index 4f4474694785..cc7d9cc60408 100644 --- a/crates/next-core/Cargo.toml +++ b/crates/next-core/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -base64 = "0.21.0" +base64 = { workspace = true } allsorts = { workspace = true } bincode = { workspace = true } either = { workspace = true, features = ["serde"] } diff --git a/crates/next-custom-transforms/Cargo.toml b/crates/next-custom-transforms/Cargo.toml index a8e97961511d..545c08684d74 100644 --- a/crates/next-custom-transforms/Cargo.toml +++ b/crates/next-custom-transforms/Cargo.toml @@ -8,7 +8,7 @@ publish = false workspace = true [dependencies] -base64 = "0.21.0" +base64 = { workspace = true } bytes-str = { workspace = true } chrono = "0.4" easy-error = "1.0.0" diff --git a/turbopack/crates/turbopack-image/Cargo.toml b/turbopack/crates/turbopack-image/Cargo.toml index ebdab94db4af..aeb0734e810a 100644 --- a/turbopack/crates/turbopack-image/Cargo.toml +++ b/turbopack/crates/turbopack-image/Cargo.toml @@ -21,7 +21,7 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -base64 = "0.21.0" +base64 = { workspace = true } bincode = { workspace = true } image = { workspace = true, default-features = false, features = [ "gif", diff --git a/turbopack/crates/turbopack-node/Cargo.toml b/turbopack/crates/turbopack-node/Cargo.toml index b31c3f8786cc..6e5126e186da 100644 --- a/turbopack/crates/turbopack-node/Cargo.toml +++ b/turbopack/crates/turbopack-node/Cargo.toml @@ -24,7 +24,7 @@ anyhow = { workspace = true } async-stream = "0.3.4" async-trait = { workspace = true } dashmap = { workspace = true } -base64 = "0.21.0" +base64 = { workspace = true } bincode = { workspace = true } bytes = { workspace = true } const_format = { workspace = true } diff --git a/turbopack/crates/turbopack-wasm/Cargo.toml b/turbopack/crates/turbopack-wasm/Cargo.toml index 5156042b7689..8f2c9ca8379d 100644 --- a/turbopack/crates/turbopack-wasm/Cargo.toml +++ b/turbopack/crates/turbopack-wasm/Cargo.toml @@ -23,6 +23,10 @@ turbo-tasks-fs = { workspace = true } turbo-tasks-hash = { workspace = true } turbopack-core = { workspace = true } turbopack-ecmascript = { workspace = true } -wasmparser = "0.235.0" -wat = "1.0.69" +# `wat` and `wasmparser` version in lockstep: `wat` pulls `wast`, which depends on a +# `wasm-encoder` that in turn pins a `wasmparser`. Keeping both on the 239 family lets that +# `wasmparser` be the same one wasmtime already links, so only one copy is built. Bumping either +# without the other, or letting `wat` float to a newer minor, reintroduces the duplicate. +wasmparser = "0.239.0" +wat = "=1.239.0"