Files
Janka UrygaandGitHub 742a2c78df next/root-params (#80255)
Implements accessing root params (params segments that occur above root
layouts) via compiler-generated getters from the `next/root-params`
module:
```tsx
// app/[lang]/layout.tsx
// (there is no app/layout.tsx, this is the root layout)

import { lang } from "next/root-params";
//       ^^^^ coresponds to the [lang] segment above this module

export default async function RootLayout({ children }) {
  return (
    <html lang={await lang()}>
      <body>{children}</body>
    </html>
  );
}

```
- This API is only usable within server components (support for using it
in route handlers will be added in the future). It can be called
anywhere in the component tree, not just in a page or a layout.
- It **cannot be used in server actions**, because they're not tied to a
route.
- It will replace `unstable_rootParams`, which will be removed soon. 

Note that we can also have multiple root layouts with distinct params,
like this:
```
app/product/[productId]/layout.tsx
app/brand/[brandId]/layout.tsx
```
In this case we'll generate getters for both `productId` and `brandId`.
They can be called anywhere, but they'll return `undefined` if used in a
subtree where a param isn't available.

Note that this PR does not yet generate type declarations for the
generated getters, essentially leaving them typed as `any`. This will be
handled in a follow up.

## Implementation notes

### Turbopack

We collect the root param names when analyzing the folder structure in
`app_structure.rs`. The set is then passed down (as a Vc) all the way to
`next_import_map.rs`, where we insert an alias to a virtual
`next/root-params.js` module. We use a `ImportMapping::Dynamic` to
generate its code lazily in a separate turbo task (which is the only
place that actually reads the root params). This avoids invalidating the
whole `ResolveOptionsContext` if the root params change.

### Webpack

We alias `next/root-params` to `next-root-params-loader`, which scans
the directory structure manually (i haven't found a good way to re-use
existing methods for doing this) and returns the generated module. Each
directory we traverse before finding a root layout is marked as a
`contextDependency`, which should invalidate the loader's result if new
files are added to any of them or if they get renamed, because it might
mean that the set of root params changed.
2025-07-28 15:01:30 +02:00

134 lines
5.0 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { join } from 'path'
import { createSandbox } from 'development-sandbox'
import { outdent } from 'outdent'
import { retry } from '../../../lib/next-test-utils'
describe('app-root-param-getters - multiple roots', () => {
const { next, isNextDev, isTurbopack } = nextTestSetup({
files: join(__dirname, 'fixtures', 'multiple-roots'),
})
it('should have root params on dashboard pages', async () => {
const $ = await next.render$('/dashboard/1')
expect($('body').text()).toContain('Dashboard Root')
expect($('p').text()).toBe(`hello world ${JSON.stringify({ id: '1' })}`)
})
it('should not have root params on marketing pages', async () => {
const $ = await next.render$('/landing')
expect($('body').text()).toContain('Marketing Root')
expect($('p').text()).toBe('hello world {}')
})
if (isNextDev) {
it('should add getters when new root layouts are added or renamed', async () => {
// Start on the dashboard page, which uses root param getters.
// This forces the bundler to generate 'next/root-params'.
await using sandbox = await createSandbox(next, undefined, `/dashboard/1`)
const { browser, session } = sandbox
expect(await browser.elementByCss('p').text()).toBe(
`hello world ${JSON.stringify({ id: '1' })}`
)
// Add a new root layout with a root param.
// This should make the bundler re-generate 'next/root-params' with a new getter for `stuff`.
const newRootLayoutFiles = new Map([
[
'app/new-root/[stuff]/layout.tsx',
outdent`
export default function Root({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
],
[
'app/new-root/[stuff]/page.tsx',
// Note that we're also importing the `id` getter just to see if it's still there
// (we expect it to return undefined, because we don't have that param on this route)
outdent`
import { id, stuff } from 'next/root-params'
export default async function Page() {
return (
<p>hello new root: {JSON.stringify({ id: await id(), stuff: await stuff() })}</p>
)
}
export async function generateStaticParams() {
return [{ stuff: '123' }]
}
`,
],
])
for (const [filePath, fileContent] of newRootLayoutFiles) {
await session.write(filePath, fileContent)
}
// The page should call the getter and get the correct param value.
await retry(async () => {
const params = { stuff: '123' }
await browser.get(new URL(`/new-root/${params.stuff}`, next.url).href)
expect(await browser.elementByCss('p').text()).toBe(
`hello new root: ${JSON.stringify(params)}`
)
})
// Change the name of the root param
// This should make the bundler re-generate 'next/root-params' again, with `things` instead of `stuff`.
if (isTurbopack) {
// FIXME(turbopack): Something in our routing logic doesn't handle renaming a route param in turbopack mode.
// I haven't found the cause for this, but `DefaultRouteMatcherManager.reload` calls
// `getSortedRoutes(['/dashboard/[id]', '/new-root/[stuff]', '/new-root/[things]'])`
// which makes it error because it looks like we have two overlapping routes.
// I'm not sure why the previous route doesn't get removed and couldn't find a workaround,
// so we're skipping the rest of the test for now.
return
}
await session.renameFolder(
'app/new-root/[stuff]',
'app/new-root/[things]'
)
// The page code we added should now be erroring, because the root param getter is called `things` now
await retry(() => {
expect(next.cliOutput).toContain(
isTurbopack
? `Export stuff doesn't exist in target module`
: `Attempted import error: 'stuff' is not exported from 'next/root-params' (imported as 'stuff').`
)
})
// Update the page to use the new root param name
await session.write(
'app/new-root/[things]/page.tsx',
outdent`
import { id, things } from 'next/root-params'
export default async function Page() {
return (
<p>hello new root: {JSON.stringify({ id: await id(), things: await things() })}</p>
)
}
export async function generateStaticParams() {
return [{ things: '123' }]
}
`
)
// The page should call the getter and get the correct param value.
await retry(async () => {
const params = { things: '123' }
await browser.get(new URL(`/new-root/${params.things}`, next.url).href)
expect(await browser.elementByCss('p').text()).toBe(
`hello new root: ${JSON.stringify(params)}`
)
})
})
}
})