From a45f5082a6e5e3c0300fce0dcb0c0a463f0c62d2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 13 Aug 2024 12:27:22 -0600 Subject: [PATCH] refactor: fallback mode --- packages/next/src/build/index.ts | 11 +- packages/next/src/build/utils.ts | 150 +++++++++++------- packages/next/src/lib/fallback.ts | 101 ++++++++++++ packages/next/src/server/base-server.ts | 68 ++++---- .../next/src/server/dev/next-dev-server.ts | 18 +-- .../src/server/dev/static-paths-worker.ts | 3 + packages/next/src/types.ts | 3 +- 7 files changed, 253 insertions(+), 101 deletions(-) create mode 100644 packages/next/src/lib/fallback.ts diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 7e73394a3bb66..4986996c0ab98 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -198,6 +198,7 @@ import { import { storeShuttle } from './flying-shuttle/store-shuttle' import { stitchBuilds } from './flying-shuttle/stitch-builds' import { inlineStaticEnv } from './flying-shuttle/inline-static-env' +import { FallbackMode } from '../lib/fallback' interface ExperimentalBypassForInfo { experimentalBypassFor?: RouteHas[] @@ -2219,9 +2220,15 @@ export default async function build( ) } - if (workerResult.prerenderFallback === 'blocking') { + if ( + workerResult.prerenderFallback === + FallbackMode.BLOCKING_RENDER + ) { ssgBlockingFallbackPages.add(page) - } else if (workerResult.prerenderFallback === true) { + } else if ( + workerResult.prerenderFallback === + FallbackMode.SERVE_PRERENDER + ) { ssgStaticFallbackPages.add(page) } } else if (workerResult.hasServerProps) { diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 3f123c443816d..933a286afe4a4 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -90,6 +90,12 @@ import { formatDynamicImportPath } from '../lib/format-dynamic-import-path' import { isInterceptionRouteAppPath } from '../server/lib/interception-routes' import { checkIsRoutePPREnabled } from '../server/lib/experimental/ppr' import type { Params } from '../client/components/params' +import { FallbackMode } from '../lib/fallback' +import { + fallbackToStaticPathsResult, + parseFallbackAppConfig, + parseFallbackStaticPathsResult, +} from '../lib/fallback' export type ROUTER_TYPE = 'pages' | 'app' @@ -945,7 +951,7 @@ export type PrerenderedRoute = { } export type StaticPathsResult = { - fallback: boolean | 'blocking' + fallback: FallbackMode prerenderedRoutes: PrerenderedRoute[] } @@ -1172,7 +1178,7 @@ export async function buildStaticPaths({ const seen = new Set() return { - fallback: staticPathsResult.fallback, + fallback: parseFallbackStaticPathsResult(staticPathsResult.fallback), prerenderedRoutes: prerenderRoutes.filter((route) => { if (seen.has(route.path)) return false @@ -1345,6 +1351,7 @@ export async function buildAppStaticPaths({ requestHeaders, maxMemoryCacheSize, fetchCacheKeyPrefix, + nextConfigOutput, ComponentMod, }: { dir: string @@ -1357,6 +1364,7 @@ export async function buildAppStaticPaths({ cacheHandler?: string maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] + nextConfigOutput: 'standalone' | 'export' | undefined ComponentMod: AppPageModule }): Promise { ComponentMod.patchFetch() @@ -1478,20 +1486,19 @@ export async function buildAppStaticPaths({ return newParams } + const appConfig = reduceAppConfig(generateParams, nextConfigOutput) + const builtParams = await buildParams() + const fallback = parseFallbackAppConfig(appConfig) - // We should return `fallback: 'blocking'` when either `dynamicParams` - // is not set or it's only set to `true`. When it's instead set to - // `false`, we need to set `fallback: false` to prevent additional pages - // from being rendered. - const fallback: false | 'blocking' = - !generateParams.some( - // TODO: dynamic params should be allowed - // to be granular per segment but we need - // additional information stored/leveraged in - // the prerender-manifest to allow this behavior - (generate) => generate.config?.dynamicParams === false - ) && 'blocking' + if ( + fallback === FallbackMode.BLOCKING_RENDER && + nextConfigOutput === 'export' + ) { + throw new Error( + '"dynamicParams: true" cannot be used with "output: export". See more info here: https://nextjs.org/docs/app/building-your-application/deploying/static-exports' + ) + } if (!hadAllParamsGenerated) { return { @@ -1502,7 +1509,7 @@ export async function buildAppStaticPaths({ return buildStaticPaths({ staticPathsResult: { - fallback, + fallback: fallbackToStaticPathsResult(fallback), paths: builtParams.map((params) => ({ params })), }, page, @@ -1522,7 +1529,7 @@ type PageIsStaticResult = { hasServerProps?: boolean hasStaticProps?: boolean prerenderedRoutes: PrerenderedRoute[] | undefined - prerenderFallback: boolean | 'blocking' | undefined + prerenderFallback: FallbackMode | undefined isNextImageImported?: boolean traceIncludes?: string[] traceExcludes?: string[] @@ -1545,6 +1552,7 @@ export async function isPageStatic({ originalAppPath, isrFlushToDisk, maxMemoryCacheSize, + nextConfigOutput, cacheHandler, pprConfig, }: { @@ -1564,7 +1572,7 @@ export async function isPageStatic({ isrFlushToDisk?: boolean maxMemoryCacheSize?: number cacheHandler?: string - nextConfigOutput: 'standalone' | 'export' + nextConfigOutput: 'standalone' | 'export' | undefined pprConfig: ExperimentalPPRConfig | undefined }): Promise { const isPageStaticSpan = trace('is-page-static-utils', parentId) @@ -1579,7 +1587,7 @@ export async function isPageStatic({ let componentsResult: LoadComponentsReturnType let prerenderedRoutes: PrerenderedRoute[] | undefined - let prerenderFallback: boolean | 'blocking' | undefined + let prerenderFallback: FallbackMode | undefined let appConfig: AppConfig = {} let isClientComponent: boolean = false const pathIsEdgeRuntime = isEdgeRuntime(pageRuntime) @@ -1652,7 +1660,7 @@ export async function isPageStatic({ ] : await collectGenerateParams(tree) - appConfig = reduceAppConfig(generateParams) + appConfig = reduceAppConfig(generateParams, nextConfigOutput) if (appConfig.dynamic === 'force-static' && pathIsEdgeRuntime) { Log.warn( @@ -1688,6 +1696,7 @@ export async function isPageStatic({ maxMemoryCacheSize, cacheHandler, ComponentMod, + nextConfigOutput, })) } } else { @@ -1781,50 +1790,73 @@ export async function isPageStatic({ }) } -function reduceAppConfig(generateParams: GenerateParamsResults): AppConfig { - return generateParams.reduce( - (builtConfig: AppConfig, curGenParams): AppConfig => { - const { - dynamic, - fetchCache, - preferredRegion, - revalidate: curRevalidate, - experimental_ppr, - } = 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 - } - // If partial prerendering has been set, only override it if the current value is - // provided as it's resolved from root layout to leaf page. - if (typeof experimental_ppr !== 'undefined') { - builtConfig.experimental_ppr = experimental_ppr - } +function reduceAppConfig( + generateParams: GenerateParamsResults, + nextConfigOutput: 'standalone' | 'export' | undefined +): AppConfig { + return generateParams.reduce((builtConfig, curGenParams) => { + const { + dynamic, + fetchCache, + preferredRegion, + revalidate: curRevalidate, + experimental_ppr, + dynamicParams, + } = 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 + } + // If partial prerendering has been set, only override it if the current value is + // provided as it's resolved from root layout to leaf page. + if (typeof experimental_ppr !== 'undefined') { + builtConfig.experimental_ppr = experimental_ppr + } - // any revalidate number overrides false - // shorter revalidate overrides longer (initially) - if (typeof builtConfig.revalidate === 'undefined') { - builtConfig.revalidate = curRevalidate - } + // 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 + } + + // The default for the dynamicParams will be based on the dynamic config + // and the next config output option. If any of these are set or the + // output config is set to export, set the dynamicParams to false, + // otherwise the default is true. + if (typeof builtConfig.dynamicParams === 'undefined') { if ( - typeof curRevalidate === 'number' && - (typeof builtConfig.revalidate !== 'number' || - curRevalidate < builtConfig.revalidate) + builtConfig.dynamic === 'error' || + builtConfig.dynamic === 'force-static' || + nextConfigOutput === 'export' ) { - builtConfig.revalidate = curRevalidate + builtConfig.dynamicParams = false + } else { + builtConfig.dynamicParams = true } - return builtConfig - }, - {} - ) + } + + if (typeof dynamicParams === 'boolean') { + builtConfig.dynamicParams = dynamicParams + } + + return builtConfig + }, {}) } export async function hasCustomGetInitialProps({ diff --git a/packages/next/src/lib/fallback.ts b/packages/next/src/lib/fallback.ts new file mode 100644 index 0000000000000..daa30ca0050e3 --- /dev/null +++ b/packages/next/src/lib/fallback.ts @@ -0,0 +1,101 @@ +import type { AppConfig } from '../build/utils' + +/** + * Describes the different fallback modes that a given page can have. + */ +export const enum FallbackMode { + /** + * A BLOCKING_RENDER fallback will block the request until the page is\ + * generated. No fallback page will be rendered, and users will have to wait + * to render the page. + */ + BLOCKING_RENDER = 'BLOCKING_RENDER', + + /** + * When set to NOT_FOUND, pages that are not already prerendered will result + * in a not found response. + */ + NOT_FOUND = 'NOT_FOUND', + + /** + * When set to PRERENDER, a fallback page will be sent to users in place of + * forcing them to wait for the page to be generated. This allows the user to + * see a rendered page earlier. + */ + SERVE_PRERENDER = 'SERVE_PRERENDER', +} + +/** + * The fallback value returned from the `getStaticPaths` function. + */ +export type GetStaticPathsFallback = boolean | 'blocking' + +/** + * Parses the fallback field from the prerender manifest. + * + * @param fallbackField The fallback field from the prerender manifest. + * @returns The fallback mode. + */ +export function parseFallbackField( + fallbackField: string | false | null +): FallbackMode { + if (typeof fallbackField === 'string') { + return FallbackMode.SERVE_PRERENDER + } else if (fallbackField === null) { + return FallbackMode.BLOCKING_RENDER + } else { + return FallbackMode.NOT_FOUND + } +} + +/** + * Parses the fallback from the static paths result. + * + * @param result The result from the static paths function. + * @returns The fallback mode. + */ +export function parseFallbackStaticPathsResult( + result: GetStaticPathsFallback +): FallbackMode { + if (result === true) { + return FallbackMode.SERVE_PRERENDER + } else if (result === 'blocking') { + return FallbackMode.BLOCKING_RENDER + } else { + 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 fallbackToStaticPathsResult( + fallback: FallbackMode +): GetStaticPathsFallback { + switch (fallback) { + case FallbackMode.SERVE_PRERENDER: + return true + case FallbackMode.BLOCKING_RENDER: + return 'blocking' + case FallbackMode.NOT_FOUND: + default: + return false + } +} + +/** + * Parses the fallback field from the app config. + * + * @param appConfig The app config. + * @returns The fallback mode. + */ +export function parseFallbackAppConfig(appConfig: AppConfig): FallbackMode { + if (appConfig.dynamicParams === false) { + return FallbackMode.NOT_FOUND + } else { + return FallbackMode.BLOCKING_RENDER + } +} diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 115001c6ee98f..ce4bcdcf31fa3 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -163,6 +163,7 @@ import { ENCODED_TAGS } from './stream-utils/encodedTags' import { NextRequestHint } from './web/adapter' import { RouteKind } from './route-kind' import type { RouteModule } from './route-modules/route-module' +import { FallbackMode, parseFallbackField } from '../lib/fallback' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -292,8 +293,6 @@ export type RequestContext< renderOpts: RenderOpts } -export type FallbackMode = false | undefined | 'blocking' | 'static' - export class NoFallbackError extends Error {} // Internal wrapper around build errors at development @@ -585,7 +584,7 @@ export default abstract class Server< ? publicRuntimeConfig : undefined, - // @ts-expect-error internal field not publicly exposed + // @ts-expect-error internal field isExperimentalCompile: this.nextConfig.experimental.isExperimentalCompile, experimental: { swrDelta: this.nextConfig.swrDelta, @@ -1830,7 +1829,7 @@ export default abstract class Server< isAppPath: boolean }): Promise<{ staticPaths?: string[] - fallbackMode?: 'static' | 'blocking' | false + fallbackMode?: FallbackMode }> { // Read whether or not fallback should exist from the manifest. const fallbackField = @@ -1840,12 +1839,7 @@ export default abstract class Server< // `staticPaths` is intentionally set to `undefined` as it should've // been caught when checking disk data. staticPaths: undefined, - fallbackMode: - typeof fallbackField === 'string' - ? 'static' - : fallbackField === null - ? 'blocking' - : fallbackField, + fallbackMode: parseFallbackField(fallbackField), } } @@ -1935,7 +1929,7 @@ export default abstract class Server< let staticPaths: string[] | undefined - let fallbackMode: FallbackMode + let fallbackMode: FallbackMode | undefined let hasFallback = false const isDynamic = isDynamicRoute(components.page) @@ -1955,8 +1949,7 @@ export default abstract class Server< if (this.nextConfig.output === 'export') { const page = components.page - - if (fallbackMode !== 'static') { + if (fallbackMode === FallbackMode.BLOCKING_RENDER) { throw new Error( `Page "${page}" is missing exported function "generateStaticParams()", which is required with "output: export" config.` ) @@ -2711,19 +2704,32 @@ export default abstract class Server< const isProduction = !this.renderOpts.dev const didRespond = hasResolved || res.sent + // If we haven't found the static paths for the route, then do it now. if (!staticPaths) { - ;({ staticPaths, fallbackMode } = hasStaticPaths - ? await this.getStaticPaths({ - pathname, - requestHeaders: req.headers, - isAppPath, - page: components.page, - }) - : { staticPaths: undefined, fallbackMode: false }) + if (hasStaticPaths) { + const pathsResult = await this.getStaticPaths({ + pathname, + requestHeaders: req.headers, + isAppPath, + page: components.page, + }) + + staticPaths = pathsResult.staticPaths + fallbackMode = pathsResult.fallbackMode + } else { + staticPaths = undefined + fallbackMode = FallbackMode.NOT_FOUND + } } - if (fallbackMode === 'static' && isBot(req.headers['user-agent'] || '')) { - fallbackMode = 'blocking' + // When serving a bot request, we want to serve a blocking render and not + // the prerendered page. This ensures that the correct content is served + // to the bot in the head. + if ( + fallbackMode === FallbackMode.SERVE_PRERENDER && + isBot(req.headers['user-agent'] || '') + ) { + fallbackMode = FallbackMode.BLOCKING_RENDER } // skip on-demand revalidate if cache is not present and @@ -2746,9 +2752,9 @@ export default abstract class Server< // or for prerendered fallback: false paths if ( isOnDemandRevalidate && - (fallbackMode !== false || previousCacheEntry) + (fallbackMode !== FallbackMode.NOT_FOUND || previousCacheEntry) ) { - fallbackMode = 'blocking' + fallbackMode = FallbackMode.BLOCKING_RENDER } // We use `ssgCacheKey` here as it is normalized to match the encoding @@ -2765,8 +2771,12 @@ export default abstract class Server< const isPageIncludedInStaticPaths = staticPathKey && staticPaths?.includes(staticPathKey) - if ((this.nextConfig.experimental as any).isExperimentalCompile) { - fallbackMode = 'blocking' + // When experimental compile is used, no pages have been prerendered, + // so they should all be blocking. + + // @ts-expect-error internal field + if (this.nextConfig.experimental.isExperimentalCompile) { + fallbackMode = FallbackMode.BLOCKING_RENDER } // When we did not respond from cache, we need to choose to block on @@ -2783,7 +2793,7 @@ export default abstract class Server< if ( process.env.NEXT_RUNTIME !== 'edge' && !this.minimalMode && - fallbackMode !== 'blocking' && + fallbackMode !== FallbackMode.BLOCKING_RENDER && staticPathKey && !didRespond && !isPreviewMode && @@ -2795,7 +2805,7 @@ export default abstract class Server< // getStaticPaths. (isProduction || (staticPaths && staticPaths?.length > 0)) && // When fallback isn't present, abort this render so we 404 - fallbackMode !== 'static' + fallbackMode === FallbackMode.NOT_FOUND ) { throw new NoFallbackError() } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 13be77b728bd3..60ccdcb9c409c 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -5,7 +5,7 @@ import type { Params } from '../../client/components/params' import type { ParsedUrl } from '../../shared/lib/router/utils/parse-url' import type { ParsedUrlQuery } from 'querystring' import type { UrlWithParsedQuery } from 'url' -import type { FallbackMode, MiddlewareRoutingItem } from '../base-server' +import type { MiddlewareRoutingItem } from '../base-server' import type { FunctionComponent } from 'react' import type { RouteDefinition } from '../route-definitions/route-definition' import type { RouteMatcherManager } from '../route-matcher-managers/route-matcher-manager' @@ -69,6 +69,7 @@ import { decorateServerError } from '../../shared/lib/error-source' import type { ServerOnInstrumentationRequestError } from '../app-render/types' import type { ServerComponentsHmrCache } from '../response-cache' import { logRequests } from './log-requests' +import { FallbackMode } from '../../lib/fallback' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -736,7 +737,7 @@ export default class DevServer extends Server { isAppPath: boolean }): Promise<{ staticPaths?: string[] - fallbackMode?: false | 'static' | 'blocking' + fallbackMode?: FallbackMode }> { // we lazy load the staticPaths to prevent the user // from waiting on them for the page to load in dev mode @@ -771,6 +772,7 @@ export default class DevServer extends Server { fetchCacheKeyPrefix: this.nextConfig.experimental.fetchCacheKeyPrefix, isrFlushToDisk: this.nextConfig.experimental.isrFlushToDisk, maxMemoryCacheSize: this.nextConfig.cacheMaxMemorySize, + nextConfigOutput: this.nextConfig.output, }) return pathsResult } finally { @@ -787,27 +789,23 @@ export default class DevServer extends Server { .then((res) => { const { prerenderedRoutes: staticPaths = [], fallback } = res.value if (!isAppPath && this.nextConfig.output === 'export') { - if (fallback === 'blocking') { + if (fallback === FallbackMode.BLOCKING_RENDER) { throw new Error( 'getStaticPaths with "fallback: blocking" cannot be used with "output: export". See more info here: https://nextjs.org/docs/advanced-features/static-html-export' ) - } else if (fallback === true) { + } else if (fallback === FallbackMode.SERVE_PRERENDER) { throw new Error( 'getStaticPaths with "fallback: true" cannot be used with "output: export". See more info here: https://nextjs.org/docs/advanced-features/static-html-export' ) } } + const value: { staticPaths: string[] fallbackMode: FallbackMode } = { staticPaths: staticPaths.map((route) => route.path), - fallbackMode: - fallback === 'blocking' - ? 'blocking' - : fallback === true - ? 'static' - : fallback, + fallbackMode: fallback ?? FallbackMode.NOT_FOUND, } this.staticPathsCache.set(pathname, value) return value diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index 7429dcfbb3bb3..b743ad453d84d 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -41,6 +41,7 @@ export async function loadStaticPaths({ maxMemoryCacheSize, requestHeaders, cacheHandler, + nextConfigOutput, }: { dir: string distDir: string @@ -56,6 +57,7 @@ export async function loadStaticPaths({ maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] cacheHandler?: string + nextConfigOutput: 'standalone' | 'export' | undefined }): Promise { // update work memory runtime-config require('../../shared/lib/runtime-config.external').setConfig(config) @@ -107,6 +109,7 @@ export async function loadStaticPaths({ fetchCacheKeyPrefix, maxMemoryCacheSize, ComponentMod: components.ComponentMod, + nextConfigOutput, }) } diff --git a/packages/next/src/types.ts b/packages/next/src/types.ts index 396abc74fdb73..3d3a57015b37d 100644 --- a/packages/next/src/types.ts +++ b/packages/next/src/types.ts @@ -18,6 +18,7 @@ import type { NextApiRequest, NextApiHandler, } from './shared/lib/utils' +import type { GetStaticPathsFallback } from './lib/fallback' import type { NextApiRequestCookies } from './server/api-utils' @@ -220,7 +221,7 @@ export type GetStaticPathsResult< Params extends ParsedUrlQuery = ParsedUrlQuery, > = { paths: Array - fallback: boolean | 'blocking' + fallback: GetStaticPathsFallback } /**