diff --git a/packages/next/src/build/collect-build-traces.ts b/packages/next/src/build/collect-build-traces.ts index 1bb6a742db4a60..74d3d7f7a6fa32 100644 --- a/packages/next/src/build/collect-build-traces.ts +++ b/packages/next/src/build/collect-build-traces.ts @@ -1,5 +1,6 @@ import { Span } from '../trace' import type { NextConfigComplete } from '../server/config-shared' +import type { PageInfoRegistry } from './page-info' import { TRACE_IGNORES, @@ -14,7 +15,6 @@ import { import path from 'path' import fs from 'fs/promises' -import type { PageInfo } from './utils' import { loadBindings } from './swc' import { nonNullable } from '../lib/non-nullable' import * as ciEnvironment from '../telemetry/ci-info' @@ -67,7 +67,7 @@ export async function collectBuildTraces({ dir, config, distDir, - pageInfos, + pageInfoRegistry, staticPages, nextBuildSpan = new Span({ name: 'build' }), hasSsrAmpPages, @@ -79,7 +79,7 @@ export async function collectBuildTraces({ staticPages: string[] hasSsrAmpPages: boolean outputFileTracingRoot: string - pageInfos: [string, PageInfo][] + pageInfoRegistry: PageInfoRegistry | undefined nextBuildSpan?: Span config: NextConfigComplete buildTraceContext?: BuildTraceContext @@ -638,10 +638,8 @@ export async function collectBuildTraces({ } // edge routes have no trace files - const [, pageInfo] = pageInfos.find((item) => item[0] === route) || [] - if (pageInfo?.runtime === 'edge') { - return - } + const pageInfo = pageInfoRegistry?.get(route) + if (pageInfo?.runtime === 'edge') return const combinedIncludes = new Set() const combinedExcludes = new Set() diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index a8e3a0b821dfe8..844a70164731d0 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -115,7 +115,8 @@ import { isReservedPage, isAppBuiltinNotFoundPage, } from './utils' -import type { PageInfo, AppConfig } from './utils' +import { PageInfoRegistry } from './page-info' +import type { AppConfig } from './utils' import { writeBuildId } from './write-build-id' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import isError from '../lib/is-error' @@ -349,6 +350,35 @@ function pageToRoute(page: string) { } } +function createAppDataRouteInfo( + page: string, + { + supportsPPR, + isRouteHandler, + }: { + supportsPPR: boolean + isRouteHandler: boolean + } +): DataRouteRouteInfo { + const normalizedRoute = normalizePagePath(page) + + // If the page is not a route handler and the page is either doesn't support + // PPR or is not PPR, we need to generate a data route. + let dataRoute: string | null = null + if (!isRouteHandler) { + dataRoute = path.posix.join(`${normalizedRoute}${RSC_SUFFIX}`) + } + + let prefetchDataRoute: string | undefined + if (supportsPPR) { + prefetchDataRoute = path.posix.join( + `${normalizedRoute}${RSC_PREFETCH_SUFFIX}` + ) + } + + return { dataRoute, prefetchDataRoute } +} + export default async function build( dir: string, reactProductionProfiling = false, @@ -395,6 +425,8 @@ export default async function build( process.env.NEXT_DEPLOYMENT_ID = config.experimental.deploymentId || '' NextBuildContext.config = config + const nextConfigPPREnabled = config.experimental.ppr === true + let configOutDir = 'out' if (config.output === 'export' && config.distDir !== '.next') { // In the past, a user had to run "next build" to generate @@ -1109,7 +1141,7 @@ export default async function build( dir, config, distDir, - pageInfos: [], + pageInfoRegistry: undefined, staticPages: [], hasSsrAmpPages: false, buildTraceContext, @@ -1183,7 +1215,7 @@ export default async function build( const appNormalizedPaths = new Map() const appDynamicParamPaths = new Set() const appDefaultConfigs = new Map() - const pageInfos = new Map() + const pageInfoRegistry = new PageInfoRegistry() const pagesManifest = JSON.parse( await fs.readFile(manifestPath, 'utf8') ) as PagesManifest @@ -1337,7 +1369,7 @@ export default async function build( minimalMode: ciEnvironment.hasNextSupport, allowedRevalidateHeaderKeys: config.experimental.allowedRevalidateHeaderKeys, - experimental: { ppr: config.experimental.ppr === true }, + experimental: { ppr: nextConfigPPREnabled }, }) incrementalCacheIpcPort = cacheInitialization.ipcPort @@ -1408,7 +1440,7 @@ export default async function build( locales: config.i18n?.locales, defaultLocale: config.i18n?.defaultLocale, nextConfigOutput: config.output, - ppr: config.experimental.ppr === true, + nextConfigPPREnabled, }) ) @@ -1620,7 +1652,7 @@ export default async function build( maxMemoryCacheSize: config.experimental.isrMemoryCacheSize, nextConfigOutput: config.output, - ppr: config.experimental.ppr === true, + nextConfigPPREnabled, }) } ) @@ -1636,11 +1668,8 @@ export default async function build( `Using edge runtime on a page currently disables static generation for that page` ) } else { - // If this route can be partially pre-rendered, then - // mark it as such and mark it that it can be - // generated server-side. if (workerResult.isPPR) { - isPPR = workerResult.isPPR + isPPR = true isSSG = true isStatic = true @@ -1838,7 +1867,7 @@ export default async function build( } } - pageInfos.set(page, { + pageInfoRegistry.set(page, { size, totalSize, isStatic, @@ -1923,7 +1952,7 @@ export default async function build( dir, config, distDir, - pageInfos: Object.entries(pageInfos), + pageInfoRegistry, staticPages: [...staticPages], nextBuildSpan, hasSsrAmpPages, @@ -2164,7 +2193,7 @@ export default async function build( }) // Ensure we don't generate explicit app prefetches while in PPR. - if (config.experimental.ppr && appPrefetchPaths.size > 0) { + if (nextConfigPPREnabled && appPrefetchPaths.size > 0) { throw new Error( "Invariant: explicit app prefetches shouldn't generated with PPR" ) @@ -2258,11 +2287,11 @@ export default async function build( appConfig.revalidate === 0 || exportResult.byPath.get(page)?.revalidate === 0 - if (hasDynamicData && pageInfos.get(page)?.isStatic) { + const { isStatic, isPPR } = pageInfoRegistry.get(page, true) + if (hasDynamicData && isStatic) { // if the page was marked as being static, but it contains dynamic data // (ie, in the case of a static generation bailout), then it should be marked dynamic - pageInfos.set(page, { - ...(pageInfos.get(page) as PageInfo), + pageInfoRegistry.patch(page, { isStatic: false, isSSG: false, }) @@ -2270,12 +2299,13 @@ export default async function build( const isRouteHandler = isAppRouteRoute(originalAppPath) - // When this is an app page and PPR is enabled, the route supports - // partial pre-rendering. - const experimentalPPR = - !isRouteHandler && config.experimental.ppr === true - ? true - : undefined + /** + * If this page is not an app route and PPR is enabled, then it + * could support PPR. + */ + const supportsPPR = !isRouteHandler && nextConfigPPREnabled + + const experimentalPPR = isPPR ? true : undefined // this flag is used to selectively bypass the static cache and invoke the lambda directly // to enable server actions on static routes @@ -2299,35 +2329,22 @@ export default async function build( hasPostponed, } = exportResult.byPath.get(route) ?? {} - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), - hasPostponed, - hasEmptyPrelude, - }) - // update the page (eg /blog/[slug]) to also have the postpone metadata - pageInfos.set(page, { - ...(pageInfos.get(page) as PageInfo), + const pageInfo = pageInfoRegistry.patch(page, { hasPostponed, hasEmptyPrelude, }) - if (revalidate !== 0) { - const normalizedRoute = normalizePagePath(route) + pageInfoRegistry.set(route, pageInfo) - let dataRoute: string | null - if (isRouteHandler) { - dataRoute = null - } else { - dataRoute = path.posix.join(`${normalizedRoute}${RSC_SUFFIX}`) - } - - let prefetchDataRoute: string | null | undefined - if (experimentalPPR) { - prefetchDataRoute = path.posix.join( - `${normalizedRoute}${RSC_PREFETCH_SUFFIX}` - ) - } + if (revalidate !== 0) { + const { dataRoute, prefetchDataRoute } = createAppDataRouteInfo( + route, + { + supportsPPR, + isRouteHandler, + } + ) const routeMeta: Partial = {} @@ -2373,8 +2390,7 @@ export default async function build( hasDynamicData = true // we might have determined during prerendering that this page // used dynamic data - pageInfos.set(route, { - ...(pageInfos.get(route) as PageInfo), + pageInfoRegistry.patch(route, { isSSG: false, isStatic: false, }) @@ -2382,24 +2398,19 @@ export default async function build( }) if (!hasDynamicData && isDynamicRoute(originalAppPath)) { - const normalizedRoute = normalizePagePath(page) - const dataRoute = path.posix.join( - `${normalizedRoute}${RSC_SUFFIX}` + const { dataRoute, prefetchDataRoute } = createAppDataRouteInfo( + page, + { + supportsPPR, + isRouteHandler, + } ) - let prefetchDataRoute: string | null | undefined - if (experimentalPPR) { - prefetchDataRoute = path.posix.join( - `${normalizedRoute}${RSC_PREFETCH_SUFFIX}` - ) - } - - pageInfos.set(page, { - ...(pageInfos.get(page) as PageInfo), + pageInfoRegistry.patch(page, { isDynamicAppRoute: true, // if PPR is turned on and the route contains a dynamic segment, // we assume it'll be partially prerendered - hasPostponed: experimentalPPR, + hasPostponed: supportsPPR, }) // TODO: create a separate manifest to allow enforcing @@ -2410,33 +2421,32 @@ export default async function build( routeRegex: normalizeRouteRegex( getNamedRouteRegex(page, false).re.source ), - dataRoute, // if dynamicParams are enabled treat as fallback: // 'blocking' if not it's fallback: false fallback: appDynamicParamPaths.has(originalAppPath) ? null : false, - dataRouteRegex: isRouteHandler - ? null - : normalizeRouteRegex( + dataRoute, + dataRouteRegex: dataRoute + ? normalizeRouteRegex( getNamedRouteRegex( dataRoute.replace(/\.rsc$/, ''), false ).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.rsc$') - ), + ) + : null, prefetchDataRoute, - prefetchDataRouteRegex: - isRouteHandler || !prefetchDataRoute - ? undefined - : normalizeRouteRegex( - getNamedRouteRegex( - prefetchDataRoute.replace(/\.prefetch\.rsc$/, ''), - false - ).re.source.replace( - /\(\?:\\\/\)\?\$$/, - '\\.prefetch\\.rsc$' - ) - ), + prefetchDataRouteRegex: prefetchDataRoute + ? normalizeRouteRegex( + getNamedRouteRegex( + prefetchDataRoute.replace(/\.prefetch\.rsc$/, ''), + false + ).re.source.replace( + /\(\?:\\\/\)\?\$$/, + '\\.prefetch\\.rsc$' + ) + ) + : undefined, } } } @@ -2601,7 +2611,7 @@ export default async function build( const hasAmp = hybridAmpPages.has(page) const file = normalizePagePath(page) - const pageInfo = pageInfos.get(page) + const pageInfo = pageInfoRegistry.get(page) const durationInfo = exportResult.byPage.get(page) if (pageInfo && durationInfo) { // Set Build Duration @@ -3083,7 +3093,7 @@ export default async function build( console.log() await nextBuildSpan.traceChild('print-tree-view').traceAsyncFn(() => - printTreeView(pageKeys, pageInfos, { + printTreeView(pageKeys, pageInfoRegistry, { distPath: distDir, buildId: buildId, pagesDir, diff --git a/packages/next/src/build/page-info.ts b/packages/next/src/build/page-info.ts new file mode 100644 index 00000000000000..3d2b487da15db8 --- /dev/null +++ b/packages/next/src/build/page-info.ts @@ -0,0 +1,57 @@ +import type { ServerRuntime } from '../../types' + +export interface PageInfo { + isHybridAmp?: boolean + size: number + totalSize: number + isStatic: boolean + isSSG: boolean + isPPR: boolean + ssgPageRoutes: string[] | null + initialRevalidateSeconds: number | false + pageDuration: number | undefined + ssgPageDurations: number[] | undefined + runtime: ServerRuntime + hasEmptyPrelude?: boolean + hasPostponed?: boolean + isDynamicAppRoute?: boolean +} + +/** + * This class is used to store information about pages that is used for the + * build output. + */ + +export class PageInfoRegistry extends Map { + /** + * Updates the page info for a given page. If the page info doesn't exist, + * then it will throw an error. + * + * @param page the page to update + * @param update the update to apply + * @returns the updated page info + */ + public patch(page: string, update: Partial): PageInfo { + const pageInfo = this.get(page, true) + this.set(page, { ...pageInfo, ...update }) + return pageInfo + } + + /** + * Gets the page info for a given page. If the page info doesn't exist, then + * it will throw an error if `errorIfMissing` is true. + * + * @param page the page to get + * @param throwIfUndefined whether to throw an error if the page info doesn't exist + */ + public get(page: string, throwIfUndefined: true): PageInfo + public get(page: string, throwIfUndefined?: boolean): PageInfo | undefined + public get(page: string, throwIfUndefined?: boolean): PageInfo | undefined { + const pageInfo = super.get(page) + if (!pageInfo && throwIfUndefined) { + throw new Error(`Invariant: Expected page "${page}" to exist`) + } + + return pageInfo + } +} diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 9accdee7157f14..c19a8f4644ce0c 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -21,6 +21,8 @@ import type { import type { WebpackLayerName } from '../lib/constants' import type { AppPageModule } from '../server/future/route-modules/app-page/module' import type { RouteModule } from '../server/future/route-modules/route-module' +import type { PageInfoRegistry } from './page-info' +import type { LoaderTree } from '../server/lib/app-dir-module' import '../server/require-hook' import '../server/node-polyfill-crypto' @@ -152,11 +154,11 @@ export async function computeFromManifest( }, distPath: string, gzipSize: boolean = true, - pageInfos?: Map + pageInfoRegistry?: PageInfoRegistry ): Promise { if ( Object.is(cachedBuildManifest, manifests.build) && - lastComputePageInfo === !!pageInfos && + lastComputePageInfo === !!pageInfoRegistry && Object.is(cachedAppBuildManifest, manifests.app) ) { return lastCompute! @@ -195,8 +197,8 @@ export async function computeFromManifest( } for (const key in manifests.build.pages) { - if (pageInfos) { - const pageInfo = pageInfos.get(key) + if (pageInfoRegistry) { + const pageInfo = pageInfoRegistry.get(key) // don't include AMP pages since they don't rely on shared bundles // AMP First pages are not under the pageInfos key if (pageInfo?.isHybridAmp) { @@ -284,7 +286,7 @@ export async function computeFromManifest( cachedBuildManifest = manifests.build cachedAppBuildManifest = manifests.app - lastComputePageInfo = !!pageInfos + lastComputePageInfo = !!pageInfoRegistry return lastCompute! } @@ -324,29 +326,12 @@ const filterAndSortList = ( return pages.sort((a, b) => a.localeCompare(b)) } -export interface PageInfo { - isHybridAmp?: boolean - size: number - totalSize: number - isStatic: boolean - isSSG: boolean - isPPR: boolean - ssgPageRoutes: string[] | null - initialRevalidateSeconds: number | false - pageDuration: number | undefined - ssgPageDurations: number[] | undefined - runtime: ServerRuntime - hasEmptyPrelude?: boolean - hasPostponed?: boolean - isDynamicAppRoute?: boolean -} - export async function printTreeView( lists: { pages: ReadonlyArray app: ReadonlyArray | undefined }, - pageInfos: Map, + pageInfoRegistry: PageInfoRegistry, { distPath, buildId, @@ -413,7 +398,7 @@ export async function printTreeView( { build: buildManifest, app: appBuildManifest }, distPath, gzipSize, - pageInfos + pageInfoRegistry ) const printFileTree = async ({ @@ -446,7 +431,7 @@ export async function printTreeView( ? '└' : '├' - const pageInfo = pageInfos.get(item) + const pageInfo = pageInfoRegistry.get(item) const ampFirst = buildManifest.ampFirstPages.includes(item) const totalDuration = (pageInfo?.pageDuration || 0) + @@ -645,10 +630,16 @@ export async function printTreeView( messages.push(['', '', '']) } - pageInfos.set('/404', { - ...(pageInfos.get('/404') || pageInfos.get('/_error'))!, - isStatic: useStaticPages404, - }) + // We should update the `isStatic` part of the pageInfo for the /404 page. + // When we're using experimental compile, this won't be available. + const pageInfo = + pageInfoRegistry.get('/404') || pageInfoRegistry.get('/_error') + if (pageInfo) { + pageInfoRegistry.set('/404', { + ...pageInfo, + isStatic: useStaticPages404, + }) + } // If there's no app /_notFound page present, then the 404 is still using the pages/404 if (!lists.pages.includes('/404') && !lists.app?.includes('/_not-found')) { @@ -1133,43 +1124,64 @@ export type AppConfigDynamic = | 'force-static' | 'force-dynamic' +type AppConfigFetchCache = 'force-cache' | 'only-cache' + export type AppConfig = { revalidate?: number | false dynamicParams?: true | false dynamic?: AppConfigDynamic - fetchCache?: 'force-cache' | 'only-cache' + fetchCache?: AppConfigFetchCache preferredRegion?: string } -export type GenerateParams = Array<{ + +type Params = Record +type GenerateStaticParams = ({ + params, +}: { + params?: Params +}) => Promise + +type SegmentGenerateParams = { config?: AppConfig isDynamicSegment?: boolean segmentPath: string getStaticPaths?: GetStaticPaths - generateStaticParams?: any + generateStaticParams?: GenerateStaticParams isLayout?: boolean -}> +} + +export type GenerateParams = Array -export const collectAppConfig = (mod: any): AppConfig | undefined => { +function collectAppConfig(mod: object): AppConfig | undefined { let hasConfig = false const config: AppConfig = {} - if (typeof mod?.revalidate !== 'undefined') { + if ( + 'revalidate' in mod && + (typeof mod.revalidate === 'number' || mod.revalidate === false) + ) { config.revalidate = mod.revalidate hasConfig = true } - if (typeof mod?.dynamicParams !== 'undefined') { + + if ('dynamicParams' in mod && typeof mod.dynamicParams === 'boolean') { config.dynamicParams = mod.dynamicParams hasConfig = true } - if (typeof mod?.dynamic !== 'undefined') { - config.dynamic = mod.dynamic + + // TODO: add config validation + if ('dynamic' in mod && typeof mod.dynamic === 'string') { + config.dynamic = mod.dynamic as AppConfigDynamic hasConfig = true } - if (typeof mod?.fetchCache !== 'undefined') { - config.fetchCache = mod.fetchCache + + // TODO: add config validation + if ('fetchCache' in mod && typeof mod.fetchCache === 'string') { + config.fetchCache = mod.fetchCache as AppConfigFetchCache hasConfig = true } - if (typeof mod?.preferredRegion !== 'undefined') { + + if ('preferredRegion' in mod && typeof mod.preferredRegion === 'string') { config.preferredRegion = mod.preferredRegion hasConfig = true } @@ -1177,56 +1189,87 @@ export const collectAppConfig = (mod: any): AppConfig | undefined => { return hasConfig ? config : undefined } -export const collectGenerateParams = async ( - segment: any, - parentSegments: string[] = [], - generateParams: GenerateParams = [] -): Promise => { - if (!Array.isArray(segment)) return generateParams - const isLayout = !!segment[2]?.layout - const mod = await (isLayout - ? segment[2]?.layout?.[0]?.() - : segment[2]?.page?.[0]?.()) - const config = collectAppConfig(mod) - const page: string | undefined = segment[0] - const isClientComponent = isClientReference(mod) - const isDynamicSegment = /^\[.+\]$/.test(page || '') - const { generateStaticParams, getStaticPaths } = mod || {} - - //console.log({parentSegments, page, isDynamicSegment, isClientComponent, generateStaticParams}) - if (isDynamicSegment && isClientComponent && generateStaticParams) { - throw new Error( - `Page "${page}" cannot export "generateStaticParams()" because it is a client component` - ) - } +/** + * Walks the loader tree and collects the generate parameters for each segment. + * + * @param tree the loader tree + * @returns the generate parameters for each segment + */ +export async function collectGenerateParams(tree: LoaderTree) { + const generateParams: GenerateParams = [] + const parentSegments: string[] = [] + + const stack: LoaderTree[] = [tree] + while (stack.length > 0) { + const segment = stack.pop() + if (!Array.isArray(segment)) continue + + const [ + // TODO: check if this is ever undefined + page = '', + parallelRoutes, + components, + ] = segment + + // If the segment doesn't have any components, then skip it. + if (!components) continue + + const isLayout = !!components.layout + const mod = await (isLayout + ? components.layout?.[0]?.() + : components.page?.[0]?.()) + + if (page) { + parentSegments.push(page) + } + + const config = mod ? collectAppConfig(mod) : undefined + const isClientComponent = isClientReference(mod) + + const isDynamicSegment = /^\[.+\]$/.test(page) + + const { generateStaticParams, getStaticPaths } = mod || {} + + if (isDynamicSegment && isClientComponent && generateStaticParams) { + throw new Error( + `Page "${page}" cannot export "generateStaticParams()" because it is a client component` + ) + } - const result = { - isLayout, - isDynamicSegment, - segmentPath: `/${parentSegments.join('/')}${ + const segmentPath = `/${parentSegments.join('/')}${ page && parentSegments.length > 0 ? '/' : '' - }${page}`, - config, - getStaticPaths: isClientComponent ? undefined : getStaticPaths, - generateStaticParams: isClientComponent ? undefined : generateStaticParams, - } + }${page}` + + const result: SegmentGenerateParams = { + isLayout, + isDynamicSegment, + segmentPath, + config, + getStaticPaths: !isClientComponent ? getStaticPaths : undefined, + generateStaticParams: !isClientComponent + ? generateStaticParams + : undefined, + } - if (page) { - parentSegments.push(page) - } + // If the configuration contributes to the static generation, then add it + // to the list. + if ( + result.config || + result.generateStaticParams || + result.getStaticPaths || + isDynamicSegment + ) { + generateParams.push(result) + } - if (result.config || result.generateStaticParams || result.getStaticPaths) { - generateParams.push(result) - } else if (isDynamicSegment) { - // It is a dynamic route, but no config was provided - generateParams.push(result) + // If there are children, then add them to the stack. + const children = parallelRoutes.children as LoaderTree | undefined + if (Array.isArray(children)) { + stack.push(children) + } } - return collectGenerateParams( - segment[1]?.children, - parentSegments, - generateParams - ) + return generateParams } export async function buildAppStaticPaths({ @@ -1240,7 +1283,7 @@ export async function buildAppStaticPaths({ requestHeaders, maxMemoryCacheSize, fetchCacheKeyPrefix, - ppr, + nextConfigPPREnabled, ComponentMod, }: { dir: string @@ -1253,7 +1296,7 @@ export async function buildAppStaticPaths({ fetchCacheKeyPrefix?: string maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] - ppr: boolean + nextConfigPPREnabled: boolean ComponentMod: AppPageModule }) { ComponentMod.patchFetch() @@ -1287,7 +1330,7 @@ export async function buildAppStaticPaths({ CurCacheHandler: CacheHandler, requestHeaders, minimalMode: ciEnvironment.hasNextSupport, - experimental: { ppr }, + experimental: { ppr: nextConfigPPREnabled }, }) return StaticGenerationAsyncStorageWrapper.wrap( @@ -1300,8 +1343,7 @@ export async function buildAppStaticPaths({ supportsDynamicHTML: true, isRevalidate: false, isBot: false, - // building static paths should never postpone - experimental: { ppr: false }, + experimental: { ppr: nextConfigPPREnabled }, }, }, async () => { @@ -1317,18 +1359,18 @@ export async function buildAppStaticPaths({ } else { // if generateStaticParams is being used we iterate over them // collecting them from each level - type Params = Array> let hadAllParamsGenerated = false const buildParams = async ( - paramsItems: Params = [{}], + paramsItems: Params[] = [{}], idx = 0 - ): Promise => { - const curGenerate = generateParams[idx] - + ): Promise => { if (idx === generateParams.length) { return paramsItems } + + const curGenerate = generateParams[idx] + if ( typeof curGenerate.generateStaticParams !== 'function' && idx < generateParams.length @@ -1343,22 +1385,27 @@ export async function buildAppStaticPaths({ } hadAllParamsGenerated = true - const newParams = [] + const newParams: Params[] = [] - for (const params of paramsItems) { - const result = await curGenerate.generateStaticParams({ params }) - // TODO: validate the result is valid here or wait for - // buildStaticPaths to validate? - for (const item of result) { - newParams.push({ ...params, ...item }) + if (curGenerate.generateStaticParams) { + for (const params of paramsItems) { + const result = await curGenerate.generateStaticParams({ + params, + }) + // TODO: validate the result is valid here or wait for buildStaticPaths to validate? + for (const item of result) { + newParams.push({ ...params, ...item }) + } } } if (idx < generateParams.length) { return buildParams(newParams, idx + 1) } + return newParams } + const builtParams = await buildParams() const fallback = !generateParams.some( // TODO: dynamic params should be allowed @@ -1393,6 +1440,44 @@ export async function buildAppStaticPaths({ ) } +function resolveAppConfig(generateParams: GenerateParams): AppConfig { + return generateParams.reduce((config: AppConfig, params): AppConfig => { + // If the segment doesn't have a config, then return the current one. + if (!params.config) return config + + const { dynamic, fetchCache, preferredRegion, revalidate } = params.config + + // TODO: should conflicting configs here throw an error + // e.g. if layout defines one region but page defines another + if (typeof config.preferredRegion === 'undefined') { + config.preferredRegion = preferredRegion + } + + if (typeof config.dynamic === 'undefined') { + config.dynamic = dynamic + } + + if (typeof config.fetchCache === 'undefined') { + config.fetchCache = fetchCache + } + + // Any revalidate number overrides `undefined`. + if (typeof config.revalidate === 'undefined') { + config.revalidate = revalidate + } + + // If the revalidate is lower than the current one, then use it. + if ( + typeof revalidate === 'number' && + (typeof config.revalidate !== 'number' || revalidate < config.revalidate) + ) { + config.revalidate = revalidate + } + + return config + }, {}) +} + export async function isPageStatic({ dir, page, @@ -1410,7 +1495,7 @@ export async function isPageStatic({ isrFlushToDisk, maxMemoryCacheSize, incrementalCacheHandlerPath, - ppr, + nextConfigPPREnabled, }: { dir: string page: string @@ -1429,7 +1514,7 @@ export async function isPageStatic({ maxMemoryCacheSize?: number incrementalCacheHandlerPath?: string nextConfigOutput: 'standalone' | 'export' - ppr: boolean + nextConfigPPREnabled: boolean }): Promise<{ isPPR?: boolean isStatic?: boolean @@ -1499,9 +1584,13 @@ export async function isPageStatic({ isAppPath: pageType === 'app', }) } - const Comp = componentsResult.Component || {} + const { Component } = componentsResult + let staticPathsResult: GetStaticPathsResult | undefined + // Keep track if the page supports partial pre-rendering. + let isPPR = false + const routeModule: RouteModule = componentsResult.ComponentMod?.routeModule @@ -1528,43 +1617,7 @@ export async function isPageStatic({ ] : await collectGenerateParams(tree) - appConfig = generateParams.reduce( - (builtConfig: AppConfig, curGenParams): AppConfig => { - const { - dynamic, - fetchCache, - preferredRegion, - revalidate: curRevalidate, - } = curGenParams?.config || {} - - // TODO: should conflicting configs here throw an error - // e.g. if layout defines one region but page defines another - if (typeof builtConfig.preferredRegion === 'undefined') { - builtConfig.preferredRegion = preferredRegion - } - if (typeof builtConfig.dynamic === 'undefined') { - builtConfig.dynamic = dynamic - } - if (typeof builtConfig.fetchCache === 'undefined') { - builtConfig.fetchCache = fetchCache - } - - // any revalidate number overrides false - // shorter revalidate overrides longer (initially) - if (typeof builtConfig.revalidate === 'undefined') { - builtConfig.revalidate = curRevalidate - } - if ( - typeof curRevalidate === 'number' && - (typeof builtConfig.revalidate !== 'number' || - curRevalidate < builtConfig.revalidate) - ) { - builtConfig.revalidate = curRevalidate - } - return builtConfig - }, - {} - ) + appConfig = resolveAppConfig(generateParams) if (appConfig.dynamic === 'force-static' && pathIsEdgeRuntime) { Log.warn( @@ -1572,11 +1625,20 @@ export async function isPageStatic({ ) } - // If force dynamic was set and we don't have PPR enabled, then set the - // revalidate to 0. - // TODO: (PPR) remove this once PPR is enabled by default - if (appConfig.dynamic === 'force-dynamic' && !ppr) { - appConfig.revalidate = 0 + // When PPR is enabled and this page is an App Page, then it should be + // partially pre-rendered. + if ( + nextConfigPPREnabled && + routeModule.definition.kind === RouteKind.APP_PAGE + ) { + isPPR = true + } + + // When `dynamic = "force-dynamic"`, then we should never prerender + // this page statically unless PPR is enabled, in which case the static + // shell can always be prerendered. + if (appConfig.dynamic === 'force-dynamic') { + appConfig.revalidate = isPPR ? false : 0 } if (isDynamicRoute(page)) { @@ -1594,17 +1656,21 @@ export async function isPageStatic({ isrFlushToDisk, maxMemoryCacheSize, incrementalCacheHandlerPath, - ppr, + nextConfigPPREnabled, ComponentMod, })) } } else { - if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { + if ( + !Component || + !isValidElementType(Component) || + typeof Component === 'string' + ) { throw new Error('INVALID_DEFAULT_EXPORT') } } - const hasGetInitialProps = !!(Comp as any).getInitialProps + const hasGetInitialProps = !!Component?.getInitialProps const hasStaticProps = !!componentsResult.getStaticProps const hasStaticPaths = !!componentsResult.getStaticPaths const hasServerProps = !!componentsResult.getServerSideProps @@ -1665,16 +1731,13 @@ export async function isPageStatic({ ) } + // If the page supports PPR or it has no data requirements, then it's + // static. let isStatic = false - if (!hasStaticProps && !hasGetInitialProps && !hasServerProps) { - isStatic = true - } - - // When PPR is enabled, any route may contain or be completely static, so - // mark this route as static. - let isPPR = false - if (ppr && routeModule.definition.kind === RouteKind.APP_PAGE) { - isPPR = true + if ( + isPPR || + (!hasStaticProps && !hasGetInitialProps && !hasServerProps) + ) { isStatic = true } @@ -2070,7 +2133,7 @@ export function getPossibleInstrumentationHookFilenames( folder: string, extensions: string[] ) { - const files = [] + const files: string[] = [] for (const extension of extensions) { files.push( path.join(folder, `${INSTRUMENTATION_HOOK_FILENAME}.${extension}`), diff --git a/packages/next/src/lib/interop-default.ts b/packages/next/src/lib/interop-default.ts index 870489613d8ce3..e48e7e9da46117 100644 --- a/packages/next/src/lib/interop-default.ts +++ b/packages/next/src/lib/interop-default.ts @@ -1,3 +1,15 @@ -export function interopDefault(mod: any) { - return mod.default || mod +export function interopDefault(mod: unknown): T | undefined { + // If the module is falsy (like `null`), just return undefined. + if (!mod) return undefined + + // If the module has a default export (named 'default'), return that. + if ( + (typeof mod === 'object' || typeof mod === 'function') && + 'default' in mod && + mod.default + ) { + return mod.default as T + } + + return mod as T } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 23aac6c89002c3..3662725d1ba490 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -703,7 +703,7 @@ export default class DevServer extends Server { fetchCacheKeyPrefix: this.nextConfig.experimental.fetchCacheKeyPrefix, isrFlushToDisk: this.nextConfig.experimental.isrFlushToDisk, maxMemoryCacheSize: this.nextConfig.experimental.isrMemoryCacheSize, - ppr: this.nextConfig.experimental.ppr === true, + nextConfigPPREnabled: this.nextConfig.experimental.ppr === true, }) return pathsResult } finally { diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index 32f052193584ad..22d6beef2be41a 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -38,7 +38,7 @@ export async function loadStaticPaths({ maxMemoryCacheSize, requestHeaders, incrementalCacheHandlerPath, - ppr, + nextConfigPPREnabled, }: { dir: string distDir: string @@ -54,7 +54,7 @@ export async function loadStaticPaths({ maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] incrementalCacheHandlerPath?: string - ppr: boolean + nextConfigPPREnabled: boolean }): Promise<{ paths?: string[] encodedPaths?: string[] @@ -109,7 +109,7 @@ export async function loadStaticPaths({ isrFlushToDisk, fetchCacheKeyPrefix, maxMemoryCacheSize, - ppr, + nextConfigPPREnabled, ComponentMod: components.ComponentMod, }) } diff --git a/packages/next/src/server/load-components.ts b/packages/next/src/server/load-components.ts index cce641eb998f2b..3abbe6f76700e3 100644 --- a/packages/next/src/server/load-components.ts +++ b/packages/next/src/server/load-components.ts @@ -45,15 +45,15 @@ export interface LoadableManifest { } export type LoadComponentsReturnType = { - Component: NextComponentType + Component?: NextComponentType pageConfig: PageConfig buildManifest: BuildManifest subresourceIntegrityManifest?: Record reactLoadableManifest: ReactLoadableManifest clientReferenceManifest?: ClientReferenceManifest serverActionsManifest?: any - Document: DocumentType - App: AppType + Document?: DocumentType + App?: AppType getStaticProps?: GetStaticProps getStaticPaths?: GetStaticPaths getServerSideProps?: GetServerSideProps @@ -108,14 +108,18 @@ async function loadComponentsImpl({ page: string isAppPath: boolean }): Promise> { - let DocumentMod = {} - let AppMod = {} + let Document: DocumentType | undefined + let App: AppType | undefined if (!isAppPath) { - ;[DocumentMod, AppMod] = await Promise.all([ + const [DocumentMod, AppMod] = await Promise.all([ Promise.resolve().then(() => requirePage('/_document', distDir, false)), Promise.resolve().then(() => requirePage('/_app', distDir, false)), ]) + + Document = interopDefault(DocumentMod) + App = interopDefault(AppMod) } + const ComponentMod = await Promise.resolve().then(() => requirePage(page, distDir, isAppPath) ) @@ -153,9 +157,7 @@ async function loadComponentsImpl({ : null, ]) - const Component = interopDefault(ComponentMod) - const Document = interopDefault(DocumentMod) - const App = interopDefault(AppMod) + const Component = interopDefault(ComponentMod) const { getServerSideProps, getStaticProps, getStaticPaths, routeModule } = ComponentMod diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 7b079fbd7162ee..fcfbabd9d46f68 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -452,15 +452,17 @@ export async function renderToHTMLImpl( runtime: globalRuntime, isExperimentalCompile, } = renderOpts + + if (!renderOpts.Component) { + throw new Error('Invariant: Cannot render without a Component') + } + const { App } = extra + let { Document } = extra const assetQueryString = metadata.assetQueryString - let Document = extra.Document - - let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) = - renderOpts.Component - const OriginComponent = Component + const { Component } = renderOpts let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, @@ -772,7 +774,7 @@ export async function renderToHTMLImpl( AppTree: (props: any) => { return ( - {renderPageTree(App, OriginComponent, { ...props, router })} + {renderPageTree(App, Component, { ...props, router })} ) }, @@ -1521,7 +1523,7 @@ export async function renderToHTMLImpl( ) if (process.env.NODE_ENV !== 'production') { - const nonRenderedComponents = [] + const nonRenderedComponents: string[] = [] const expectedDocComponents = ['Main', 'Head', 'NextScript', 'Html'] for (const comp of expectedDocComponents) { @@ -1587,5 +1589,18 @@ export const renderToHTML: PagesRender = ( query, renderOpts ) => { - return renderToHTMLImpl(req, res, pathname, query, renderOpts, renderOpts) + if (!renderOpts.App) { + throw new Error('Invariant: missing App in renderOpts') + } + + if (!renderOpts.Document) { + throw new Error('Invariant: missing Document in renderOpts') + } + + const extra = { + App: renderOpts.App, + Document: renderOpts.Document, + } + + return renderToHTMLImpl(req, res, pathname, query, renderOpts, extra) }