Skip to content

Commit

Permalink
feat: rootParams
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Nov 15, 2024
1 parent 560d95b commit ec32809
Show file tree
Hide file tree
Showing 32 changed files with 510 additions and 5 deletions.
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 { unstable_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,
unstable_after: require('next/dist/server/after').unstable_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.unstable_after = serverExports.unstable_after
exports.connection = serverExports.connection
exports.unstable_rootParams = serverExports.unstable_rootParams
90 changes: 90 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,63 @@ function formatTimespanWithSeconds(seconds: undefined | number): string {
return text + ' (' + descriptive + ')'
}

function getRootParamsFromLayouts(layoutsMap: Record<string, string[]>) {
let shortestLayoutLength = Infinity
const rootLayouts: { route: string; params: string[] }[] = []
const allRootParams = new Set<string>()

for (const [route, params] of Object.entries(layoutsMap)) {
const segments = route.split('/')
if (segments.length <= shortestLayoutLength) {
if (segments.length < shortestLayoutLength) {
rootLayouts.length = 0 // Clear previous layouts if we found a shorter one
shortestLayoutLength = segments.length
}
rootLayouts.push({ route, params })
params.forEach((param) => allRootParams.add(param))
}
}

const result = Array.from(allRootParams).map((param) => ({
param,
// if we detected multiple root layouts and not all of them have the param,
// then it needs to be marked optional in the type.
optional: !rootLayouts.every((layout) => layout.params.includes(param)),
}))

return result
}

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 }) => `${param}${optional ? '?' : ''}: string`)
.join(', ')} }>
}
`
}

function createCustomCacheLifeDefinitions(cacheLife: {
[profile: string]: CacheLife
}) {
Expand Down Expand Up @@ -855,6 +913,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 +1007,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 @@ -331,6 +331,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 { workAsyncStorage } 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 @@ -669,7 +669,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 @@ -176,7 +176,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 @@ -229,7 +229,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
179 changes: 179 additions & 0 deletions packages/next/src/server/request/root-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { InvariantError } from '../../shared/lib/invariant-error'
import {
postponeWithTracking,
throwToInterruptStaticGeneration,
} from '../app-render/dynamic-rendering'
import {
workAsyncStorage,
type WorkStore,
} from '../app-render/work-async-storage.external'
import {
workUnitAsyncStorage,
type PrerenderStore,
type PrerenderStoreLegacy,
type PrerenderStorePPR,
} from '../app-render/work-unit-async-storage.external'
import { makeHangingPromise } from '../dynamic-rendering-utils'
import type { FallbackRouteParams } from './fallback-params'
import type { Params } from './params'
import { describeStringPropertyAccess, wellKnownProperties } from './utils'

interface CacheLifetime {}
const CachedParams = new WeakMap<CacheLifetime, Promise<Params>>()

export async function unstable_rootParams(): Promise<Params> {
const workStore = workAsyncStorage.getStore()
const workUnitStore = workUnitAsyncStorage.getStore()

if (!workStore) {
throw new InvariantError('Missing workStore in unstable_rootParams')
}

const underlyingParams = workStore.rootParams

if (workUnitStore) {
switch (workUnitStore.type) {
case 'prerender':
case 'prerender-ppr':
case 'prerender-legacy':
return createPrerenderParams(underlyingParams, workStore, workUnitStore)
default:
// fallthrough
}
}
return makeUntrackedRootParams(underlyingParams)
}

function createPrerenderParams(
underlyingParams: Params,
workStore: WorkStore,
prerenderStore: PrerenderStore
): Promise<Params> {
const fallbackParams = workStore.fallbackRouteParams
if (fallbackParams) {
let hasSomeFallbackParams = false
for (const key in underlyingParams) {
if (fallbackParams.has(key)) {
hasSomeFallbackParams = true
break
}
}

if (hasSomeFallbackParams) {
// params need to be treated as dynamic because we have at least one fallback param
if (prerenderStore.type === 'prerender') {
// We are in a dynamicIO (PPR or otherwise) prerender
const cachedParams = CachedParams.get(underlyingParams)
if (cachedParams) {
return cachedParams
}

const promise = makeHangingPromise<Params>(
prerenderStore.renderSignal,
'`unstable_rootParams`'
)
CachedParams.set(underlyingParams, promise)

return promise
}
// 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 params object which will postpone
// if you access the fallback params
return makeErroringRootParams(
underlyingParams,
fallbackParams,
workStore,
prerenderStore
)
}
}

// We don't have any fallback params so we have an entirely static safe params object
return makeUntrackedRootParams(underlyingParams)
}

function makeErroringRootParams(
underlyingParams: Params,
fallbackParams: FallbackRouteParams,
workStore: WorkStore,
prerenderStore: PrerenderStorePPR | PrerenderStoreLegacy
): Promise<Params> {
const cachedParams = CachedParams.get(underlyingParams)
if (cachedParams) {
return cachedParams
}

const augmentedUnderlying = { ...underlyingParams }

// We don't use makeResolvedReactPromise here because params
// supports copying with spread and we don't want to unnecessarily
// instrument the promise with spreadable properties of ReactPromise.
const promise = Promise.resolve(augmentedUnderlying)
CachedParams.set(underlyingParams, promise)

Object.keys(underlyingParams).forEach((prop) => {
if (wellKnownProperties.has(prop)) {
// These properties cannot be shadowed because they need to be the
// true underlying value for Promises to work correctly at runtime
} else {
if (fallbackParams.has(prop)) {
Object.defineProperty(augmentedUnderlying, prop, {
get() {
const expression = describeStringPropertyAccess('params', prop)
// In most dynamic APIs we also throw if `dynamic = "error"` however
// for params is only dynamic when we're generating a fallback shell
// and even when `dynamic = "error"` we still support generating dynamic
// fallback shells
// TODO remove this comment when dynamicIO is the default since there
// will be no `dynamic = "error"`
if (prerenderStore.type === 'prerender-ppr') {
// PPR Prerender (no dynamicIO)
postponeWithTracking(
workStore.route,
expression,
prerenderStore.dynamicTracking
)
} else {
// Legacy Prerender
throwToInterruptStaticGeneration(
expression,
workStore,
prerenderStore
)
}
},
enumerable: true,
})
} else {
;(promise as any)[prop] = underlyingParams[prop]
}
}
})

return promise
}

function makeUntrackedRootParams(underlyingParams: Params): Promise<Params> {
const cachedParams = CachedParams.get(underlyingParams)
if (cachedParams) {
return cachedParams
}

// We don't use makeResolvedReactPromise here because params
// supports copying with spread and we don't want to unnecessarily
// instrument the promise with spreadable properties of ReactPromise.
const promise = Promise.resolve(underlyingParams)
CachedParams.set(underlyingParams, promise)

Object.keys(underlyingParams).forEach((prop) => {
if (wellKnownProperties.has(prop)) {
// These properties cannot be shadowed because they need to be the
// true underlying value for Promises to work correctly at runtime
} else {
;(promise as any)[prop] = underlyingParams[prop]
}
})

return promise
}
Loading

0 comments on commit ec32809

Please sign in to comment.