From af60007cdc04787a683722f46c22e87c86d10407 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Dec 2024 13:49:57 -0700 Subject: [PATCH] feat: added partial shell generation using root params --- packages/next/src/build/index.ts | 123 +++++--- .../manifests/formatter/format-manifest.ts | 6 +- .../build/segment-config/app/app-segments.ts | 10 +- .../segment-config/app/collect-root-params.ts | 62 ++++ packages/next/src/build/utils.ts | 288 ++++++++++-------- packages/next/src/lib/fallback.ts | 20 -- .../next/src/server/dev/next-dev-server.ts | 2 +- .../src/server/dev/static-paths-worker.ts | 9 +- 8 files changed, 316 insertions(+), 204 deletions(-) create mode 100644 packages/next/src/build/segment-config/app/collect-root-params.ts diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index a2d9c4731741fd..b0b37c36ac55b7 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2130,11 +2130,27 @@ export default async function build( } else { const isDynamic = isDynamicRoute(page) + let isRootParamsDynamic = workerResult.rootParams + ? workerResult.rootParams.some((param) => + workerResult.prerenderedRoutes?.some((route) => + route.fallbackRouteParams?.includes(param) + ) + ) + : false + + 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 + if ( + workerResult.isRoutePPREnabled && + !isRootParamsDynamic + ) { isSSG = true isStatic = true @@ -2159,7 +2175,7 @@ export default async function build( workerResult.prerenderedRoutes ) ssgPageRoutes = workerResult.prerenderedRoutes.map( - (route) => route.path + (route) => route.decoded ) isSSG = true } @@ -2187,9 +2203,11 @@ export default async function build( if (!isDynamic) { staticPaths.set(originalAppPath, [ { - path: page, + decoded: page, encoded: page, fallbackRouteParams: undefined, + // TODO: fix this + fallbackMode: undefined, }, ]) isStatic = true @@ -2255,18 +2273,23 @@ export default async function build( workerResult.prerenderedRoutes ) ssgPageRoutes = workerResult.prerenderedRoutes.map( - (route) => route.path + (route) => route.decoded ) } if ( - workerResult.prerenderFallbackMode === - FallbackMode.BLOCKING_STATIC_RENDER + workerResult.prerenderedRoutes?.every( + ({ fallbackMode }) => + fallbackMode === + FallbackMode.BLOCKING_STATIC_RENDER + ) ) { ssgBlockingFallbackPages.add(page) } else if ( - workerResult.prerenderFallbackMode === - FallbackMode.PRERENDER + workerResult.prerenderedRoutes?.every( + ({ fallbackMode }) => + fallbackMode === FallbackMode.PRERENDER + ) ) { ssgStaticFallbackPages.add(page) } @@ -2691,7 +2714,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.decoded)] } ) ) @@ -2742,7 +2765,7 @@ export default async function build( // post slugs. additionalPaths.forEach((routes, page) => { routes.forEach((route) => { - defaultMap[route.path] = { + defaultMap[route.decoded] = { page, query: { __nextSsgPath: route.encoded }, } @@ -2772,7 +2795,7 @@ export default async function build( : undefined routes.forEach((route) => { - defaultMap[route.path] = { + defaultMap[route.decoded] = { page: originalAppPath, query: { __nextSsgPath: route.encoded }, _fallbackRouteParams: route.fallbackRouteParams, @@ -2884,8 +2907,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 Error('Page not found') + + const appConfig = appDefaultConfigs.get(originalAppPath) + if (!appConfig) throw new Error('App config not found') let hasRevalidateZero = appConfig.revalidate === 0 || @@ -2927,8 +2953,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 @@ -2950,11 +2976,11 @@ export default async function build( unknownPrerenderRoutes = getSortedRouteObjects( unknownPrerenderRoutes, - (prerenderedRoute) => prerenderedRoute.path + (prerenderedRoute) => prerenderedRoute.decoded ) knownPrerenderRoutes = getSortedRouteObjects( knownPrerenderRoutes, - (prerenderedRoute) => prerenderedRoute.path + (prerenderedRoute) => prerenderedRoute.decoded ) prerenderedRoutes = [ @@ -2965,7 +2991,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.decoded === UNDERSCORE_NOT_FOUND_ROUTE) { continue } @@ -2976,28 +3002,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.decoded === page) continue + if (route.decoded === UNDERSCORE_NOT_FOUND_ROUTE) continue const { revalidate = appConfig.revalidate ?? false, metadata = {}, hasEmptyPrelude, hasPostponed, - } = exportResult.byPath.get(route) ?? {} + } = exportResult.byPath.get(route.decoded) ?? {} - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfos.set(route.decoded, { + ...(pageInfos.get(route.decoded) as PageInfo), hasPostponed, hasEmptyPrelude, }) @@ -3010,7 +3036,7 @@ export default async function build( }) if (revalidate !== 0) { - const normalizedRoute = normalizePagePath(route) + const normalizedRoute = normalizePagePath(route.decoded) let dataRoute: string | null if (isAppRouteHandler) { @@ -3032,7 +3058,7 @@ export default async function build( const meta = collectMeta(metadata) - prerenderManifest.routes[route] = { + prerenderManifest.routes[route.decoded] = { initialStatus: meta.status, initialHeaders: meta.headers, renderingMode: isAppPPREnabled @@ -3052,8 +3078,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.decoded, { + ...(pageInfos.get(route.decoded) as PageInfo), isSSG: false, isStatic: false, }) @@ -3065,14 +3091,21 @@ 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({ + decoded: page, + encoded: page, + fallbackRouteParams: [], + fallbackMode: + fallbackModes.get(originalAppPath) ?? + FallbackMode.NOT_FOUND, + }) } for (const route of dynamicRoutes) { - const normalizedRoute = normalizePagePath(route) + const normalizedRoute = normalizePagePath(route.decoded) const { metadata, revalidate } = - exportResult.byPath.get(route) ?? {} + exportResult.byPath.get(route.decoded) ?? {} let dataRoute: string | null = null if (!isAppRouteHandler) { @@ -3086,8 +3119,8 @@ export default async function build( ) } - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfos.set(route.decoded, { + ...(pageInfos.get(route.decoded) as PageInfo), isDynamicAppRoute: true, // if PPR is turned on and the route contains a dynamic segment, // we assume it'll be partially prerendered @@ -3095,7 +3128,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 @@ -3107,7 +3140,7 @@ export default async function build( const fallback: Fallback = fallbackModeToFallbackField( fallbackMode, - route + route.decoded ) const meta = @@ -3117,7 +3150,7 @@ export default async function build( ? collectMeta(metadata) : {} - prerenderManifest.dynamicRoutes[route] = { + prerenderManifest.dynamicRoutes[route.decoded] = { experimentalPPR: isRoutePPREnabled, renderingMode: isAppPPREnabled ? isRoutePPREnabled @@ -3126,7 +3159,7 @@ export default async function build( : undefined, experimentalBypassFor: bypassFor, routeRegex: normalizeRouteRegex( - getNamedRouteRegex(route, false).re.source + getNamedRouteRegex(route.decoded, false).re.source ), dataRoute, fallback, @@ -3416,10 +3449,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.decoded) await moveExportedPage( page, - route.path, + route.decoded, pageFile, isSsg, 'html', @@ -3427,7 +3460,7 @@ export default async function build( ) await moveExportedPage( page, - route.path, + route.decoded, pageFile, isSsg, 'json', @@ -3455,13 +3488,13 @@ export default async function build( } const initialRevalidateSeconds = - exportResult.byPath.get(route.path)?.revalidate ?? false + exportResult.byPath.get(route.decoded)?.revalidate ?? false if (typeof initialRevalidateSeconds === 'undefined') { throw new Error("Invariant: page wasn't built") } - prerenderManifest.routes[route.path] = { + prerenderManifest.routes[route.decoded] = { initialRevalidateSeconds, experimentalPPR: undefined, renderingMode: undefined, @@ -3469,7 +3502,7 @@ export default async function build( dataRoute: path.posix.join( '/_next/data', buildId, - `${normalizePagePath(route.path)}.json` + `${normalizePagePath(route.decoded)}.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 113f1a6c8ffca6..03f9b15ac2fc85 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 84c3828717722e..8511640b345aee 100644 --- a/packages/next/src/build/segment-config/app/app-segments.ts +++ b/packages/next/src/build/segment-config/app/app-segments.ts @@ -85,15 +85,14 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) { const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree) const isClientComponent: boolean = 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, } @@ -140,14 +139,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-params.ts b/packages/next/src/build/segment-config/app/collect-root-params.ts new file mode 100644 index 00000000000000..2171516c45005d --- /dev/null +++ b/packages/next/src/build/segment-config/app/collect-root-params.ts @@ -0,0 +1,62 @@ +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 collectPageRootParams( + 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. + current = parallelRoutes.children + } + + throw new Error('No root layout found') +} + +/** + * Collects the segments for a given route module. + * + * @param components the loaded components + * @returns the segments for the route module + */ +export function collectRootParams({ + routeModule, +}: LoadComponentsReturnType< + AppPageModule | AppRouteModule +>): readonly string[] { + if (isAppRouteRouteModule(routeModule)) { + return [] + } + + if (isAppPageRouteModule(routeModule)) { + return collectPageRootParams(routeModule) + } + + throw new InvariantError( + 'Expected a route module to be one of app route or page' + ) +} diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 9780d3d3bb1fa0..694311c3534dfb 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -2,12 +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 { - GetStaticPaths, - GetStaticPathsResult, - PageConfig, - ServerRuntime, -} from '../types' +import type { GetStaticPaths, PageConfig, ServerRuntime } from '../types' import type { BuildManifest } from '../server/get-page-files' import type { Redirect, @@ -90,17 +85,14 @@ import { isInterceptionRouteAppPath } from '../server/lib/interception-routes' import { checkIsRoutePPREnabled } from '../server/lib/experimental/ppr' import type { Params } from '../server/request/params' import { FallbackMode } from '../lib/fallback' -import { - fallbackModeToStaticPathsResult, - parseStaticPathsResult, -} from '../lib/fallback' -import { getParamKeys } from '../server/request/fallback-params' +import { parseStaticPathsResult } from '../lib/fallback' import type { OutgoingHttpHeaders } from 'http' 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 { AfterRunner } from '../server/after/run-with-after' +import { collectRootParams } from './segment-config/app/collect-root-params' export type ROUTER_TYPE = 'pages' | 'app' @@ -952,16 +944,36 @@ export async function getJsPageSizeInKb( return [-1, -1] } +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 +} + +function normalizePathname(pathname: string) { + return pathname.replace(/\\/g, '/').replace(/(?!^)\/$/, '') +} + type StaticPrerenderedRoute = { - path: string + decoded: string encoded: string fallbackRouteParams: undefined + fallbackMode: FallbackMode | undefined } type FallbackPrerenderedRoute = { - path: string + decoded: string encoded: string fallbackRouteParams: readonly string[] + fallbackMode: FallbackMode | undefined } export type PrerenderedRoute = StaticPrerenderedRoute | FallbackPrerenderedRoute @@ -971,22 +983,18 @@ export type StaticPathsResult = { prerenderedRoutes: PrerenderedRoute[] } -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) @@ -994,16 +1002,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` + @@ -1078,7 +1077,7 @@ export async function buildStaticPaths({ // encoded so we decode the segments ensuring we only escape path // delimiters prerenderedRoutes.push({ - path: entry + decoded: entry .split('/') .map((segment) => escapePathDelimiters(decodeURIComponent(segment), true) @@ -1086,6 +1085,7 @@ export async function buildStaticPaths({ .join('/'), encoded: entry, fallbackRouteParams: undefined, + fallbackMode: parseStaticPathsResult(staticPathsResult.fallback), }) } // For the object-provided path, we must make sure it specifies all @@ -1122,51 +1122,33 @@ export async function buildStaticPaths({ ) { paramValue = [] } + if ( (repeat && !Array.isArray(paramValue)) || - (!repeat && typeof paramValue !== 'string') + (!repeat && typeof paramValue !== 'string') || + !paramValue ) { - // 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) { @@ -1181,13 +1163,18 @@ export async function buildStaticPaths({ const curLocale = entry.locale || defaultLocale || '' prerenderedRoutes.push({ - path: `${curLocale ? `/${curLocale}` : ''}${ - curLocale && builtPage === '/' ? '' : builtPage - }`, - encoded: `${curLocale ? `/${curLocale}` : ''}${ - curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage - }`, + decoded: normalizePathname( + `${curLocale ? `/${curLocale}` : ''}${ + curLocale && builtPage === '/' ? '' : builtPage + }` + ), + encoded: normalizePathname( + `${curLocale ? `/${curLocale}` : ''}${ + curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage + }` + ), fallbackRouteParams: undefined, + fallbackMode: parseStaticPathsResult(staticPathsResult.fallback), }) } }) @@ -1197,10 +1184,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.decoded)) return false // Filter out duplicate paths. - seen.add(route.path) + seen.add(route.decoded) return true }), } @@ -1216,7 +1203,6 @@ export async function buildAppStaticPaths({ distDir, dynamicIO, authInterrupts, - configFileName, segments, isrFlushToDisk, cacheHandler, @@ -1228,12 +1214,12 @@ export async function buildAppStaticPaths({ ComponentMod, isRoutePPREnabled, buildId, + rootParams, }: { dir: string page: string dynamicIO: boolean authInterrupts: boolean - configFileName: string segments: AppSegment[] distDir: string isrFlushToDisk?: boolean @@ -1248,6 +1234,7 @@ export async function buildAppStaticPaths({ ComponentMod: AppPageModule isRoutePPREnabled: boolean | undefined buildId: string + rootParams: readonly string[] }): Promise { if ( segments.some((generate) => generate.config?.dynamicParams === true) && @@ -1289,18 +1276,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() @@ -1414,18 +1391,10 @@ 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. - const hadAllParamsGenerated = - paramKeys.size === 0 || - (routeParams.length > 0 && - routeParams.every((params) => { - for (const key of paramKeys) { - if (key in params) continue - return false - } - return true - })) + // Determine if all the segments have had their parameters provided. + const hadAllParamsGenerated = paramKeys.every((key) => + routeParams.every((params) => key in params) + ) // TODO: dynamic params should be allowed to be granular per segment but // we need additional information stored/leveraged in the prerender @@ -1445,34 +1414,102 @@ export async function buildAppStaticPaths({ : undefined : FallbackMode.NOT_FOUND - let result: PartialStaticPathsResult = { + const result: PartialStaticPathsResult = { 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) { + const prerenderedRoutes: PrerenderedRoute[] = [] + + routeParams.forEach((params) => { + let decoded: string = page + let encoded: string = page + + const fallbackRouteParams: string[] = [] - // 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), + for (const key of paramKeys) { + let paramValue = params[key] + + const { repeat, optional } = regex.groups[key] + if ( + optional && + params.hasOwnProperty(key) && + (paramValue === null || + paramValue === undefined || + (paramValue as any) === false) + ) { + paramValue = [] + } + + if ( + (repeat && !Array.isArray(paramValue)) || + (!repeat && typeof paramValue !== 'string') || + !paramValue + ) { + if (isRoutePPREnabled) { + // This is a partial route, so we should add the value to the + // fallbackRouteParams. + fallbackRouteParams.push(key) + continue + } + + // This route is not complete, and we aren't performing a partial + // prerender, so we should return, skipping this route. + return + } + + let replaced = `[${repeat ? '...' : ''}${key}]` + if (optional) { + replaced = `[${replaced}]` + } + + decoded = decoded.replace( + replaced, + encodeParam(paramValue, (value) => escapePathDelimiters(value, true)) + ) + encoded = encoded.replace( + replaced, + encodeParam(paramValue, encodeURIComponent) + ) + } + + const fallbackParamsIncludesRootParams = fallbackRouteParams.some( + (param) => rootParams.includes(param) + ) + + prerenderedRoutes.push({ + decoded: normalizePathname(decoded), + encoded: normalizePathname(encoded), + fallbackRouteParams, + fallbackMode: + // If the fallback params includes any root params, then we need to + // perform a blocking static render. + fallbackParamsIncludesRootParams + ? FallbackMode.BLOCKING_STATIC_RENDER + : fallbackMode, + }) }) + + // If the fallback mode is a prerender, we want to include the dynamic + // route in the prerendered routes too. + if (isRoutePPREnabled) { + prerenderedRoutes.unshift({ + decoded: page, + encoded: page, + fallbackRouteParams: paramKeys, + fallbackMode: + // If this route has any rootParams, then the generic route will + // always require a blocking static render. + rootParams.length > 0 + ? FallbackMode.BLOCKING_STATIC_RENDER + : fallbackMode, + }) + } + + result.prerenderedRoutes = prerenderedRoutes } await afterRunner.executeAfter() @@ -1489,6 +1526,7 @@ type PageIsStaticResult = { hasStaticProps?: boolean prerenderedRoutes: PrerenderedRoute[] | undefined prerenderFallbackMode: FallbackMode | undefined + rootParams: readonly string[] | undefined isNextImageImported?: boolean traceIncludes?: string[] traceExcludes?: string[] @@ -1570,6 +1608,7 @@ export async function isPageStatic({ let prerenderedRoutes: PrerenderedRoute[] | undefined let prerenderFallbackMode: FallbackMode | undefined let appConfig: AppSegmentConfig = {} + let rootParams: readonly string[] | undefined let isClientComponent: boolean = false const pathIsEdgeRuntime = isEdgeRuntime(pageRuntime) @@ -1617,7 +1656,6 @@ export async function isPageStatic({ }) } const Comp = componentsResult.Component as NextComponentType | undefined - let staticPathsResult: GetStaticPathsResult | undefined const routeModule: RouteModule = componentsResult.routeModule @@ -1645,6 +1683,8 @@ export async function isPageStatic({ ) } + rootParams = collectRootParams(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. @@ -1661,13 +1701,12 @@ export async function isPageStatic({ } if (isDynamicRoute(page)) { - ;({ fallbackMode: prerenderFallbackMode, prerenderedRoutes } = + ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = await buildAppStaticPaths({ dir, page, dynamicIO, authInterrupts, - configFileName, segments, distDir, requestHeaders: {}, @@ -1679,6 +1718,7 @@ export async function isPageStatic({ nextConfigOutput, isRoutePPREnabled, buildId, + rootParams, })) } } else { @@ -1722,14 +1762,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!, })) } @@ -1757,6 +1796,7 @@ export async function isPageStatic({ isAmpOnly: config.amp === true, prerenderFallbackMode, prerenderedRoutes, + rootParams, hasStaticProps, hasServerProps, isNextImageImported, diff --git a/packages/next/src/lib/fallback.ts b/packages/next/src/lib/fallback.ts index e84552a3c3b070..879bf6604c30bf 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 cc85998b43d947..76aa95248738be 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.decoded), 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 28ac0fa167ad71..1c89f9614ddfa9 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -5,7 +5,7 @@ import '../node-environment' import { buildAppStaticPaths, - buildStaticPaths, + buildPagesStaticPaths, reduceAppConfig, } from '../../build/utils' import { collectSegments } from '../../build/segment-config/app/app-segments' @@ -19,6 +19,7 @@ import { type ExperimentalPPRConfig, } from '../lib/experimental/ppr' import { InvariantError } from '../../shared/lib/invariant-error' +import { collectRootParams } from '../../build/segment-config/app/collect-root-params' type RuntimeConfig = { pprConfig: ExperimentalPPRConfig | undefined @@ -92,12 +93,13 @@ export async function loadStaticPaths({ isAppPageRouteModule(components.routeModule) && checkIsRoutePPREnabled(config.pprConfig, reduceAppConfig(segments)) + const rootParams = collectRootParams(components) + return buildAppStaticPaths({ dir, page: pathname, dynamicIO: config.dynamicIO, segments, - configFileName: config.configFileName, distDir, requestHeaders, cacheHandler, @@ -110,6 +112,7 @@ export async function loadStaticPaths({ isRoutePPREnabled, buildId, authInterrupts, + rootParams, }) } else if (!components.getStaticPaths) { // We shouldn't get to this point since the worker should only be called for @@ -119,7 +122,7 @@ export async function loadStaticPaths({ ) } - return buildStaticPaths({ + return buildPagesStaticPaths({ page: pathname, getStaticPaths: components.getStaticPaths, configFileName: config.configFileName,