Skip to content

Commit f76c1ae

Browse files
authored
feat: rootParams (experimental) (#72837)
### What & Why It's common to have top-level "dynamic" params that remain constant (as "global" or "root" params) regardless of where you are within a root layout. For example, `[lang]` or `[locale]`, regardless of where you are in that layout that param will be available. We should provide a more convenient way to access these params without requiring it to be plumbed through ALS or context. ### How This introduces a new API, `unstable_rootParams`, that will return all segment params up to and including the [root layout](https://nextjs.org/docs/app/api-reference/file-conventions/layout#root-layouts). In other words: `/app/[foo]/[bar]/layout.tsx` -> `{ foo: string, bar: string }` `/app/[foo]/[bar]/[baz]/page.tsx` -> `{ foo: string, bar: string }` (`baz` is not included here since the root params up to the root layout were just `foo` & `bar`. This also supports the case of having multiple root layouts. Since navigating between a root layouts will trigger an MPA navigation, we're still able to enforce that those params will not change. This PR also includes some work to the types plugin generate types for root params because they can be statically determined ahead of time. For example, in the above example, `unstable_rootParams()` will be typed as `Promise<{ foo: string, bar: string }>`. In the case where there are multiple root layouts, it gets a bit more nuanced. e.g. at build time we aren't able to determine if you're going to be accessing rootParams on root layout A or root layout B. For this reason, they'll become optionally typed, eg: `Promise<{ foo?: string }>` where `foo` might only be available on the `/app/(marketing)/[foo]/layout.tsx` root. This feature is experimental and under active development and as such, is currently exported with an `unstable` prefix.
1 parent 033727a commit f76c1ae

File tree

38 files changed

+651
-7
lines changed

38 files changed

+651
-7
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pnpm-lock.yaml
1313

1414
packages/next/src/bundles/webpack/packages/*.runtime.js
1515
packages/next/src/bundles/webpack/packages/lazy-compilation-*.js
16+
packages/next/errors.json
1617

1718
.github/actions/next-stats-action/.work
1819

packages/next/errors.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,5 +611,7 @@
611611
"610": "Could not find a production build in the '%s' directory. Try building your app with 'next build' before starting the static export. https://nextjs.org/docs/messages/next-export-no-build-id",
612612
"611": "Route %s with \\`dynamic = \"error\"\\` couldn't be rendered statically because it used \\`request.%s\\`.",
613613
"612": "ServerPrerenderStreamResult cannot be consumed as a stream because it is not yet complete. status: %s",
614-
"613": "Expected the input to be `string | string[]`"
615-
}
614+
"613": "Expected the input to be `string | string[]`",
615+
"614": "Route %s used \"unstable_rootParams\" inside \"use cache\". This is not currently supported.",
616+
"615": "Missing workStore in unstable_rootParams"
617+
}

packages/next/server.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
1414
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
1515
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
1616
export { after } from 'next/dist/server/after'
17+
export { unstable_rootParams } from 'next/dist/server/request/root-params'
1718
export { connection } from 'next/dist/server/request/connection'
1819
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
1920
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'

packages/next/server.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const serverExports = {
1313
.URLPattern,
1414
after: require('next/dist/server/after').after,
1515
connection: require('next/dist/server/request/connection').connection,
16+
unstable_rootParams: require('next/dist/server/request/root-params')
17+
.unstable_rootParams,
1618
}
1719

1820
// https://nodejs.org/api/esm.html#commonjs-namespaces
@@ -28,3 +30,4 @@ exports.userAgent = serverExports.userAgent
2830
exports.URLPattern = serverExports.URLPattern
2931
exports.after = serverExports.after
3032
exports.connection = serverExports.connection
33+
exports.unstable_rootParams = serverExports.unstable_rootParams

packages/next/src/build/webpack/plugins/next-types-plugin/index.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ async function collectNamedSlots(layoutPath: string) {
237237
// possible to provide the same experience for dynamic routes.
238238

239239
const pluginState = getProxiedPluginState({
240+
collectedRootParams: {} as Record<string, string[]>,
240241
routeTypes: {
241242
edge: {
242243
static: '',
@@ -584,6 +585,103 @@ function formatTimespanWithSeconds(seconds: undefined | number): string {
584585
return text + ' (' + descriptive + ')'
585586
}
586587

588+
function getRootParamsFromLayouts(layouts: Record<string, string[]>) {
589+
// Sort layouts by depth (descending)
590+
const sortedLayouts = Object.entries(layouts).sort(
591+
(a, b) => b[0].split('/').length - a[0].split('/').length
592+
)
593+
594+
if (!sortedLayouts.length) {
595+
return []
596+
}
597+
598+
// we assume the shorted layout path is the root layout
599+
let rootLayout = sortedLayouts[sortedLayouts.length - 1][0]
600+
601+
let rootParams = new Set<string>()
602+
let isMultipleRootLayouts = false
603+
604+
for (const [layoutPath, params] of sortedLayouts) {
605+
const allSegmentsAreDynamic = layoutPath
606+
.split('/')
607+
.slice(1, -1)
608+
// match dynamic params but not catch-all or optional catch-all
609+
.every((segment) => /^\[[^[.\]]+\]$/.test(segment))
610+
611+
if (allSegmentsAreDynamic) {
612+
if (isSubpath(rootLayout, layoutPath)) {
613+
// Current path is a subpath of the root layout, update root
614+
rootLayout = layoutPath
615+
rootParams = new Set(params)
616+
} else {
617+
// Found another potential root layout
618+
isMultipleRootLayouts = true
619+
// Add any new params
620+
for (const param of params) {
621+
rootParams.add(param)
622+
}
623+
}
624+
}
625+
}
626+
627+
// Create result array
628+
const result = Array.from(rootParams).map((param) => ({
629+
param,
630+
optional: isMultipleRootLayouts,
631+
}))
632+
633+
return result
634+
}
635+
636+
function isSubpath(parentLayoutPath: string, potentialChildLayoutPath: string) {
637+
// we strip off the `layout` part of the path as those will always conflict with being a subpath
638+
const parentSegments = parentLayoutPath.split('/').slice(1, -1)
639+
const childSegments = potentialChildLayoutPath.split('/').slice(1, -1)
640+
641+
// child segments should be shorter or equal to parent segments to be a subpath
642+
if (childSegments.length > parentSegments.length || !childSegments.length)
643+
return false
644+
645+
// Verify all segment values are equal
646+
return childSegments.every(
647+
(childSegment, index) => childSegment === parentSegments[index]
648+
)
649+
}
650+
651+
function createServerDefinitions(
652+
rootParams: { param: string; optional: boolean }[]
653+
) {
654+
return `
655+
declare module 'next/server' {
656+
657+
import type { AsyncLocalStorage as NodeAsyncLocalStorage } from 'async_hooks'
658+
declare global {
659+
var AsyncLocalStorage: typeof NodeAsyncLocalStorage
660+
}
661+
export { NextFetchEvent } from 'next/dist/server/web/spec-extension/fetch-event'
662+
export { NextRequest } from 'next/dist/server/web/spec-extension/request'
663+
export { NextResponse } from 'next/dist/server/web/spec-extension/response'
664+
export { NextMiddleware, MiddlewareConfig } from 'next/dist/server/web/types'
665+
export { userAgentFromString } from 'next/dist/server/web/spec-extension/user-agent'
666+
export { userAgent } from 'next/dist/server/web/spec-extension/user-agent'
667+
export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
668+
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
669+
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
670+
export { unstable_after } from 'next/dist/server/after'
671+
export { connection } from 'next/dist/server/request/connection'
672+
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
673+
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'
674+
export function unstable_rootParams(): Promise<{ ${rootParams
675+
.map(
676+
({ param, optional }) =>
677+
// ensure params with dashes are valid keys
678+
`${param.includes('-') ? `'${param}'` : param}${optional ? '?' : ''}: string`
679+
)
680+
.join(', ')} }>
681+
}
682+
`
683+
}
684+
587685
function createCustomCacheLifeDefinitions(cacheLife: {
588686
[profile: string]: CacheLife
589687
}) {
@@ -855,6 +953,22 @@ export class NextTypesPlugin {
855953
if (!IS_IMPORTABLE) return
856954

857955
if (IS_LAYOUT) {
956+
const rootLayoutPath = normalizeAppPath(
957+
ensureLeadingSlash(
958+
getPageFromPath(
959+
path.relative(this.appDir, mod.resource),
960+
this.pageExtensions
961+
)
962+
)
963+
)
964+
965+
const foundParams = Array.from(
966+
rootLayoutPath.matchAll(/\[(.*?)\]/g),
967+
(match) => match[1]
968+
)
969+
970+
pluginState.collectedRootParams[rootLayoutPath] = foundParams
971+
858972
const slots = await collectNamedSlots(mod.resource)
859973
assets[assetPath] = new sources.RawSource(
860974
createTypeGuardFile(mod.resource, relativeImportPath, {
@@ -933,6 +1047,22 @@ export class NextTypesPlugin {
9331047

9341048
await Promise.all(promises)
9351049

1050+
const rootParams = getRootParamsFromLayouts(
1051+
pluginState.collectedRootParams
1052+
)
1053+
// If we discovered rootParams, we'll override the `next/server` types
1054+
// since we're able to determine the root params at build time.
1055+
if (rootParams.length > 0) {
1056+
const serverTypesPath = path.join(
1057+
assetDirRelative,
1058+
'types/server.d.ts'
1059+
)
1060+
1061+
assets[serverTypesPath] = new sources.RawSource(
1062+
createServerDefinitions(rootParams)
1063+
) as unknown as webpack.sources.RawSource
1064+
}
1065+
9361066
// Support `"moduleResolution": "Node16" | "NodeNext"` with `"type": "module"`
9371067

9381068
const packageJsonAssetPath = path.join(

packages/next/src/server/app-render/create-component-tree.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,10 @@ async function createComponentTreeInternal({
396396
// Resolve the segment param
397397
const actualSegment = segmentParam ? segmentParam.treeSegment : segment
398398

399+
if (rootLayoutAtThisLevel) {
400+
workStore.rootParams = currentParams
401+
}
402+
399403
//
400404
// TODO: Combine this `map` traversal with the loop below that turns the array
401405
// into an object.

packages/next/src/server/app-render/work-async-storage.external.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { DeepReadonly } from '../../shared/lib/deep-readonly'
77
import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config'
88
import type { AfterContext } from '../after/after-context'
99
import type { CacheLife } from '../use-cache/cache-life'
10+
import type { Params } from '../request/params'
1011

1112
// Share the instance module in the next-shared layer
1213
import { workAsyncStorageInstance } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
@@ -69,6 +70,8 @@ export interface WorkStore {
6970
Record<string, { files: string[] }>
7071
>
7172
readonly assetPrefix?: string
73+
74+
rootParams: Params
7275
}
7376

7477
export type WorkAsyncStorage = AsyncLocalStorage<WorkStore>

packages/next/src/server/async-storage/work-store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export function createWorkStore({
112112

113113
isDraftMode: renderOpts.isDraftMode,
114114

115+
rootParams: {},
116+
115117
requestEndedState,
116118
isPrefetchRequest,
117119
buildId: renderOpts.buildId,

packages/next/src/server/lib/patch-fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,7 @@ export function createPatchedFetcher(
670670
)
671671
await handleUnlock()
672672

673-
// We we return a new Response to the caller.
673+
// We return a new Response to the caller.
674674
return new Response(bodyBuffer, {
675675
headers: res.headers,
676676
status: res.status,

packages/next/src/server/request/draft-mode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ class DraftMode {
171171
return false
172172
}
173173
public enable() {
174-
// We we have a store we want to track dynamic data access to ensure we
174+
// We have a store we want to track dynamic data access to ensure we
175175
// don't statically generate routes that manipulate draft mode.
176176
trackDynamicDraftMode('draftMode().enable()')
177177
if (this._provider !== null) {
@@ -224,7 +224,7 @@ function trackDynamicDraftMode(expression: string) {
224224
const store = workAsyncStorage.getStore()
225225
const workUnitStore = workUnitAsyncStorage.getStore()
226226
if (store) {
227-
// We we have a store we want to track dynamic data access to ensure we
227+
// We have a store we want to track dynamic data access to ensure we
228228
// don't statically generate routes that manipulate draft mode.
229229
if (workUnitStore) {
230230
if (workUnitStore.type === 'cache') {

0 commit comments

Comments
 (0)