Skip to content

Commit

Permalink
feat: rootParams (experimental) (#72837)
Browse files Browse the repository at this point in the history
### 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.
  • Loading branch information
ztanner authored Dec 16, 2024
1 parent 033727a commit f76c1ae
Show file tree
Hide file tree
Showing 38 changed files with 651 additions and 7 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pnpm-lock.yaml

packages/next/src/bundles/webpack/packages/*.runtime.js
packages/next/src/bundles/webpack/packages/lazy-compilation-*.js
packages/next/errors.json

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

Expand Down
6 changes: 4 additions & 2 deletions packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,5 +611,7 @@
"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",
"611": "Route %s with \\`dynamic = \"error\"\\` couldn't be rendered statically because it used \\`request.%s\\`.",
"612": "ServerPrerenderStreamResult cannot be consumed as a stream because it is not yet complete. status: %s",
"613": "Expected the input to be `string | string[]`"
}
"613": "Expected the input to be `string | string[]`",
"614": "Route %s used \"unstable_rootParams\" inside \"use cache\". This is not currently supported.",
"615": "Missing workStore in unstable_rootParams"
}
1 change: 1 addition & 0 deletions packages/next/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
export { after } from 'next/dist/server/after'
export { unstable_rootParams } from 'next/dist/server/request/root-params'
export { connection } from 'next/dist/server/request/connection'
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'
3 changes: 3 additions & 0 deletions packages/next/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const serverExports = {
.URLPattern,
after: require('next/dist/server/after').after,
connection: require('next/dist/server/request/connection').connection,
unstable_rootParams: require('next/dist/server/request/root-params')
.unstable_rootParams,
}

// https://nodejs.org/api/esm.html#commonjs-namespaces
Expand All @@ -28,3 +30,4 @@ exports.userAgent = serverExports.userAgent
exports.URLPattern = serverExports.URLPattern
exports.after = serverExports.after
exports.connection = serverExports.connection
exports.unstable_rootParams = serverExports.unstable_rootParams
130 changes: 130 additions & 0 deletions packages/next/src/build/webpack/plugins/next-types-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ async function collectNamedSlots(layoutPath: string) {
// possible to provide the same experience for dynamic routes.

const pluginState = getProxiedPluginState({
collectedRootParams: {} as Record<string, string[]>,
routeTypes: {
edge: {
static: '',
Expand Down Expand Up @@ -584,6 +585,103 @@ function formatTimespanWithSeconds(seconds: undefined | number): string {
return text + ' (' + descriptive + ')'
}

function getRootParamsFromLayouts(layouts: Record<string, string[]>) {
// Sort layouts by depth (descending)
const sortedLayouts = Object.entries(layouts).sort(
(a, b) => b[0].split('/').length - a[0].split('/').length
)

if (!sortedLayouts.length) {
return []
}

// we assume the shorted layout path is the root layout
let rootLayout = sortedLayouts[sortedLayouts.length - 1][0]

let rootParams = new Set<string>()
let isMultipleRootLayouts = false

for (const [layoutPath, params] of sortedLayouts) {
const allSegmentsAreDynamic = layoutPath
.split('/')
.slice(1, -1)
// match dynamic params but not catch-all or optional catch-all
.every((segment) => /^\[[^[.\]]+\]$/.test(segment))

if (allSegmentsAreDynamic) {
if (isSubpath(rootLayout, layoutPath)) {
// Current path is a subpath of the root layout, update root
rootLayout = layoutPath
rootParams = new Set(params)
} else {
// Found another potential root layout
isMultipleRootLayouts = true
// Add any new params
for (const param of params) {
rootParams.add(param)
}
}
}
}

// Create result array
const result = Array.from(rootParams).map((param) => ({
param,
optional: isMultipleRootLayouts,
}))

return result
}

function isSubpath(parentLayoutPath: string, potentialChildLayoutPath: string) {
// we strip off the `layout` part of the path as those will always conflict with being a subpath
const parentSegments = parentLayoutPath.split('/').slice(1, -1)
const childSegments = potentialChildLayoutPath.split('/').slice(1, -1)

// child segments should be shorter or equal to parent segments to be a subpath
if (childSegments.length > parentSegments.length || !childSegments.length)
return false

// Verify all segment values are equal
return childSegments.every(
(childSegment, index) => childSegment === parentSegments[index]
)
}

function createServerDefinitions(
rootParams: { param: string; optional: boolean }[]
) {
return `
declare module 'next/server' {
import type { AsyncLocalStorage as NodeAsyncLocalStorage } from 'async_hooks'
declare global {
var AsyncLocalStorage: typeof NodeAsyncLocalStorage
}
export { NextFetchEvent } from 'next/dist/server/web/spec-extension/fetch-event'
export { NextRequest } from 'next/dist/server/web/spec-extension/request'
export { NextResponse } from 'next/dist/server/web/spec-extension/response'
export { NextMiddleware, MiddlewareConfig } from 'next/dist/server/web/types'
export { userAgentFromString } from 'next/dist/server/web/spec-extension/user-agent'
export { userAgent } from 'next/dist/server/web/spec-extension/user-agent'
export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url'
export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response'
export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types'
export { unstable_after } from 'next/dist/server/after'
export { connection } from 'next/dist/server/request/connection'
export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params'
export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params'
export function unstable_rootParams(): Promise<{ ${rootParams
.map(
({ param, optional }) =>
// ensure params with dashes are valid keys
`${param.includes('-') ? `'${param}'` : param}${optional ? '?' : ''}: string`
)
.join(', ')} }>
}
`
}

function createCustomCacheLifeDefinitions(cacheLife: {
[profile: string]: CacheLife
}) {
Expand Down Expand Up @@ -855,6 +953,22 @@ export class NextTypesPlugin {
if (!IS_IMPORTABLE) return

if (IS_LAYOUT) {
const rootLayoutPath = normalizeAppPath(
ensureLeadingSlash(
getPageFromPath(
path.relative(this.appDir, mod.resource),
this.pageExtensions
)
)
)

const foundParams = Array.from(
rootLayoutPath.matchAll(/\[(.*?)\]/g),
(match) => match[1]
)

pluginState.collectedRootParams[rootLayoutPath] = foundParams

const slots = await collectNamedSlots(mod.resource)
assets[assetPath] = new sources.RawSource(
createTypeGuardFile(mod.resource, relativeImportPath, {
Expand Down Expand Up @@ -933,6 +1047,22 @@ export class NextTypesPlugin {

await Promise.all(promises)

const rootParams = getRootParamsFromLayouts(
pluginState.collectedRootParams
)
// If we discovered rootParams, we'll override the `next/server` types
// since we're able to determine the root params at build time.
if (rootParams.length > 0) {
const serverTypesPath = path.join(
assetDirRelative,
'types/server.d.ts'
)

assets[serverTypesPath] = new sources.RawSource(
createServerDefinitions(rootParams)
) as unknown as webpack.sources.RawSource
}

// Support `"moduleResolution": "Node16" | "NodeNext"` with `"type": "module"`

const packageJsonAssetPath = path.join(
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ async function createComponentTreeInternal({
// Resolve the segment param
const actualSegment = segmentParam ? segmentParam.treeSegment : segment

if (rootLayoutAtThisLevel) {
workStore.rootParams = currentParams
}

//
// TODO: Combine this `map` traversal with the loop below that turns the array
// into an object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { DeepReadonly } from '../../shared/lib/deep-readonly'
import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config'
import type { AfterContext } from '../after/after-context'
import type { CacheLife } from '../use-cache/cache-life'
import type { Params } from '../request/params'

// Share the instance module in the next-shared layer
import { workAsyncStorageInstance } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
Expand Down Expand Up @@ -69,6 +70,8 @@ export interface WorkStore {
Record<string, { files: string[] }>
>
readonly assetPrefix?: string

rootParams: Params
}

export type WorkAsyncStorage = AsyncLocalStorage<WorkStore>
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/async-storage/work-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export function createWorkStore({

isDraftMode: renderOpts.isDraftMode,

rootParams: {},

requestEndedState,
isPrefetchRequest,
buildId: renderOpts.buildId,
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/lib/patch-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ export function createPatchedFetcher(
)
await handleUnlock()

// We we return a new Response to the caller.
// We return a new Response to the caller.
return new Response(bodyBuffer, {
headers: res.headers,
status: res.status,
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/server/request/draft-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ class DraftMode {
return false
}
public enable() {
// We we have a store we want to track dynamic data access to ensure we
// We have a store we want to track dynamic data access to ensure we
// don't statically generate routes that manipulate draft mode.
trackDynamicDraftMode('draftMode().enable()')
if (this._provider !== null) {
Expand Down Expand Up @@ -224,7 +224,7 @@ function trackDynamicDraftMode(expression: string) {
const store = workAsyncStorage.getStore()
const workUnitStore = workUnitAsyncStorage.getStore()
if (store) {
// We we have a store we want to track dynamic data access to ensure we
// We have a store we want to track dynamic data access to ensure we
// don't statically generate routes that manipulate draft mode.
if (workUnitStore) {
if (workUnitStore.type === 'cache') {
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/server/request/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function createPrerenderParams(
prerenderStore
)
}
// remaining cases are prender-ppr and prerender-legacy
// remaining cases are prerender-ppr and prerender-legacy
// We aren't in a dynamicIO prerender but we do have fallback params at this
// level so we need to make an erroring exotic params object which will postpone
// if you access the fallback params
Expand Down
Loading

0 comments on commit f76c1ae

Please sign in to comment.