diff --git a/packages/next/errors.json b/packages/next/errors.json index 83944174b1305..f01ba7e3e4a08 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -613,5 +613,10 @@ "612": "ServerPrerenderStreamResult cannot be consumed as a stream because it is not yet complete. status: %s", "613": "Expected the input to be `string | string[]`", "614": "Route %s used \"unstable_rootParams\" inside \"use cache\". This is not currently supported.", - "615": "Missing workStore in unstable_rootParams" + "615": "Missing workStore in unstable_rootParams", + "616": "App config not found", + "617": "A required parameter (%s) was not provided as a string received %s in generateStaticParams for %s", + "618": "A required parameter (%s) was not provided as an array received %s in generateStaticParams for %s", + "619": "Page not found", + "620": "A required parameter (%s) was not provided as %s received %s in getStaticPaths for %s" } \ No newline at end of file diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index db15eb0aaef0c..0b7378cc0171e 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -134,6 +134,7 @@ import { collectMeta, } from './utils' import type { PageInfo, PageInfos } from './utils' +import type { PrerenderedRoute } from './static-paths/types' import type { AppSegmentConfig } from './segment-config/app/app-segment-config' import { writeBuildId } from './write-build-id' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' @@ -212,7 +213,7 @@ import { formatNodeOptions, getParsedNodeOptionsWithoutInspect, } from '../server/lib/utils' -import type { PrerenderedRoute } from './static-paths/types' +import { InvariantError } from '../shared/lib/invariant-error' type Fallback = null | boolean | string @@ -2131,11 +2132,16 @@ export default async function build( } else { const isDynamic = isDynamicRoute(page) + if ( + typeof workerResult.isRoutePPREnabled === 'boolean' + ) { + isRoutePPREnabled = workerResult.isRoutePPREnabled + } + // If this route can be partially pre-rendered, then // mark it as such and mark that it can be // generated server-side. if (workerResult.isRoutePPREnabled) { - isRoutePPREnabled = workerResult.isRoutePPREnabled isSSG = true isStatic = true @@ -2160,7 +2166,7 @@ export default async function build( workerResult.prerenderedRoutes ) ssgPageRoutes = workerResult.prerenderedRoutes.map( - (route) => route.path + (route) => route.pathname ) isSSG = true } @@ -2188,9 +2194,12 @@ export default async function build( if (!isDynamic) { staticPaths.set(originalAppPath, [ { - path: page, - encoded: page, + pathname: page, + encodedPathname: page, fallbackRouteParams: undefined, + fallbackMode: + workerResult.prerenderFallbackMode, + fallbackRootParams: undefined, }, ]) isStatic = true @@ -2256,7 +2265,7 @@ export default async function build( workerResult.prerenderedRoutes ) ssgPageRoutes = workerResult.prerenderedRoutes.map( - (route) => route.path + (route) => route.pathname ) } @@ -2692,7 +2701,7 @@ export default async function build( new Map( Array.from(additionalPaths.entries()).map( ([page, routes]): [string, string[]] => { - return [page, routes.map((route) => route.path)] + return [page, routes.map((route) => route.pathname)] } ) ) @@ -2743,9 +2752,9 @@ export default async function build( // post slugs. additionalPaths.forEach((routes, page) => { routes.forEach((route) => { - defaultMap[route.path] = { + defaultMap[route.pathname] = { page, - query: { __nextSsgPath: route.encoded }, + query: { __nextSsgPath: route.encodedPathname }, } }) }) @@ -2773,9 +2782,9 @@ export default async function build( : undefined routes.forEach((route) => { - defaultMap[route.path] = { + defaultMap[route.pathname] = { page: originalAppPath, - query: { __nextSsgPath: route.encoded }, + query: { __nextSsgPath: route.encodedPathname }, _fallbackRouteParams: route.fallbackRouteParams, _isDynamicError: isDynamicError, _isAppDir: true, @@ -2885,8 +2894,11 @@ export default async function build( } staticPaths.forEach((prerenderedRoutes, originalAppPath) => { - const page = appNormalizedPaths.get(originalAppPath) || '' - const appConfig = appDefaultConfigs.get(originalAppPath) || {} + const page = appNormalizedPaths.get(originalAppPath) + if (!page) throw new InvariantError('Page not found') + + const appConfig = appDefaultConfigs.get(originalAppPath) + if (!appConfig) throw new InvariantError('App config not found') let hasRevalidateZero = appConfig.revalidate === 0 || @@ -2928,8 +2940,8 @@ export default async function build( // route), any routes that were generated with unknown route params // should be collected and included in the dynamic routes part // of the manifest instead. - const routes: string[] = [] - const dynamicRoutes: string[] = [] + const routes: PrerenderedRoute[] = [] + const dynamicRoutes: PrerenderedRoute[] = [] // Sort the outputted routes to ensure consistent output. Any route // though that has unknown route params will be pulled and sorted @@ -2951,11 +2963,11 @@ export default async function build( unknownPrerenderRoutes = getSortedRouteObjects( unknownPrerenderRoutes, - (prerenderedRoute) => prerenderedRoute.path + (prerenderedRoute) => prerenderedRoute.pathname ) knownPrerenderRoutes = getSortedRouteObjects( knownPrerenderRoutes, - (prerenderedRoute) => prerenderedRoute.path + (prerenderedRoute) => prerenderedRoute.pathname ) prerenderedRoutes = [ @@ -2966,7 +2978,7 @@ export default async function build( for (const prerenderedRoute of prerenderedRoutes) { // TODO: check if still needed? // Exclude the /_not-found route. - if (prerenderedRoute.path === UNDERSCORE_NOT_FOUND_ROUTE) { + if (prerenderedRoute.pathname === UNDERSCORE_NOT_FOUND_ROUTE) { continue } @@ -2977,28 +2989,28 @@ export default async function build( ) { // If the route has unknown params, then we need to add it to // the list of dynamic routes. - dynamicRoutes.push(prerenderedRoute.path) + dynamicRoutes.push(prerenderedRoute) } else { // If the route doesn't have unknown params, then we need to // add it to the list of routes. - routes.push(prerenderedRoute.path) + routes.push(prerenderedRoute) } } // Handle all the static routes. for (const route of routes) { - if (isDynamicRoute(page) && route === page) continue - if (route === UNDERSCORE_NOT_FOUND_ROUTE) continue + if (isDynamicRoute(page) && route.pathname === page) continue + if (route.pathname === UNDERSCORE_NOT_FOUND_ROUTE) continue const { revalidate = appConfig.revalidate ?? false, metadata = {}, hasEmptyPrelude, hasPostponed, - } = exportResult.byPath.get(route) ?? {} + } = exportResult.byPath.get(route.pathname) ?? {} - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfos.set(route.pathname, { + ...(pageInfos.get(route.pathname) as PageInfo), hasPostponed, hasEmptyPrelude, }) @@ -3011,7 +3023,7 @@ export default async function build( }) if (revalidate !== 0) { - const normalizedRoute = normalizePagePath(route) + const normalizedRoute = normalizePagePath(route.pathname) let dataRoute: string | null if (isAppRouteHandler) { @@ -3033,7 +3045,7 @@ export default async function build( const meta = collectMeta(metadata) - prerenderManifest.routes[route] = { + prerenderManifest.routes[route.pathname] = { initialStatus: meta.status, initialHeaders: meta.headers, renderingMode: isAppPPREnabled @@ -3053,8 +3065,8 @@ export default async function build( hasRevalidateZero = true // we might have determined during prerendering that this page // used dynamic data - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfos.set(route.pathname, { + ...(pageInfos.get(route.pathname) as PageInfo), isSSG: false, isStatic: false, }) @@ -3066,14 +3078,22 @@ export default async function build( // they are enabled, then it'll already be included in the // prerendered routes. if (!isRoutePPREnabled) { - dynamicRoutes.push(page) + dynamicRoutes.push({ + pathname: page, + encodedPathname: page, + fallbackRouteParams: undefined, + fallbackMode: + fallbackModes.get(originalAppPath) ?? + FallbackMode.NOT_FOUND, + fallbackRootParams: undefined, + }) } for (const route of dynamicRoutes) { - const normalizedRoute = normalizePagePath(route) + const normalizedRoute = normalizePagePath(route.pathname) const { metadata, revalidate } = - exportResult.byPath.get(route) ?? {} + exportResult.byPath.get(route.pathname) ?? {} let dataRoute: string | null = null if (!isAppRouteHandler) { @@ -3087,8 +3107,8 @@ export default async function build( ) } - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfos.set(route.pathname, { + ...(pageInfos.get(route.pathname) as PageInfo), isDynamicAppRoute: true, // if PPR is turned on and the route contains a dynamic segment, // we assume it'll be partially prerendered @@ -3096,7 +3116,7 @@ export default async function build( }) const fallbackMode = - fallbackModes.get(originalAppPath) ?? FallbackMode.NOT_FOUND + route.fallbackMode ?? FallbackMode.NOT_FOUND // When we're configured to serve a prerender, we should use the // fallback revalidate from the export result. If it can't be @@ -3108,7 +3128,7 @@ export default async function build( const fallback: Fallback = fallbackModeToFallbackField( fallbackMode, - route + route.pathname ) const meta = @@ -3118,7 +3138,7 @@ export default async function build( ? collectMeta(metadata) : {} - prerenderManifest.dynamicRoutes[route] = { + prerenderManifest.dynamicRoutes[route.pathname] = { experimentalPPR: isRoutePPREnabled, renderingMode: isAppPPREnabled ? isRoutePPREnabled @@ -3127,7 +3147,7 @@ export default async function build( : undefined, experimentalBypassFor: bypassFor, routeRegex: normalizeRouteRegex( - getNamedRouteRegex(route, false).re.source + getNamedRouteRegex(route.pathname, false).re.source ), dataRoute, fallback, @@ -3417,10 +3437,10 @@ export default async function build( // We must also copy specific versions of this page as defined by // `getStaticPaths` (additionalSsgPaths). for (const route of additionalPaths.get(page) ?? []) { - const pageFile = normalizePagePath(route.path) + const pageFile = normalizePagePath(route.pathname) await moveExportedPage( page, - route.path, + route.pathname, pageFile, isSsg, 'html', @@ -3428,7 +3448,7 @@ export default async function build( ) await moveExportedPage( page, - route.path, + route.pathname, pageFile, isSsg, 'json', @@ -3456,13 +3476,13 @@ export default async function build( } const initialRevalidateSeconds = - exportResult.byPath.get(route.path)?.revalidate ?? false + exportResult.byPath.get(route.pathname)?.revalidate ?? false if (typeof initialRevalidateSeconds === 'undefined') { throw new Error("Invariant: page wasn't built") } - prerenderManifest.routes[route.path] = { + prerenderManifest.routes[route.pathname] = { initialRevalidateSeconds, experimentalPPR: undefined, renderingMode: undefined, @@ -3470,7 +3490,7 @@ export default async function build( dataRoute: path.posix.join( '/_next/data', buildId, - `${normalizePagePath(route.path)}.json` + `${normalizePagePath(route.pathname)}.json` ), // Pages does not have a prefetch data route. prefetchDataRoute: undefined, diff --git a/packages/next/src/build/manifests/formatter/format-manifest.ts b/packages/next/src/build/manifests/formatter/format-manifest.ts index 113f1a6c8ffca..03f9b15ac2fc8 100644 --- a/packages/next/src/build/manifests/formatter/format-manifest.ts +++ b/packages/next/src/build/manifests/formatter/format-manifest.ts @@ -4,9 +4,5 @@ * JSON string, otherwise it will return a minified JSON string. */ export function formatManifest(manifest: T): string { - if (process.env.NODE_ENV === 'development') { - return JSON.stringify(manifest, null, 2) - } - - return JSON.stringify(manifest) + return JSON.stringify(manifest, null, 2) } diff --git a/packages/next/src/build/segment-config/app/app-segments.ts b/packages/next/src/build/segment-config/app/app-segments.ts index 115e384bbe54e..0c90a47d491f7 100644 --- a/packages/next/src/build/segment-config/app/app-segments.ts +++ b/packages/next/src/build/segment-config/app/app-segments.ts @@ -92,15 +92,15 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) { // Process current node const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree) const isClientComponent = userland && isClientReference(userland) - const isDynamicSegment = /\[.*\]$/.test(name) - const param = isDynamicSegment ? getSegmentParam(name)?.param : undefined + + const param = getSegmentParam(name)?.param const segment: AppSegment = { name, param, filePath, config: undefined, - isDynamicSegment, + isDynamicSegment: !!param, generateStaticParams: undefined, } @@ -157,14 +157,13 @@ function collectAppRouteSegments( // Generate all the segments. const segments: AppSegment[] = parts.map((name) => { - const isDynamicSegment = /^\[.*\]$/.test(name) - const param = isDynamicSegment ? getSegmentParam(name)?.param : undefined + const param = getSegmentParam(name)?.param return { name, param, filePath: undefined, - isDynamicSegment, + isDynamicSegment: !!param, config: undefined, generateStaticParams: undefined, } diff --git a/packages/next/src/build/segment-config/app/collect-root-param-keys.ts b/packages/next/src/build/segment-config/app/collect-root-param-keys.ts new file mode 100644 index 0000000000000..829fcf32de3cb --- /dev/null +++ b/packages/next/src/build/segment-config/app/collect-root-param-keys.ts @@ -0,0 +1,65 @@ +import { getSegmentParam } from '../../../server/app-render/get-segment-param' +import type { LoadComponentsReturnType } from '../../../server/load-components' +import type { AppPageModule } from '../../../server/route-modules/app-page/module' +import type AppPageRouteModule from '../../../server/route-modules/app-page/module' +import type { AppRouteModule } from '../../../server/route-modules/app-route/module' +import { + isAppPageRouteModule, + isAppRouteRouteModule, +} from '../../../server/route-modules/checks' +import { InvariantError } from '../../../shared/lib/invariant-error' + +function collectAppPageRootParamKeys( + routeModule: AppPageRouteModule +): readonly string[] { + let rootParams: string[] = [] + + let current = routeModule.userland.loaderTree + while (current) { + const [name, parallelRoutes, modules] = current + + // If this is a dynamic segment, then we collect the param. + const param = getSegmentParam(name)?.param + if (param) { + rootParams.push(param) + } + + // If this has a layout module, then we've found the root layout because + // we return once we found the first layout. + if (typeof modules.layout !== 'undefined') { + return rootParams + } + + // This didn't include a root layout, so we need to continue. We don't need + // to collect from other parallel routes because we can't have a parallel + // route above a root layout. + current = parallelRoutes.children + } + + // If we didn't find a root layout, then we don't have any params. + return [] +} + +/** + * Collects the segments for a given route module. + * + * @param components the loaded components + * @returns the segments for the route module + */ +export function collectRootParamKeys({ + routeModule, +}: LoadComponentsReturnType< + AppPageModule | AppRouteModule +>): readonly string[] { + if (isAppRouteRouteModule(routeModule)) { + return [] + } + + if (isAppPageRouteModule(routeModule)) { + return collectAppPageRootParamKeys(routeModule) + } + + throw new InvariantError( + 'Expected a route module to be one of app route or page' + ) +} diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index a8c34019d2632..1ace85d799132 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -1,33 +1,240 @@ -import { - IncrementalCache, - type CacheHandler, -} from '../../server/lib/incremental-cache' -import type { AppPageModule } from '../../server/route-modules/app-page/module.compiled' +import type { ParamValue, Params } from '../../server/request/params' +import type { AppPageModule } from '../../server/route-modules/app-page/module' import type { AppSegment } from '../segment-config/app/app-segments' import type { StaticPathsResult } from './types' -import type { Params } from '../../server/request/params' +import type { CacheHandler } from '../../server/lib/incremental-cache' import path from 'path' -import { - FallbackMode, - fallbackModeToStaticPathsResult, -} from '../../lib/fallback' -import * as ciEnvironment from '../../server/ci-info' -import { formatDynamicImportPath } from '../../lib/format-dynamic-import-path' -import { interopDefault } from '../../lib/interop-default' import { AfterRunner } from '../../server/after/run-with-after' import { createWorkStore } from '../../server/async-storage/work-store' +import { FallbackMode } from '../../lib/fallback' +import { formatDynamicImportPath } from '../../lib/format-dynamic-import-path' +import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' +import { + getRouteRegex, + type RouteRegex, +} from '../../shared/lib/router/utils/route-regex' +import { IncrementalCache } from '../../server/lib/incremental-cache' +import { interopDefault } from '../../lib/interop-default' import { nodeFs } from '../../server/lib/node-fs-methods' -import { getParamKeys } from '../../server/request/fallback-params' -import { buildStaticPaths } from './pages' +import { normalizePathname, encodeParam } from './utils' +import * as ciEnvironment from '../../server/ci-info' +import escapePathDelimiters from '../../shared/lib/router/utils/escape-path-delimiters' + +/** + * Compares two parameters to see if they're equal. + * + * @param a - The first parameter. + * @param b - The second parameter. + * @returns Whether the parameters are equal. + */ +function areParamValuesEqual(a: ParamValue, b: ParamValue) { + // If they're equal, then we can return true. + if (a === b) { + return true + } + + // If they're both arrays, then we can return true if they have the same + // length and all the items are the same. + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false + } + + return a.every((item) => b.includes(item)) + } + + // Otherwise, they're not equal. + return false +} + +/** + * Filters out duplicate parameters from a list of parameters. + * + * @param paramKeys - The keys of the parameters. + * @param routeParams - The list of parameters to filter. + * @returns The list of unique parameters. + */ +function filterUniqueParams( + paramKeys: readonly string[], + routeParams: readonly Params[] +): Params[] { + const unique: Params[] = [] + + for (const params of routeParams) { + let i = 0 + for (; i < unique.length; i++) { + const item = unique[i] + let j = 0 + for (; j < paramKeys.length; j++) { + const key = paramKeys[j] + + // If the param is not the same, then we need to break out of the loop. + if (!areParamValuesEqual(item[key], params[key])) { + break + } + } + + // If we got to the end of the paramKeys array, then it means that we + // found a duplicate. Skip it. + if (j === paramKeys.length) { + break + } + } + + // If we didn't get to the end of the unique array, then it means that we + // found a duplicate. Skip it. + if (i < unique.length) { + continue + } + + unique.push(params) + } + + return unique +} + +/** + * Filters out all combinations of root params from a list of parameters. + * + * Given the following root param ('lang'), and the following routeParams: + * + * ``` + * [ + * { lang: 'en', region: 'US', slug: ['home'] }, + * { lang: 'en', region: 'US', slug: ['about'] }, + * { lang: 'fr', region: 'CA', slug: ['about'] }, + * ] + * ``` + * + * The result will be: + * + * ``` + * [ + * { lang: 'en', region: 'US' }, + * { lang: 'fr', region: 'CA' }, + * ] + * ``` + * + * @param rootParamKeys - The keys of the root params. + * @param routeParams - The list of parameters to filter. + * @returns The list of combinations of root params. + */ +function filterRootParamsCombinations( + rootParamKeys: readonly string[], + routeParams: readonly Params[] +): Params[] { + const combinations: Params[] = [] + + for (const params of routeParams) { + const combination: Params = {} + + // Collect all root params. As soon as we don't find a root param, break. + let i = 0 + for (; i < rootParamKeys.length; i++) { + const key = rootParamKeys[i] + if (params[key]) { + combination[key] = params[key] + } else { + break + } + } + + // If we didn't find all root params, skip this combination. We only want to + // generate combinations that have all root params. + if (i < rootParamKeys.length) { + continue + } + + combinations.push(combination) + } + + return combinations +} + +/** + * Validates the parameters to ensure they're accessible and have the correct + * types. + * + * @param page - The page to validate. + * @param regex - The route regex. + * @param isRoutePPREnabled - Whether the route has partial prerendering enabled. + * @param paramKeys - The keys of the parameters. + * @param routeParams - The list of parameters to validate. + * @returns The list of validated parameters. + */ +function validateParams( + page: string, + regex: RouteRegex, + isRoutePPREnabled: boolean, + paramKeys: readonly string[], + routeParams: readonly Params[] +): Params[] { + const valid: Params[] = [] + + for (const params of routeParams) { + const item: Params = {} + + for (const key of paramKeys) { + const { repeat, optional } = regex.groups[key] + + let paramValue = params[key] + + if ( + optional && + params.hasOwnProperty(key) && + (paramValue === null || + paramValue === undefined || + (paramValue as any) === false) + ) { + paramValue = [] + } + // A parameter is missing, so the rest of the params are not accessible. + // We only support this when the route has partial prerendering enabled. + // This will make it so that the remaining params are marked as missing so + // we can generate a fallback route for them. + if (!paramValue && isRoutePPREnabled) { + break + } + + // Perform validation for the parameter based on whether it's a repeat + // parameter or not. + if (repeat) { + if (!Array.isArray(paramValue)) { + throw new Error( + `A required parameter (${key}) was not provided as an array received ${typeof paramValue} in generateStaticParams for ${page}` + ) + } + } else { + if (typeof paramValue !== 'string') { + throw new Error( + `A required parameter (${key}) was not provided as a string received ${typeof paramValue} in generateStaticParams for ${page}` + ) + } + } + + item[key] = paramValue + } + + valid.push(item) + } + + return valid +} + +/** + * Builds the static paths for an app using `generateStaticParams`. + * + * @param params - The parameters for the build. + * @returns The static paths. + */ export async function buildAppStaticPaths({ dir, page, distDir, dynamicIO, authInterrupts, - configFileName, segments, isrFlushToDisk, cacheHandler, @@ -37,14 +244,14 @@ export async function buildAppStaticPaths({ fetchCacheKeyPrefix, nextConfigOutput, ComponentMod, - isRoutePPREnabled, + isRoutePPREnabled = false, buildId, + rootParamKeys, }: { dir: string page: string dynamicIO: boolean authInterrupts: boolean - configFileName: string segments: AppSegment[] distDir: string isrFlushToDisk?: boolean @@ -57,8 +264,9 @@ export async function buildAppStaticPaths({ requestHeaders: IncrementalCache['requestHeaders'] nextConfigOutput: 'standalone' | 'export' | undefined ComponentMod: AppPageModule - isRoutePPREnabled: boolean | undefined + isRoutePPREnabled: boolean buildId: string + rootParamKeys: readonly string[] }): Promise> { if ( segments.some((generate) => generate.config?.dynamicParams === true) && @@ -100,18 +308,8 @@ export async function buildAppStaticPaths({ minimalMode: ciEnvironment.hasNextSupport, }) - const paramKeys = new Set() - - const staticParamKeys = new Set() - for (const segment of segments) { - if (segment.param) { - paramKeys.add(segment.param) - - if (segment.config?.dynamicParams === false) { - staticParamKeys.add(segment.param) - } - } - } + const regex = getRouteRegex(page) + const paramKeys = Object.keys(getRouteMatcher(regex)(page) || {}) const afterRunner = new AfterRunner() @@ -225,10 +423,9 @@ export async function buildAppStaticPaths({ } } - // Determine if all the segments have had their parameters provided. If there - // was no dynamic parameters, then we've collected all the params. + // Determine if all the segments have had their parameters provided. const hadAllParamsGenerated = - paramKeys.size === 0 || + paramKeys.length === 0 || (routeParams.length > 0 && routeParams.every((params) => { for (const key of paramKeys) { @@ -256,33 +453,91 @@ export async function buildAppStaticPaths({ : undefined : FallbackMode.NOT_FOUND - let result: Partial = { + const result: Partial = { fallbackMode, prerenderedRoutes: lastDynamicSegmentHadGenerateStaticParams ? [] : undefined, } - if (hadAllParamsGenerated && fallbackMode) { - result = await buildStaticPaths({ - staticPathsResult: { - fallback: fallbackModeToStaticPathsResult(fallbackMode), - paths: routeParams.map((params) => ({ params })), - }, - page, - configFileName, - appDir: true, - }) - } + if (hadAllParamsGenerated || isRoutePPREnabled) { + if (isRoutePPREnabled) { + // Discover all unique combinations of the rootParams so we can generate + // shells for each of them. + routeParams.unshift( + // We're inserting an empty object at the beginning of the array so that + // we can generate a shell for when all params are unknown. + {}, + ...filterRootParamsCombinations(rootParamKeys, routeParams) + ) + } + + filterUniqueParams( + paramKeys, + validateParams(page, regex, isRoutePPREnabled, paramKeys, routeParams) + ).forEach((params) => { + let pathname: string = page + let encodedPathname: string = page + + const fallbackRouteParams: string[] = [] + + for (const key of paramKeys) { + if (fallbackRouteParams.length > 0) { + // This is a partial route, so we should add the value to the + // fallbackRouteParams. + fallbackRouteParams.push(key) + continue + } + + let paramValue = params[key] + + if (!paramValue) { + if (isRoutePPREnabled) { + // This is a partial route, so we should add the value to the + // fallbackRouteParams. + fallbackRouteParams.push(key) + continue + } else { + // This route is not complete, and we aren't performing a partial + // prerender, so we should return, skipping this route. + return + } + } + + const { repeat, optional } = regex.groups[key] + let replaced = `[${repeat ? '...' : ''}${key}]` + if (optional) { + replaced = `[${replaced}]` + } + + pathname = pathname.replace( + replaced, + encodeParam(paramValue, (value) => escapePathDelimiters(value, true)) + ) + encodedPathname = encodedPathname.replace( + replaced, + encodeParam(paramValue, encodeURIComponent) + ) + } + + const fallbackRootParams = rootParamKeys.filter((param) => + fallbackRouteParams.includes(param) + ) - // If the fallback mode is a prerender, we want to include the dynamic - // route in the prerendered routes too. - if (isRoutePPREnabled) { - result.prerenderedRoutes ??= [] - result.prerenderedRoutes.unshift({ - path: page, - encoded: page, - fallbackRouteParams: getParamKeys(page), + result.prerenderedRoutes ??= [] + result.prerenderedRoutes.push({ + pathname: normalizePathname(pathname), + encodedPathname: normalizePathname(encodedPathname), + fallbackRouteParams, + fallbackMode: dynamicParams + ? // If the fallback params includes any root params, then we need to + // perform a blocking static render. + fallbackRootParams.length > 0 + ? FallbackMode.BLOCKING_STATIC_RENDER + : fallbackMode + : FallbackMode.NOT_FOUND, + fallbackRootParams, + }) }) } diff --git a/packages/next/src/build/static-paths/pages.ts b/packages/next/src/build/static-paths/pages.ts index ba56624fec916..2d6f8b3451baa 100644 --- a/packages/next/src/build/static-paths/pages.ts +++ b/packages/next/src/build/static-paths/pages.ts @@ -1,29 +1,26 @@ -import type { GetStaticPaths, GetStaticPathsResult } from '../../types' +import type { GetStaticPaths } from '../../types' import type { PrerenderedRoute, StaticPathsResult } from './types' -import { normalizeLocalePath } from '../../client/normalize-locale-path' import { parseStaticPathsResult } from '../../lib/fallback' +import { normalizeLocalePath } from '../../shared/lib/i18n/normalize-locale-path' import escapePathDelimiters from '../../shared/lib/router/utils/escape-path-delimiters' import { removeTrailingSlash } from '../../shared/lib/router/utils/remove-trailing-slash' import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' +import { encodeParam, normalizePathname } from './utils' -export async function buildStaticPaths({ +export async function buildPagesStaticPaths({ page, getStaticPaths, - staticPathsResult, configFileName, locales, defaultLocale, - appDir, }: { page: string - getStaticPaths?: GetStaticPaths - staticPathsResult?: GetStaticPathsResult + getStaticPaths: GetStaticPaths configFileName: string locales?: string[] defaultLocale?: string - appDir?: boolean }): Promise { const prerenderedRoutes: PrerenderedRoute[] = [] const _routeRegex = getRouteRegex(page) @@ -31,16 +28,7 @@ export async function buildStaticPaths({ // Get the default list of allowed params. const routeParameterKeys = Object.keys(_routeMatcher(page)) - - if (!staticPathsResult) { - if (getStaticPaths) { - staticPathsResult = await getStaticPaths({ locales, defaultLocale }) - } else { - throw new Error( - `invariant: attempted to buildStaticPaths without "staticPathsResult" or "getStaticPaths" ${page}` - ) - } - } + const staticPathsResult = await getStaticPaths({ locales, defaultLocale }) const expectedReturnVal = `Expected: { paths: [], fallback: boolean }\n` + @@ -115,14 +103,16 @@ export async function buildStaticPaths({ // encoded so we decode the segments ensuring we only escape path // delimiters prerenderedRoutes.push({ - path: entry + pathname: entry .split('/') .map((segment) => escapePathDelimiters(decodeURIComponent(segment), true) ) .join('/'), - encoded: entry, + encodedPathname: entry, fallbackRouteParams: undefined, + fallbackMode: parseStaticPathsResult(staticPathsResult.fallback), + fallbackRootParams: undefined, }) } // For the object-provided path, we must make sure it specifies all @@ -159,51 +149,33 @@ export async function buildStaticPaths({ ) { paramValue = [] } + if ( (repeat && !Array.isArray(paramValue)) || - (!repeat && typeof paramValue !== 'string') + (!repeat && typeof paramValue !== 'string') || + typeof paramValue === 'undefined' ) { - // If this is from app directory, and not all params were provided, - // then filter this out. - if (appDir && typeof paramValue === 'undefined') { - builtPage = '' - encodedBuiltPage = '' - return - } - throw new Error( `A required parameter (${validParamKey}) was not provided as ${ repeat ? 'an array' : 'a string' - } received ${typeof paramValue} in ${ - appDir ? 'generateStaticParams' : 'getStaticPaths' - } for ${page}` + } received ${typeof paramValue} in getStaticPaths for ${page}` ) } + let replaced = `[${repeat ? '...' : ''}${validParamKey}]` if (optional) { replaced = `[${replaced}]` } - builtPage = builtPage - .replace( - replaced, - repeat - ? (paramValue as string[]) - .map((segment) => escapePathDelimiters(segment, true)) - .join('/') - : escapePathDelimiters(paramValue as string, true) - ) - .replace(/\\/g, '/') - .replace(/(?!^)\/$/, '') - - encodedBuiltPage = encodedBuiltPage - .replace( - replaced, - repeat - ? (paramValue as string[]).map(encodeURIComponent).join('/') - : encodeURIComponent(paramValue as string) - ) - .replace(/\\/g, '/') - .replace(/(?!^)\/$/, '') + + builtPage = builtPage.replace( + replaced, + encodeParam(paramValue, (value) => escapePathDelimiters(value, true)) + ) + + encodedBuiltPage = encodedBuiltPage.replace( + replaced, + encodeParam(paramValue, encodeURIComponent) + ) }) if (!builtPage && !encodedBuiltPage) { @@ -218,13 +190,19 @@ export async function buildStaticPaths({ const curLocale = entry.locale || defaultLocale || '' prerenderedRoutes.push({ - path: `${curLocale ? `/${curLocale}` : ''}${ - curLocale && builtPage === '/' ? '' : builtPage - }`, - encoded: `${curLocale ? `/${curLocale}` : ''}${ - curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage - }`, + pathname: normalizePathname( + `${curLocale ? `/${curLocale}` : ''}${ + curLocale && builtPage === '/' ? '' : builtPage + }` + ), + encodedPathname: normalizePathname( + `${curLocale ? `/${curLocale}` : ''}${ + curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage + }` + ), fallbackRouteParams: undefined, + fallbackMode: parseStaticPathsResult(staticPathsResult.fallback), + fallbackRootParams: undefined, }) } }) @@ -234,10 +212,10 @@ export async function buildStaticPaths({ return { fallbackMode: parseStaticPathsResult(staticPathsResult.fallback), prerenderedRoutes: prerenderedRoutes.filter((route) => { - if (seen.has(route.path)) return false + if (seen.has(route.pathname)) return false // Filter out duplicate paths. - seen.add(route.path) + seen.add(route.pathname) return true }), } diff --git a/packages/next/src/build/static-paths/types.ts b/packages/next/src/build/static-paths/types.ts index ac9a8fd124c28..e0ec1b8b3af77 100644 --- a/packages/next/src/build/static-paths/types.ts +++ b/packages/next/src/build/static-paths/types.ts @@ -1,15 +1,19 @@ import type { FallbackMode } from '../../lib/fallback' type StaticPrerenderedRoute = { - path: string - encoded: string + pathname: string + encodedPathname: string fallbackRouteParams: undefined + fallbackMode: FallbackMode | undefined + fallbackRootParams: undefined } type FallbackPrerenderedRoute = { - path: string - encoded: string + pathname: string + encodedPathname: string fallbackRouteParams: readonly string[] + fallbackMode: FallbackMode | undefined + fallbackRootParams: readonly string[] } export type PrerenderedRoute = StaticPrerenderedRoute | FallbackPrerenderedRoute diff --git a/packages/next/src/build/static-paths/utils.ts b/packages/next/src/build/static-paths/utils.ts new file mode 100644 index 0000000000000..0b6efc44632ed --- /dev/null +++ b/packages/next/src/build/static-paths/utils.ts @@ -0,0 +1,30 @@ +/** + * Encodes a parameter value using the provided encoder. + * + * @param value - The value to encode. + * @param encoder - The encoder to use. + * @returns The encoded value. + */ +export function encodeParam( + value: string | string[], + encoder: (value: string) => string +) { + let replaceValue: string + if (Array.isArray(value)) { + replaceValue = value.map(encoder).join('/') + } else { + replaceValue = encoder(value) + } + + return replaceValue +} + +/** + * Normalizes a pathname to a consistent format. + * + * @param pathname - The pathname to normalize. + * @returns The normalized pathname. + */ +export function normalizePathname(pathname: string) { + return pathname.replace(/\\/g, '/').replace(/(?!^)\/$/, '') +} diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 80cf8ce046e74..263d3d6dc5c93 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -2,7 +2,7 @@ import type { NextConfig, NextConfigComplete } from '../server/config-shared' import type { ExperimentalPPRConfig } from '../server/lib/experimental/ppr' import type { AppBuildManifest } from './webpack/plugins/app-build-manifest-plugin' import type { AssetBinding } from './webpack/loaders/get-module-build-info' -import type { GetStaticPathsResult, PageConfig, ServerRuntime } from '../types' +import type { PageConfig, ServerRuntime } from '../types' import type { BuildManifest } from '../server/get-page-files' import type { Redirect, @@ -77,9 +77,10 @@ import type { AppSegmentConfig } from './segment-config/app/app-segment-config' import type { AppSegment } from './segment-config/app/app-segments' import { collectSegments } from './segment-config/app/app-segments' import { createIncrementalCache } from '../export/helpers/create-incremental-cache' -import type { PrerenderedRoute } from './static-paths/types' +import { collectRootParamKeys } from './segment-config/app/collect-root-param-keys' import { buildAppStaticPaths } from './static-paths/app' -import { buildStaticPaths } from './static-paths/pages' +import { buildPagesStaticPaths } from './static-paths/pages' +import type { PrerenderedRoute } from './static-paths/types' export type ROUTER_TYPE = 'pages' | 'app' @@ -940,6 +941,7 @@ type PageIsStaticResult = { hasStaticProps?: boolean prerenderedRoutes: PrerenderedRoute[] | undefined prerenderFallbackMode: FallbackMode | undefined + rootParamKeys: readonly string[] | undefined isNextImageImported?: boolean traceIncludes?: string[] traceExcludes?: string[] @@ -1021,6 +1023,7 @@ export async function isPageStatic({ let prerenderedRoutes: PrerenderedRoute[] | undefined let prerenderFallbackMode: FallbackMode | undefined let appConfig: AppSegmentConfig = {} + let rootParamKeys: readonly string[] | undefined let isClientComponent: boolean = false const pathIsEdgeRuntime = isEdgeRuntime(pageRuntime) @@ -1069,7 +1072,6 @@ export async function isPageStatic({ }) } const Comp = componentsResult.Component as NextComponentType | undefined - let staticPathsResult: GetStaticPathsResult | undefined const routeModule: RouteModule = componentsResult.routeModule @@ -1097,6 +1099,8 @@ export async function isPageStatic({ ) } + rootParamKeys = collectRootParamKeys(componentsResult) + // A page supports partial prerendering if it is an app page and either // the whole app has PPR enabled or this page has PPR enabled when we're // in incremental mode. @@ -1113,13 +1117,12 @@ export async function isPageStatic({ } if (isDynamicRoute(page)) { - ;({ fallbackMode: prerenderFallbackMode, prerenderedRoutes } = + ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = await buildAppStaticPaths({ dir, page, dynamicIO, authInterrupts, - configFileName, segments, distDir, requestHeaders: {}, @@ -1131,6 +1134,7 @@ export async function isPageStatic({ nextConfigOutput, isRoutePPREnabled, buildId, + rootParamKeys, })) } } else { @@ -1174,14 +1178,13 @@ export async function isPageStatic({ ) } - if ((hasStaticProps && hasStaticPaths) || staticPathsResult) { - ;({ fallbackMode: prerenderFallbackMode, prerenderedRoutes } = - await buildStaticPaths({ + if (hasStaticProps && hasStaticPaths) { + ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = + await buildPagesStaticPaths({ page, locales, defaultLocale, configFileName, - staticPathsResult, getStaticPaths: componentsResult.getStaticPaths!, })) } @@ -1209,6 +1212,7 @@ export async function isPageStatic({ isAmpOnly: config.amp === true, prerenderFallbackMode, prerenderedRoutes, + rootParamKeys, hasStaticProps, hasServerProps, isNextImageImported, diff --git a/packages/next/src/lib/fallback.ts b/packages/next/src/lib/fallback.ts index e84552a3c3b07..879bf6604c30b 100644 --- a/packages/next/src/lib/fallback.ts +++ b/packages/next/src/lib/fallback.ts @@ -91,23 +91,3 @@ export function parseStaticPathsResult( return FallbackMode.NOT_FOUND } } - -/** - * Converts the fallback mode to a static paths result. - * - * @param fallback The fallback mode. - * @returns The static paths fallback result. - */ -export function fallbackModeToStaticPathsResult( - fallback: FallbackMode -): GetStaticPathsFallback { - switch (fallback) { - case FallbackMode.PRERENDER: - return true - case FallbackMode.BLOCKING_STATIC_RENDER: - return 'blocking' - case FallbackMode.NOT_FOUND: - default: - return false - } -} diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index cc85998b43d94..a1d4a38bbc457 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -808,7 +808,7 @@ export default class DevServer extends Server { staticPaths: string[] | undefined fallbackMode: FallbackMode | undefined } = { - staticPaths: staticPaths?.map((route) => route.path), + staticPaths: staticPaths?.map((route) => route.pathname), fallbackMode: fallback, } this.staticPathsCache.set(pathname, value) diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index 3d7a4cb6acd8e..17b1330aebf53 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -15,8 +15,9 @@ import { type ExperimentalPPRConfig, } from '../lib/experimental/ppr' import { InvariantError } from '../../shared/lib/invariant-error' +import { collectRootParamKeys } from '../../build/segment-config/app/collect-root-param-keys' import { buildAppStaticPaths } from '../../build/static-paths/app' -import { buildStaticPaths } from '../../build/static-paths/pages' +import { buildPagesStaticPaths } from '../../build/static-paths/pages' type RuntimeConfig = { pprConfig: ExperimentalPPRConfig | undefined @@ -91,12 +92,13 @@ export async function loadStaticPaths({ isAppPageRouteModule(components.routeModule) && checkIsRoutePPREnabled(config.pprConfig, reduceAppConfig(segments)) + const rootParamKeys = collectRootParamKeys(components) + return buildAppStaticPaths({ dir, page: pathname, dynamicIO: config.dynamicIO, segments, - configFileName: config.configFileName, distDir, requestHeaders, cacheHandler, @@ -109,6 +111,7 @@ export async function loadStaticPaths({ isRoutePPREnabled, buildId, authInterrupts, + rootParamKeys, }) } else if (!components.getStaticPaths) { // We shouldn't get to this point since the worker should only be called for @@ -118,7 +121,7 @@ export async function loadStaticPaths({ ) } - return buildStaticPaths({ + return buildPagesStaticPaths({ page: pathname, getStaticPaths: components.getStaticPaths, configFileName: config.configFileName, diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 6ff702eb24954..1e516a6202972 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -22,7 +22,8 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' -export type Params = Record | undefined> +export type ParamValue = string | Array | undefined +export type Params = Record /** * In this version of Next.js the `params` prop passed to Layouts, Pages, and other Segments is a Promise. diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts index 79a085a96322e..c9ec436f0a842 100644 --- a/packages/next/src/server/request/root-params.ts +++ b/packages/next/src/server/request/root-params.ts @@ -174,7 +174,6 @@ function makeErroringRootParams( } function makeUntrackedRootParams(underlyingParams: Params): Promise { - console.log('makeUntrackedRootParams', underlyingParams) const cachedParams = CachedParams.get(underlyingParams) if (cachedParams) { return cachedParams