From 32957e522335995143704d7bfac31625cfd56b61 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/errors.json | 3 +- 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 | 199 ++++++++++++++++++ .../server/route-modules/app-route/module.ts | 2 +- packages/next/src/server/web/exports/index.ts | 1 + .../app-root-params/dynamic-io.test.ts | 30 +++ .../dynamic-io/app/[lang]/[locale]/layout.tsx | 16 ++ .../dynamic-io/app/[lang]/[locale]/page.tsx | 19 ++ .../fixtures/dynamic-io/next.config.js | 5 + .../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 +++ 37 files changed, 648 insertions(+), 6 deletions(-) create mode 100644 packages/next/src/server/request/root-params.ts create mode 100644 test/e2e/app-dir/app-root-params/dynamic-io.test.ts create mode 100644 test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/layout.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/page.tsx create mode 100644 test/e2e/app-dir/app-root-params/fixtures/dynamic-io/next.config.js 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/errors.json b/packages/next/errors.json index 020e25df0bdbf..70ecca525e565 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -524,5 +524,6 @@ "523": "[%s]: experimental.useLightningcss does not work with postcss plugins. Please remove 'useLightningcss: true' from your configuration.", "524": "A default cacheLife profile must always be provided. This is a bug in Next.js.", "525": "cache: 'no-store' used on fetch for %s with 'export const fetchCache = 'only-cache'", - "526": "Invariant: did not expect response writer to be written to for upgrade request" + "526": "Invariant: did not expect response writer to be written to for upgrade request", + "527": "Route %s used \"unstable_rootParams\" inside \"use cache\". This is not currently supported." } diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 46b8a39c0fe79..09a3b20709279 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 { 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 2704955a25f36..cd0dcecc135bd 100644 --- a/packages/next/server.js +++ b/packages/next/server.js @@ -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 @@ -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 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 23b851cc21292..29ed3e36be8e4 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 eb1e6d27db5f7..2154934e99a85 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -403,6 +403,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 fa6fa5b22f70c..eb78373aec06e 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 { workAsyncStorageInstance } 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 7096973984c04..4a85b8a832696 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 60fca42908ba3..00a3677ec5436 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -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, diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index 7d3540c5092a1..37d9902bf1d23 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -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) { @@ -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') { 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..79a085a96322e --- /dev/null +++ b/packages/next/src/server/request/root-params.ts @@ -0,0 +1,199 @@ +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 'cache': { + // TODO: We need to be able to express this case with PPR+DynamicIO. + // We don't want rootParams to leak into the fallback shell, and we don't + // currently have a way to express `dynamicParams = false`. + if (workStore.fallbackRouteParams && process.env.__NEXT_PPR) { + throw new Error( + `Route ${workStore.route} used "unstable_rootParams" inside "use cache". This is not currently supported.` + ) + } + + return makeUntrackedRootParams(underlyingParams) + } + 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 { + console.log('makeUntrackedRootParams', underlyingParams) + 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 0424c1f5c4f1b..23f1f79679453 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -559,7 +559,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 38b1264ed87bf..74f3d41181f88 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 { 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/dynamic-io.test.ts b/test/e2e/app-dir/app-root-params/dynamic-io.test.ts new file mode 100644 index 0000000000000..3bd7a26303ee8 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/dynamic-io.test.ts @@ -0,0 +1,30 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' + +const isPPREnabled = process.env.__NEXT_EXPERIMENTAL_PPR === 'true' + +describe('app-root-params - dynamicIO', () => { + const { next } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'dynamic-io'), + skipStart: isPPREnabled, + skipDeployment: isPPREnabled, + }) + + // TODO: We need to figure out how to handle cached root params when + // generating a PPR fallback shell. + if (isPPREnabled) { + it('is not currently enabled', () => {}) + } else { + it('should prerender pages when using rootParams in generateStaticParams', async () => { + const $ = await next.render$('/en/us') + expect($('#param').text()).toBe('en us') + const initialRandom = $('#random').text() + expect(initialRandom).toMatch(/0(\.\d+)?$/) + + const $2 = await next.render$('/en/us') + expect($2('#param').text()).toBe('en us') + const random = $2('#random').text() + expect(random).toBe(initialRandom) + }) + } +}) diff --git a/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..0502ea507ea10 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/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/dynamic-io/app/[lang]/[locale]/page.tsx b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/page.tsx new file mode 100644 index 0000000000000..8df9103917574 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/app/[lang]/[locale]/page.tsx @@ -0,0 +1,19 @@ +'use cache' + +import { unstable_rootParams } from 'next/server' + +export default async function Page() { + const rootParams = await unstable_rootParams() + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random' + ).then((res) => res.text()) + + return ( +

+ + {rootParams.lang} {rootParams.locale} + {' '} + {data} +

+ ) +} diff --git a/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/next.config.js b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/next.config.js new file mode 100644 index 0000000000000..bb76fc99dc6b6 --- /dev/null +++ b/test/e2e/app-dir/app-root-params/fixtures/dynamic-io/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + dynamicIO: true, + }, +} 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 }>` + ) + }) + } +})