From e95d12788ba54ed6d851db2f1048619fe03d2024 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Tue, 12 Nov 2024 08:39:11 -0800 Subject: [PATCH] feat: rootParams --- packages/next/server.d.ts | 1 + packages/next/server.js | 3 + .../plugins/next-types-plugin/index.ts | 130 ++++++++++++ .../app-render/create-component-tree.tsx | 4 + .../app-render/work-async-storage.external.ts | 3 + .../src/server/async-storage/work-store.ts | 2 + packages/next/src/server/lib/patch-fetch.ts | 2 +- .../next/src/server/request/draft-mode.ts | 4 +- packages/next/src/server/request/params.ts | 2 +- .../next/src/server/request/root-params.ts | 186 ++++++++++++++++++ .../server/route-modules/app-route/module.ts | 2 +- packages/next/src/server/web/exports/index.ts | 1 + .../app/[lang]/[locale]/layout.tsx | 16 ++ .../[lang]/[locale]/other/[slug]/layout.tsx | 3 + .../app/[lang]/[locale]/other/[slug]/page.tsx | 13 ++ .../app/[lang]/[locale]/other/layout.tsx | 3 + .../app/[lang]/[locale]/other/page.tsx | 3 + .../app/[lang]/[locale]/page.tsx | 5 + .../app/(dashboard)/[id]/data/page.tsx | 5 + .../app/(dashboard)/[id]/layout.tsx | 9 + .../app/(marketing)/landing/page.tsx | 5 + .../multiple-roots/app/(marketing)/layout.tsx | 9 + .../simple/app/[lang]/[locale]/layout.tsx | 9 + .../[lang]/[locale]/other/[slug]/layout.tsx | 3 + .../app/[lang]/[locale]/other/[slug]/page.tsx | 13 ++ .../app/[lang]/[locale]/other/layout.tsx | 3 + .../simple/app/[lang]/[locale]/other/page.tsx | 3 + .../simple/app/[lang]/[locale]/page.tsx | 5 + .../generate-static-params.test.ts | 50 +++++ .../app-root-params/multiple-roots.test.ts | 33 ++++ .../app-dir/app-root-params/next.config.js | 6 + .../app-dir/app-root-params/simple.test.ts | 32 +++ 32 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 packages/next/src/server/request/root-params.ts create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/data/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/landing/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/generate-static-params.test.ts create mode 100644 test/e2e/app-dir/app-root-params/multiple-roots.test.ts create mode 100644 test/e2e/app-dir/app-root-params/next.config.js create mode 100644 test/e2e/app-dir/app-root-params/simple.test.ts diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 2b94d798ff84e..0a667c3213621 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -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' diff --git a/packages/next/server.js b/packages/next/server.js index ff224a2bb5a93..4805ed594c15d 100644 --- a/packages/next/server.js +++ b/packages/next/server.js @@ -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 @@ -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 diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index f2d481fd053a8..f3962592d8880 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -237,6 +237,7 @@ async function collectNamedSlots(layoutPath: string) { // possible to provide the same experience for dynamic routes. const pluginState = getProxiedPluginState({ + collectedRootParams: {} as Record, routeTypes: { edge: { static: '', @@ -584,6 +585,103 @@ function formatTimespanWithSeconds(seconds: undefined | number): string { return text + ' (' + descriptive + ')' } +function getRootParamsFromLayouts(layouts: Record) { + // 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() + 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 }) { @@ -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, { @@ -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( diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index aae3045113d99..0e7bfd1a8c036 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -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. diff --git a/packages/next/src/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index 17efa63b0c184..61bc8ce8c7ad9 100644 --- a/packages/next/src/server/app-render/work-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-async-storage.external.ts @@ -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' } @@ -69,6 +70,8 @@ export interface WorkStore { Record > readonly assetPrefix?: string + + rootParams: Params } export type WorkAsyncStorage = AsyncLocalStorage diff --git a/packages/next/src/server/async-storage/work-store.ts b/packages/next/src/server/async-storage/work-store.ts index 3939fbc3be939..a7d19003c63c6 100644 --- a/packages/next/src/server/async-storage/work-store.ts +++ b/packages/next/src/server/async-storage/work-store.ts @@ -112,6 +112,8 @@ export function createWorkStore({ isDraftMode: renderOpts.isDraftMode, + rootParams: {}, + requestEndedState, isPrefetchRequest, buildId: renderOpts.buildId, diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index ef003e8ae1773..23a65d2c8edc6 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -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, diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index 954f48a43b380..2a13a6f5174ba 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -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) { @@ -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') { diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 81a4e72cab7cc..6ff702eb24954 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -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 diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts new file mode 100644 index 0000000000000..bd26e750f2a52 --- /dev/null +++ b/packages/next/src/server/request/root-params.ts @@ -0,0 +1,186 @@ +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>() + +export async function unstable_rootParams(): Promise { + 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 createPrerenderRootParams( + underlyingParams, + workStore, + workUnitStore + ) + default: + // fallthrough + } + } + return makeUntrackedRootParams(underlyingParams) +} + +function createPrerenderRootParams( + underlyingParams: Params, + workStore: WorkStore, + prerenderStore: PrerenderStore +): Promise { + 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( + 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 { + 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( + 'unstable_rootParams', + 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 { + 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 +} diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index a1889ea8d13a1..29817a73d8cb2 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -546,7 +546,7 @@ export class AppRouteRouteModule extends RouteModule< // Let's append any cookies that were added by the // cookie API. - // TODO leaving the gate here b/c it indicates that we we might not actually want to do this + // TODO leaving the gate here b/c it indicates that we might not actually want to do this // on every `do` call. During prerender there should be no mutableCookies because if (requestStore.type === 'request') { appendMutableCookies(headers, requestStore.mutableCookies) diff --git a/packages/next/src/server/web/exports/index.ts b/packages/next/src/server/web/exports/index.ts index fc5fc2bbb4883..74aab183c82e9 100644 --- a/packages/next/src/server/web/exports/index.ts +++ b/packages/next/src/server/web/exports/index.ts @@ -7,3 +7,4 @@ export { userAgent, userAgentFromString } from '../spec-extension/user-agent' export { URLPattern } from '../spec-extension/url-pattern' export { unstable_after } from '../../after' export { connection } from '../../request/connection' +export { unstable_rootParams } from '../../request/root-params' diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..0502ea507ea10 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [ + { lang: 'en', locale: 'en-US' }, + { lang: 'es', locale: 'es-ES' }, + ] +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx new file mode 100644 index 0000000000000..760aaed24e143 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx @@ -0,0 +1,13 @@ +import { headers } from 'next/headers' +import { unstable_rootParams } from 'next/server' + +export default async function Page({ params }) { + await headers() + const { slug } = await params + return ( +
+

{slug}

+

{JSON.stringify(await unstable_rootParams())}

+
+ ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx new file mode 100644 index 0000000000000..187b865111066 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Other Page
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx new file mode 100644 index 0000000000000..f241e008dc96f --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx @@ -0,0 +1,5 @@ +import { unstable_rootParams } from 'next/server' + +export default async function Page() { + return

hello world {JSON.stringify(await unstable_rootParams())}

+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/data/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/data/page.tsx new file mode 100644 index 0000000000000..f241e008dc96f --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/data/page.tsx @@ -0,0 +1,5 @@ +import { unstable_rootParams } from 'next/server' + +export default async function Page() { + return

hello world {JSON.stringify(await unstable_rootParams())}

+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx new file mode 100644 index 0000000000000..cde4bf8fe4b43 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(dashboard)/[id]/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + Dashboard Root: {children} + + ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/landing/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/landing/page.tsx new file mode 100644 index 0000000000000..f241e008dc96f --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/landing/page.tsx @@ -0,0 +1,5 @@ +import { unstable_rootParams } from 'next/server' + +export default async function Page() { + return

hello world {JSON.stringify(await unstable_rootParams())}

+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/layout.tsx new file mode 100644 index 0000000000000..ab9238c0e14de --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/multiple-roots/app/(marketing)/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + Marketing Root: {children} + + ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..716a8db36f52c --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx new file mode 100644 index 0000000000000..f5dcadb7a7bbe --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx @@ -0,0 +1,13 @@ +import { cookies } from 'next/headers' +import { unstable_rootParams } from 'next/server' + +export default async function Page({ params }) { + await cookies() + const { slug } = await params + return ( +
+

{slug}

+

{JSON.stringify(await unstable_rootParams())}

+
+ ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/page.tsx new file mode 100644 index 0000000000000..187b865111066 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/other/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Other Page
+} diff --git a/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/page.tsx new file mode 100644 index 0000000000000..f241e008dc96f --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/simple/app/[lang]/[locale]/page.tsx @@ -0,0 +1,5 @@ +import { unstable_rootParams } from 'next/server' + +export default async function Page() { + return

hello world {JSON.stringify(await unstable_rootParams())}

+} diff --git a/test/e2e/app-dir/app-root-params/generate-static-params.test.ts b/test/e2e/app-dir/app-root-params/generate-static-params.test.ts new file mode 100644 index 0000000000000..12b95cc7ab015 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/generate-static-params.test.ts @@ -0,0 +1,50 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' + +describe('app-root-params - generateStaticParams', () => { + const { next, isNextDeploy, isTurbopack } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'generate-static-params'), + }) + + it('should return rootParams', async () => { + const $ = await next.render$('/en/us') + expect($('p').text()).toBe('hello world {"lang":"en","locale":"us"}') + }) + + it('should only return rootParams and not other params', async () => { + const $ = await next.render$('/en/us/other/1') + expect($('#dynamic-params').text()).toBe('1') + expect($('#root-params').text()).toBe('{"lang":"en","locale":"us"}') + }) + + it('should be a cache hit for fully prerendered pages', async () => { + const response = await next.fetch('/en/us') + expect(response.status).toBe(200) + expect( + response.headers.get(isNextDeploy ? 'x-vercel-cache' : 'x-nextjs-cache') + ).toBe('HIT') + }) + + it("should be a cache miss for pages that aren't prerendered", async () => { + const response = await next.fetch('/en/us/other/1') + expect(response.status).toBe(200) + if (isNextDeploy) { + expect(response.headers.get('x-vercel-cache')).toBe('MISS') + } else { + expect(response.headers.get('x-nextjs-cache')).toBeFalsy() + } + }) + + // `next-types-plugin` currently only runs in Webpack. + // We skip deployment mode since we don't care about the deploy, we just want to + // check the file generated at build time. + if (!isNextDeploy && !isTurbopack) { + it('should correctly generate types', async () => { + expect(await next.hasFile('.next/types/server.d.ts')).toBe(true) + const fileContents = await next.readFile('.next/types/server.d.ts') + expect(fileContents).toContain( + `export function unstable_rootParams(): Promise<{ lang: string, locale: string }>` + ) + }) + } +}) diff --git a/test/e2e/app-dir/app-root-params/multiple-roots.test.ts b/test/e2e/app-dir/app-root-params/multiple-roots.test.ts new file mode 100644 index 0000000000000..6dc4d6985dc39 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/multiple-roots.test.ts @@ -0,0 +1,33 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' + +describe('app-root-params - multiple roots', () => { + const { next, isNextDeploy, isTurbopack } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'multiple-roots'), + }) + + it('should have root params on dashboard pages', async () => { + const $ = await next.render$('/1/data') + expect($('body').text()).toContain('Dashboard Root') + expect($('p').text()).toBe('hello world {"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 {}') + }) + + // `next-types-plugin` currently only runs in Webpack. + // We skip deployment mode since we don't care about the deploy, we just want to + // check the file generated at build time. + if (!isNextDeploy && !isTurbopack) { + it('should correctly generate types', async () => { + expect(await next.hasFile('.next/types/server.d.ts')).toBe(true) + const fileContents = await next.readFile('.next/types/server.d.ts') + expect(fileContents).toContain( + `export function unstable_rootParams(): Promise<{ id?: string }>` + ) + }) + } +}) diff --git a/test/e2e/app-dir/app-root-params/next.config.js b/test/e2e/app-dir/app-root-params/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/app-root-params/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/app-root-params/simple.test.ts b/test/e2e/app-dir/app-root-params/simple.test.ts new file mode 100644 index 0000000000000..f6aebc3d1e5b5 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/simple.test.ts @@ -0,0 +1,32 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' + +describe('app-root-params - simple', () => { + const { next, isNextDeploy, isTurbopack } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'simple'), + }) + + it('should return rootParams', async () => { + const $ = await next.render$('/en/us') + expect($('p').text()).toBe('hello world {"lang":"en","locale":"us"}') + }) + + it('should only return rootParams and not other params', async () => { + const $ = await next.render$('/en/us/other/1') + expect($('#dynamic-params').text()).toBe('1') + expect($('#root-params').text()).toBe('{"lang":"en","locale":"us"}') + }) + + // `next-types-plugin` currently only runs in Webpack. + // We skip deployment mode since we don't care about the deploy, we just want to + // check the file generated at build time. + if (!isNextDeploy && !isTurbopack) { + it('should correctly generate types', async () => { + expect(await next.hasFile('.next/types/server.d.ts')).toBe(true) + const fileContents = await next.readFile('.next/types/server.d.ts') + expect(fileContents).toContain( + `export function unstable_rootParams(): Promise<{ lang: string, locale: string }>` + ) + }) + } +})