diff --git a/packages/next-swc/crates/next-core/src/app_segment_config.rs b/packages/next-swc/crates/next-core/src/app_segment_config.rs index cbed6876317f1..112e5122cf92e 100644 --- a/packages/next-swc/crates/next-core/src/app_segment_config.rs +++ b/packages/next-swc/crates/next-core/src/app_segment_config.rs @@ -72,6 +72,7 @@ pub struct NextSegmentConfig { pub fetch_cache: Option, pub runtime: Option, pub preferred_region: Option>, + pub experimental_ppr: Option, } #[turbo_tasks::value_impl] @@ -93,6 +94,7 @@ impl NextSegmentConfig { fetch_cache, runtime, preferred_region, + experimental_ppr, } = self; *dynamic = dynamic.or(parent.dynamic); *dynamic_params = dynamic_params.or(parent.dynamic_params); @@ -100,6 +102,7 @@ impl NextSegmentConfig { *fetch_cache = fetch_cache.or(parent.fetch_cache); *runtime = runtime.or(parent.runtime); *preferred_region = preferred_region.take().or(parent.preferred_region.clone()); + *experimental_ppr = experimental_ppr.or(parent.experimental_ppr); } /// Applies a config from a paralllel route to this config, returning an @@ -133,6 +136,7 @@ impl NextSegmentConfig { fetch_cache, runtime, preferred_region, + experimental_ppr, } = self; merge_parallel(dynamic, ¶llel_config.dynamic, "dynamic")?; merge_parallel( @@ -148,6 +152,11 @@ impl NextSegmentConfig { ¶llel_config.preferred_region, "referredRegion", )?; + merge_parallel( + experimental_ppr, + ¶llel_config.experimental_ppr, + "experimental_ppr", + )?; Ok(()) } } @@ -422,6 +431,15 @@ fn parse_config_value( config.preferred_region = Some(preferred_region); } + "experimental_ppr" => { + let value = eval_context.eval(init); + let Some(val) = value.as_bool() else { + invalid_config("`experimental_ppr` needs to be a static boolean", &value); + return; + }; + + config.experimental_ppr = Some(val); + } _ => {} } } diff --git a/packages/next-swc/crates/next-core/src/next_config.rs b/packages/next-swc/crates/next-core/src/next_config.rs index 7ed10e26e1982..8b749fce85146 100644 --- a/packages/next-swc/crates/next-core/src/next_config.rs +++ b/packages/next-swc/crates/next-core/src/next_config.rs @@ -520,7 +520,7 @@ pub struct ExperimentalConfig { output_file_tracing_root: Option, /// Using this feature will enable the `react@experimental` for the `app` /// directory. - ppr: Option, + ppr: Option, taint: Option, proxy_timeout: Option, /// enables the minification of server code. @@ -542,6 +542,49 @@ pub struct ExperimentalConfig { worker_threads: Option, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)] +#[serde(rename_all = "lowercase")] +pub enum ExperimentalPartialPrerenderingIncrementalValue { + Incremental, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TraceRawVcs)] +#[serde(untagged)] +pub enum ExperimentalPartialPrerendering { + Incremental(ExperimentalPartialPrerenderingIncrementalValue), + Boolean(bool), +} + +#[test] +fn test_parse_experimental_partial_prerendering() { + let json = serde_json::json!({ + "ppr": "incremental" + }); + let config: ExperimentalConfig = serde_json::from_value(json).unwrap(); + assert_eq!( + config.ppr, + Some(ExperimentalPartialPrerendering::Incremental( + ExperimentalPartialPrerenderingIncrementalValue::Incremental + )) + ); + + let json = serde_json::json!({ + "ppr": true + }); + let config: ExperimentalConfig = serde_json::from_value(json).unwrap(); + assert_eq!( + config.ppr, + Some(ExperimentalPartialPrerendering::Boolean(true)) + ); + + // Expect if we provide a random string, it will fail. + let json = serde_json::json!({ + "ppr": "random" + }); + let config = serde_json::from_value::(json); + assert!(config.is_err()); +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)] #[serde(rename_all = "camelCase")] pub struct SubResourceIntegrity { @@ -572,6 +615,25 @@ pub enum EsmExternals { Bool(bool), } +// Test for esm externals deserialization. +#[test] +fn test_esm_externals_deserialization() { + let json = serde_json::json!({ + "esmExternals": true + }); + let config: ExperimentalConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.esm_externals, Some(EsmExternals::Bool(true))); + + let json = serde_json::json!({ + "esmExternals": "loose" + }); + let config: ExperimentalConfig = serde_json::from_value(json).unwrap(); + assert_eq!( + config.esm_externals, + Some(EsmExternals::Loose(EsmExternalsValue::Loose)) + ); +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TraceRawVcs)] #[serde(rename_all = "camelCase")] pub struct ServerActions { @@ -934,7 +996,19 @@ impl NextConfig { #[turbo_tasks::function] pub async fn enable_ppr(self: Vc) -> Result> { - Ok(Vc::cell(self.await?.experimental.ppr.unwrap_or(false))) + Ok(Vc::cell( + self.await? + .experimental + .ppr + .as_ref() + .map(|ppr| match ppr { + ExperimentalPartialPrerendering::Incremental( + ExperimentalPartialPrerenderingIncrementalValue::Incremental, + ) => true, + ExperimentalPartialPrerendering::Boolean(b) => *b, + }) + .unwrap_or(false), + )) } #[turbo_tasks::function] diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 3469e0bd7b524..97dd5b2f69455 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -314,7 +314,7 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { export function getMiddlewareMatchers( matcherOrMatchers: unknown, - nextConfig: NextConfig + nextConfig: Pick ): MiddlewareMatcher[] { let matchers: unknown[] = [] if (Array.isArray(matcherOrMatchers)) { diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index f1b5a6c6805d9..6b82ef457517a 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -187,6 +187,10 @@ import { traceMemoryUsage } from '../lib/memory/trace' import { generateEncryptionKeyBase64 } from '../server/app-render/encryption-utils' import type { DeepReadonly } from '../shared/lib/deep-readonly' import uploadTrace from '../trace/upload-trace' +import { + checkIsAppPPREnabled, + checkIsRoutePPREnabled, +} from '../server/lib/experimental/ppr' interface ExperimentalBypassForInfo { experimentalBypassFor?: RouteHas[] @@ -1740,7 +1744,6 @@ export default async function build( const additionalSsgPaths = new Map>() const additionalSsgPathsEncoded = new Map>() const appStaticPaths = new Map>() - const appPrefetchPaths = new Map() const appStaticPathsEncoded = new Map>() const appNormalizedPaths = new Map() const appDynamicParamPaths = new Set() @@ -1808,7 +1811,7 @@ export default async function build( minimalMode: ciEnvironment.hasNextSupport, allowedRevalidateHeaderKeys: config.experimental.allowedRevalidateHeaderKeys, - experimental: { ppr: config.experimental.ppr === true }, + isAppPPREnabled: checkIsAppPPREnabled(config.experimental.ppr), }) incrementalCacheIpcPort = cacheInitialization.ipcPort @@ -1886,7 +1889,7 @@ export default async function build( locales: config.i18n?.locales, defaultLocale: config.i18n?.defaultLocale, nextConfigOutput: config.output, - ppr: config.experimental.ppr === true, + pprConfig: config.experimental.ppr, }) ) @@ -1984,7 +1987,7 @@ export default async function build( computedManifestData ) - let isPPR = false + let isRoutePPREnabled = false let isSSG = false let isStatic = false let isServerComponent = false @@ -2099,7 +2102,7 @@ export default async function build( : config.experimental.isrFlushToDisk, maxMemoryCacheSize: config.cacheMaxMemorySize, nextConfigOutput: config.output, - ppr: config.experimental.ppr === true, + pprConfig: config.experimental.ppr, }) } ) @@ -2118,8 +2121,8 @@ export default async function build( // If this route can be partially pre-rendered, then // mark it as such and mark that it can be // generated server-side. - if (workerResult.isPPR) { - isPPR = workerResult.isPPR + if (workerResult.isRoutePPREnabled) { + isRoutePPREnabled = workerResult.isRoutePPREnabled isSSG = true isStatic = true @@ -2174,7 +2177,6 @@ export default async function build( ]) isStatic = true } else if ( - isDynamic && !hasGenerateStaticParams && (appConfig.dynamic === 'error' || appConfig.dynamic === 'force-static') @@ -2182,7 +2184,7 @@ export default async function build( appStaticPaths.set(originalAppPath, []) appStaticPathsEncoded.set(originalAppPath, []) isStatic = true - isPPR = false + isRoutePPREnabled = false } } } @@ -2193,18 +2195,6 @@ export default async function build( appDynamicParamPaths.add(originalAppPath) } appDefaultConfigs.set(originalAppPath, appConfig) - - // Only generate the app prefetch rsc if the route is - // an app page. - if ( - !isStatic && - !isAppRouteRoute(originalAppPath) && - !isDynamicRoute(originalAppPath) && - !isPPR && - !isInterceptionRoute - ) { - appPrefetchPaths.set(originalAppPath, page) - } } } else { if (isEdgeRuntime(pageRuntime)) { @@ -2328,7 +2318,7 @@ export default async function build( totalSize, isStatic, isSSG, - isPPR, + isRoutePPREnabled, isHybridAmp, ssgPageRoutes, initialRevalidateSeconds: false, @@ -2623,34 +2613,24 @@ export default async function build( // revalidate periods and dynamicParams settings appStaticPaths.forEach((routes, originalAppPath) => { const encodedRoutes = appStaticPathsEncoded.get(originalAppPath) - const appConfig = appDefaultConfigs.get(originalAppPath) || {} + const appConfig = appDefaultConfigs.get(originalAppPath) routes.forEach((route, routeIdx) => { defaultMap[route] = { page: originalAppPath, query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, - _isDynamicError: appConfig.dynamic === 'error', + _isDynamicError: appConfig?.dynamic === 'error', _isAppDir: true, + _isRoutePPREnabled: appConfig + ? checkIsRoutePPREnabled( + config.experimental.ppr, + appConfig + ) + : undefined, } }) }) - // Ensure we don't generate explicit app prefetches while in PPR. - if (config.experimental.ppr && appPrefetchPaths.size > 0) { - throw new Error( - "Invariant: explicit app prefetches shouldn't generated with PPR" - ) - } - - for (const [originalAppPath, page] of appPrefetchPaths) { - defaultMap[page] = { - page: originalAppPath, - query: {}, - _isAppDir: true, - _isAppPrefetch: true, - } - } - if (i18n) { for (const page of [ ...staticPages, @@ -2682,6 +2662,7 @@ export default async function build( } } } + return defaultMap }, } @@ -2752,8 +2733,9 @@ export default async function build( // When this is an app page and PPR is enabled, the route supports // partial pre-rendering. - const experimentalPPR = - !isRouteHandler && config.experimental.ppr === true + const experimentalPPR: true | undefined = + !isRouteHandler && + checkIsRoutePPREnabled(config.experimental.ppr, appConfig) ? true : undefined diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 203557b34246e..fc1099298ab47 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1,4 +1,5 @@ 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 { @@ -87,6 +88,7 @@ import { interopDefault } from '../lib/interop-default' import type { PageExtensions } from './page-extensions-type' import { formatDynamicImportPath } from '../lib/format-dynamic-import-path' import { isInterceptionRouteAppPath } from '../server/future/helpers/interception-routes' +import { checkIsRoutePPREnabled } from '../server/lib/experimental/ppr' export type ROUTER_TYPE = 'pages' | 'app' @@ -347,7 +349,10 @@ export interface PageInfo { totalSize: number isStatic: boolean isSSG: boolean - isPPR: boolean + /** + * If true, it means that the route has partial prerendering enabled. + */ + isRoutePPREnabled: boolean ssgPageRoutes: string[] | null initialRevalidateSeconds: number | false pageDuration: number | undefined @@ -489,7 +494,7 @@ export async function printTreeView( symbol = ' ' } else if (isEdgeRuntime(pageInfo?.runtime)) { symbol = 'ƒ' - } else if (pageInfo?.isPPR) { + } else if (pageInfo?.isRoutePPREnabled) { if ( // If the page has an empty prelude, then it's equivalent to a dynamic page pageInfo?.hasEmptyPrelude || @@ -1180,6 +1185,13 @@ export type AppConfig = { dynamic?: AppConfigDynamic fetchCache?: 'force-cache' | 'only-cache' preferredRegion?: string + + /** + * When true, the page will be served using partial prerendering. + * This setting will only take affect if it's enabled via + * the `experimental.ppr = "incremental"` option. + */ + experimental_ppr?: boolean } type Params = Record @@ -1197,10 +1209,12 @@ type GenerateParamsResult = { export type GenerateParamsResults = GenerateParamsResult[] -export const collectAppConfig = (mod: any): AppConfig | undefined => { +const collectAppConfig = ( + mod: Partial | undefined +): AppConfig | undefined => { let hasConfig = false - const config: AppConfig = {} + if (typeof mod?.revalidate !== 'undefined') { config.revalidate = mod.revalidate hasConfig = true @@ -1221,8 +1235,14 @@ export const collectAppConfig = (mod: any): AppConfig | undefined => { config.preferredRegion = mod.preferredRegion hasConfig = true } + if (typeof mod?.experimental_ppr !== 'undefined') { + config.experimental_ppr = mod.experimental_ppr + hasConfig = true + } + + if (!hasConfig) return undefined - return hasConfig ? config : undefined + return config } /** @@ -1313,7 +1333,6 @@ export async function buildAppStaticPaths({ requestHeaders, maxMemoryCacheSize, fetchCacheKeyPrefix, - ppr, ComponentMod, }: { dir: string @@ -1326,7 +1345,6 @@ export async function buildAppStaticPaths({ cacheHandler?: string maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] - ppr: boolean ComponentMod: AppPageModule }) { ComponentMod.patchFetch() @@ -1361,7 +1379,7 @@ export async function buildAppStaticPaths({ CurCacheHandler: CacheHandler, requestHeaders, minimalMode: ciEnvironment.hasNextSupport, - experimental: { ppr }, + isAppPPREnabled: false, }) return StaticGenerationAsyncStorageWrapper.wrap( @@ -1373,8 +1391,6 @@ export async function buildAppStaticPaths({ incrementalCache, supportsDynamicHTML: true, isRevalidate: false, - // building static paths should never postpone - experimental: { ppr: false }, }, }, async () => { @@ -1488,7 +1504,7 @@ export async function isPageStatic({ isrFlushToDisk, maxMemoryCacheSize, cacheHandler, - ppr, + pprConfig, }: { dir: string page: string @@ -1507,9 +1523,9 @@ export async function isPageStatic({ maxMemoryCacheSize?: number cacheHandler?: string nextConfigOutput: 'standalone' | 'export' - ppr: boolean + pprConfig: ExperimentalPPRConfig | undefined }): Promise<{ - isPPR?: boolean + isRoutePPREnabled?: boolean isStatic?: boolean isAmpOnly?: boolean isHybridAmp?: boolean @@ -1584,13 +1600,9 @@ export async function isPageStatic({ const routeModule: RouteModule = componentsResult.ComponentMod?.routeModule - let supportsPPR = false + let isRoutePPREnabled: boolean = false if (pageType === 'app') { - if (ppr && routeModule.definition.kind === RouteKind.APP_PAGE) { - supportsPPR = true - } - const ComponentMod: AppPageModule = componentsResult.ComponentMod isClientComponent = isClientReference(componentsResult.ComponentMod) @@ -1620,6 +1632,7 @@ export async function isPageStatic({ fetchCache, preferredRegion, revalidate: curRevalidate, + experimental_ppr, } = curGenParams?.config || {} // TODO: should conflicting configs here throw an error @@ -1633,6 +1646,11 @@ export async function isPageStatic({ 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) @@ -1657,10 +1675,18 @@ export async function isPageStatic({ ) } + // 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. + isRoutePPREnabled = + routeModule.definition.kind === RouteKind.APP_PAGE && + !isInterceptionRouteAppPath(page) && + checkIsRoutePPREnabled(pprConfig, appConfig) + // 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' && !supportsPPR) { + if (appConfig.dynamic === 'force-dynamic' && !isRoutePPREnabled) { appConfig.revalidate = 0 } @@ -1679,7 +1705,6 @@ export async function isPageStatic({ isrFlushToDisk, maxMemoryCacheSize, cacheHandler, - ppr, ComponentMod, })) } @@ -1757,21 +1782,13 @@ export async function isPageStatic({ // When PPR is enabled, any route may be completely static, so // mark this route as static. - let isPPR = false - if (supportsPPR) { - isPPR = true + if (isRoutePPREnabled) { isStatic = true } - // interception routes depend on `Next-URL` and `Next-Router-State-Tree` request headers and thus cannot be prerendered - if (isInterceptionRouteAppPath(page)) { - isStatic = false - isPPR = false - } - return { isStatic, - isPPR, + isRoutePPREnabled, isHybridAmp: config.amp === 'hybrid', isAmpOnly: config.amp === true, prerenderRoutes, diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index ecd022f9e2c2e..862e5bc3ea916 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -5,6 +5,7 @@ import type { import type { MiddlewareMatcher } from '../../analysis/get-page-static-info' import { webpack } from 'next/dist/compiled/webpack/webpack' import { needsExperimentalReact } from '../../../lib/needs-experimental-react' +import { checkIsAppPPREnabled } from '../../../server/lib/experimental/ppr' function errorIfEnvConflicted(config: NextConfigComplete, key: string) { const isPrivateKey = /^(?:NODE_.+)|^(?:__.+)$/i.test(key) @@ -165,7 +166,7 @@ export function getDefineEnv({ ? 'nodejs' : '', 'process.env.NEXT_MINIMAL': '', - 'process.env.__NEXT_PPR': config.experimental.ppr === true, + 'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr), 'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false, 'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': fetchCacheKeyPrefix ?? '', 'process.env.__NEXT_MIDDLEWARE_MATCHERS': middlewareMatchers ?? [], diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index de056f719699f..64265157ef8a1 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -80,6 +80,7 @@ checkFields>() diff --git a/packages/next/src/export/helpers/create-incremental-cache.ts b/packages/next/src/export/helpers/create-incremental-cache.ts index abc768da13b35..bb844c7b0c9e2 100644 --- a/packages/next/src/export/helpers/create-incremental-cache.ts +++ b/packages/next/src/export/helpers/create-incremental-cache.ts @@ -14,7 +14,7 @@ export async function createIncrementalCache({ distDir, dir, enabledDirectories, - experimental, + isAppPPREnabled, flushToDisk, }: { cacheHandler?: string @@ -23,7 +23,7 @@ export async function createIncrementalCache({ distDir: string dir: string enabledDirectories: NextEnabledDirectories - experimental: { ppr: boolean } + isAppPPREnabled: boolean flushToDisk?: boolean }) { // Custom cache handler overrides. @@ -60,7 +60,7 @@ export async function createIncrementalCache({ serverDistDir: path.join(distDir, 'server'), CurCacheHandler: CacheHandler, minimalMode: hasNextSupport, - experimental, + isAppPPREnabled, }) ;(globalThis as any).__incrementalCache = incrementalCache diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index ffd2c408c5a56..e90a0f64262a1 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -57,6 +57,7 @@ import { validateRevalidate } from '../server/lib/patch-fetch' import { TurborepoAccessTraceResult } from '../build/turborepo-access-trace' import { createProgress } from '../build/progress' import type { DeepReadonly } from '../shared/lib/deep-readonly' +import { checkIsAppPPREnabled } from '../server/lib/experimental/ppr' export class ExportError extends Error { code = 'NEXT_EXPORT_ERROR' @@ -422,7 +423,7 @@ export async function exportAppImpl( strictNextHead: !!nextConfig.experimental.strictNextHead, deploymentId: nextConfig.deploymentId, experimental: { - ppr: nextConfig.experimental.ppr === true, + isAppPPREnabled: checkIsAppPPREnabled(nextConfig.experimental.ppr), missingSuspenseWithCSRBailout: nextConfig.experimental.missingSuspenseWithCSRBailout === true, swrDelta: nextConfig.experimental.swrDelta, diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index cfd84f9118b1b..bb6e23ae01be0 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -64,7 +64,7 @@ export async function exportAppPage( const { flightData, revalidate = false, postponed, fetchTags } = metadata // Ensure we don't postpone without having PPR enabled. - if (postponed && !renderOpts.experimental.ppr) { + if (postponed && !renderOpts.experimental.isRoutePPREnabled) { throw new Error('Invariant: page postponed without PPR being enabled') } @@ -93,8 +93,9 @@ export async function exportAppPage( } // If PPR is enabled, we want to emit a prefetch rsc file for the page // instead of the standard rsc. This is because the standard rsc will - // contain the dynamic data. - else if (renderOpts.experimental.ppr) { + // contain the dynamic data. We do this if any routes have PPR enabled so + // that the cache read/write is the same. + else if (renderOpts.experimental.isAppPPREnabled) { // If PPR is enabled, we should emit the flight data as the prefetch // payload. await fileWriter( @@ -113,12 +114,6 @@ export async function exportAppPage( const headers: OutgoingHttpHeaders = { ...metadata.headers } - // When PPR is enabled, we should grab the headers from the mocked response - // and add it to the headers. - if (renderOpts.experimental.ppr) { - Object.assign(headers, res.getHeaders()) - } - if (fetchTags) { headers[NEXT_CACHE_TAGS_HEADER] = fetchTags } @@ -133,10 +128,11 @@ export async function exportAppPage( const isParallelRoute = /\/@\w+/.test(page) const isNonSuccessfulStatusCode = res.statusCode > 300 + // When PPR is enabled, we don't always send 200 for routes that have been // pregenerated, so we should grab the status code from the mocked // response. - let status: number | undefined = renderOpts.experimental.ppr + let status: number | undefined = renderOpts.experimental.isRoutePPREnabled ? res.statusCode : undefined diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 562e55d6803c0..189b307c28fd0 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -64,7 +64,6 @@ export async function exportAppRoute( notFoundRoutes: [], }, renderOpts: { - experimental: { ppr: false }, originalPathname: page, nextExport: true, supportsDynamicHTML: false, diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 59c7719ac7518..7f973ffae3764 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -83,13 +83,13 @@ async function exportPageImpl( // Check if this is an `app/` page. _isAppDir: isAppDir = false, - // TODO: use this when we've re-enabled app prefetching https://github.com/vercel/next.js/pull/58609 - // // Check if this is an `app/` prefix request. - // _isAppPrefetch: isAppPrefetch = false, - // Check if this should error when dynamic usage is detected. _isDynamicError: isDynamicError = false, + // If this page supports partial prerendering, then we need to pass that to + // the renderOpts. + _isRoutePPREnabled: isRoutePPREnabled, + // Pull the original query out. query: originalQuery = {}, } = pathMap @@ -231,8 +231,7 @@ async function exportPageImpl( distDir, dir, enabledDirectories, - // PPR is not available for Pages. - experimental: { ppr: false }, + isAppPPREnabled: input.renderOpts.experimental.isAppPPREnabled, // skip writing to disk in minimal mode for now, pending some // changes to better support it flushToDisk: !hasNextSupport, @@ -271,6 +270,10 @@ async function exportPageImpl( locale, supportsDynamicHTML: false, originalPathname: page, + experimental: { + ...input.renderOpts.experimental, + isRoutePPREnabled, + }, } if (hasNextSupport) { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 3b92c350d3a40..d5b2043b1e259 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -707,10 +707,29 @@ async function renderToHTMLOrFlightImpl( const isNextExport = !!renderOpts.nextExport const { staticGenerationStore, requestStore } = baseCtx const { isStaticGeneration } = staticGenerationStore - // when static generation fails during PPR, we log the errors separately. We intentionally - // silence the error logger in this case to avoid double logging. - const silenceStaticGenerationErrors = - renderOpts.experimental.ppr && isStaticGeneration + + /** + * Sets the headers on the response object. If we're generating static HTML, + * we store the headers in the metadata object as well so that they can be + * persisted. + */ + const setHeader = isStaticGeneration + ? (name: string, value: string | string[]) => { + res.setHeader(name, value) + + metadata.headers ??= {} + metadata.headers[name] = res.getHeader(name) + + return res + } + : res.setHeader.bind(res) + + const isRoutePPREnabled = renderOpts.experimental.isRoutePPREnabled === true + + // When static generation fails during PPR, we log the errors separately. We + // intentionally silence the error logger in this case to avoid double + // logging. + const silenceStaticGenerationErrors = isRoutePPREnabled && isStaticGeneration const serverComponentsErrorHandler = createErrorHandler({ source: ErrorHandlerSource.serverComponents, @@ -788,7 +807,7 @@ async function renderToHTMLOrFlightImpl( const shouldProvideFlightRouterState = isRSCRequest && (!isPrefetchRSCRequest || - !renderOpts.experimental.ppr || + !isRoutePPREnabled || // Interception routes currently depend on the flight router state to // extract dynamic params. isInterceptionRouteAppPath(pagePath)) @@ -950,28 +969,22 @@ async function renderToHTMLOrFlightImpl( const isResume = !!renderOpts.postponed - const onHeaders = staticGenerationStore.prerenderState - ? // During prerender we write headers to metadata - (headers: Headers) => { - headers.forEach((value, key) => { - metadata.headers ??= {} - metadata.headers[key] = value - }) - } - : isStaticGeneration || isResume - ? // During static generation and during resumes we don't - // ask React to emit headers. For Resume this is just not supported - // For static generation we know there will be an entire HTML document - // output and so moving from tag to header for preloading can only - // server to alter preloading priorities in unwanted ways - undefined - : // During dynamic renders that are not resumes we write - // early headers to the response - (headers: Headers) => { - headers.forEach((value, key) => { - res.appendHeader(key, value) - }) - } + const onHeaders = + // During prerenders, we want to capture the headers created so we can + // persist them to the metadata. + staticGenerationStore.prerenderState || + // During static generation and during resumes we don't + // ask React to emit headers. For Resume this is just not supported + // For static generation we know there will be an entire HTML document + // output and so moving from tag to header for preloading can only + // server to alter preloading priorities in unwanted ways + (!isStaticGeneration && !isResume) + ? (headers: Headers) => { + headers.forEach((value, key) => { + setHeader(key, value) + }) + } + : undefined const getServerInsertedHTML = makeGetServerInsertedHTML({ polyfills, @@ -981,7 +994,7 @@ async function renderToHTMLOrFlightImpl( }) const renderer = createStaticRenderer({ - ppr: renderOpts.experimental.ppr, + isRoutePPREnabled, isStaticGeneration, // If provided, the postpone state should be parsed as JSON so it can be // provided to React. @@ -1086,7 +1099,7 @@ async function renderToHTMLOrFlightImpl( // We postponed but nothing dynamic was used. We resume the render now and immediately abort it // so we can set all the postponed boundaries to client render mode before we store the HTML response const resumeRenderer = createStaticRenderer({ - ppr: true, + isRoutePPREnabled, isStaticGeneration: false, postponed: getDynamicHTMLPostponedState(postponed), streamOptions: { @@ -1232,14 +1245,14 @@ async function renderToHTMLOrFlightImpl( // If there were mutable cookies set, we need to set them on the // response. if (appendMutableCookies(headers, err.mutableCookies)) { - res.setHeader('set-cookie', Array.from(headers.values())) + setHeader('set-cookie', Array.from(headers.values())) } } const redirectUrl = addPathPrefix( getURLFromRedirectError(err), renderOpts.basePath ) - res.setHeader('Location', redirectUrl) + setHeader('Location', redirectUrl) } const is404 = res.statusCode === 404 diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index a86623b46380e..e7a076f0baec4 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -413,7 +413,7 @@ async function createComponentTreeInternal({ // possible during both prefetches and dynamic navigations. But during // the beta period, we should be clear about this trade off in our // communications. - !experimental.ppr + !experimental.isRoutePPREnabled ) { // Don't prefetch this child. This will trigger a lazy fetch by the // client router. diff --git a/packages/next/src/server/app-render/static/static-renderer.ts b/packages/next/src/server/app-render/static/static-renderer.ts index 616f762c68d50..3650ced6fd924 100644 --- a/packages/next/src/server/app-render/static/static-renderer.ts +++ b/packages/next/src/server/app-render/static/static-renderer.ts @@ -111,10 +111,10 @@ export function getDynamicDataPostponedState(): DynamicDataPostponedState { type Options = { /** - * Whether or not PPR is enabled. This is used to determine which renderer to - * use. + * Whether or not PPR is enabled for this page. This is used to determine + * which renderer to use. */ - ppr: boolean + isRoutePPREnabled: boolean /** * Whether or not this is a static generation render. This is used to @@ -138,7 +138,7 @@ type Options = { } export function createStaticRenderer({ - ppr, + isRoutePPREnabled, isStaticGeneration, postponed, streamOptions: { @@ -152,7 +152,7 @@ export function createStaticRenderer({ formState, }, }: Options): Renderer { - if (ppr) { + if (isRoutePPREnabled) { if (isStaticGeneration) { // This is a Prerender return new StaticRenderer({ diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 8740ce034021d..b1287d261049a 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -159,7 +159,16 @@ export interface RenderOptsPartial { params?: ParsedUrlQuery isPrefetch?: boolean experimental: { - ppr: boolean + /** + * When true, some routes support partial prerendering (PPR). + */ + isAppPPREnabled: boolean + + /** + * When true, it indicates that the current page supports partial + * prerendering. + */ + isRoutePPREnabled?: boolean missingSuspenseWithCSRBailout: boolean swrDelta: SwrDelta | undefined } diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index 3a0dd77ea61ec..98bf501a08770 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -113,7 +113,7 @@ export async function walkTreeWithFlightRouterState({ const shouldSkipComponentTree = // loading.tsx has no effect on prefetching when PPR is enabled - !experimental.ppr && + !experimental.isRoutePPREnabled && isPrefetch && !Boolean(components.loading) && (flightRouterState || diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts index bbe9d42e1bb86..bf9fcb863ab98 100644 --- a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts @@ -16,7 +16,7 @@ export type StaticGenerationContext = { fetchCache?: StaticGenerationStore['fetchCache'] isServerAction?: boolean waitUntil?: Promise - experimental: { ppr: boolean; missingSuspenseWithCSRBailout?: boolean } + experimental?: Pick /** * Fetch metrics attached in patch-fetch.ts @@ -77,7 +77,7 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper< !renderOpts.isServerAction const prerenderState: StaticGenerationStore['prerenderState'] = - isStaticGeneration && renderOpts.experimental.ppr + isStaticGeneration && renderOpts.experimental?.isRoutePPREnabled ? createPrerenderState(renderOpts.isDebugPPRSkeleton) : null diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 1883d99687edb..fb8da83c9824b 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -12,7 +12,11 @@ import type { import type { ParsedUrlQuery } from 'querystring' import type { RenderOptsPartial as PagesRenderOptsPartial } from './render' import type { RenderOptsPartial as AppRenderOptsPartial } from './app-render/types' -import type { ResponseCacheBase, ResponseCacheEntry } from './response-cache' +import type { + ResponseCacheBase, + ResponseCacheEntry, + ResponseGenerator, +} from './response-cache' import type { UrlWithParsedQuery } from 'url' import { NormalizeError, @@ -138,6 +142,7 @@ import { toRoute } from './lib/to-route' import type { DeepReadonly } from '../shared/lib/deep-readonly' import { isNodeNextRequest, isNodeNextResponse } from './base-http/helpers' import { patchSetHeaderWithCookieSupport } from './lib/patch-set-header' +import { checkIsAppPPREnabled } from './lib/experimental/ppr' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -472,14 +477,16 @@ export default abstract class Server< this.enabledDirectories = this.getEnabledDirectories(dev) + const isAppPPREnabled = + this.enabledDirectories.app && + checkIsAppPPREnabled(this.nextConfig.experimental.ppr) + this.normalizers = { // We should normalize the pathname from the RSC prefix only in minimal // mode as otherwise that route is not exposed external to the server as // we instead only rely on the headers. postponed: - this.enabledDirectories.app && - this.nextConfig.experimental.ppr && - this.minimalMode + isAppPPREnabled && this.minimalMode ? new PostponedPathnameNormalizer() : undefined, rsc: @@ -487,9 +494,7 @@ export default abstract class Server< ? new RSCPathnameNormalizer() : undefined, prefetchRSC: - this.enabledDirectories.app && - this.nextConfig.experimental.ppr && - this.minimalMode + isAppPPREnabled && this.minimalMode ? new PrefetchRSCPathnameNormalizer() : undefined, data: this.enabledDirectories.pages @@ -549,9 +554,7 @@ export default abstract class Server< // @ts-expect-error internal field not publicly exposed isExperimentalCompile: this.nextConfig.experimental.isExperimentalCompile, experimental: { - ppr: - this.enabledDirectories.app && - this.nextConfig.experimental.ppr === true, + isAppPPREnabled, missingSuspenseWithCSRBailout: this.nextConfig.experimental.missingSuspenseWithCSRBailout === true, swrDelta: this.nextConfig.experimental.swrDelta, @@ -1942,16 +1945,53 @@ export default abstract class Server< // Don't delete headers[RSC] yet, it still needs to be used in renderToHTML later const isRSCRequest = isRSCRequestCheck(req) + const { routeModule } = components + + /** + * If the route being rendered is an app page, and the ppr feature has been + * enabled, then the given route _could_ support PPR. + */ + const couldSupportPPR: boolean = + typeof routeModule !== 'undefined' && + isAppPageRouteModule(routeModule) && + this.renderOpts.experimental.isAppPPREnabled + + // If this is a request that's rendering an app page that support's PPR, + // then if we're in development mode (or using the experimental test + // proxy) and the query parameter is set, then we should render the + // skeleton. We assume that if the page _could_ support it, we should + // show the skeleton in development. Ideally we would check the appConfig + // to see if this page has it enabled or not, but that would require + // plumbing the appConfig through to the server during development. + const isDebugPPRSkeleton = + query.__nextppronly && + couldSupportPPR && + (this.renderOpts.dev || this.experimentalTestProxy) + ? true + : false + + // This page supports PPR if it has `experimentalPPR` set to `true` in the + // prerender manifest and this is an app page. + const isRoutePPREnabled: boolean = + couldSupportPPR && + (( + prerenderManifest.routes[pathname] ?? + prerenderManifest.dynamicRoutes[pathname] + )?.experimentalPPR === true || + isDebugPPRSkeleton) + // If we're in minimal mode, then try to get the postponed information from // the request metadata. If available, use it for resuming the postponed // render. - const minimalPostponed = getRequestMeta(req, 'postponed') + const minimalPostponed = isRoutePPREnabled + ? getRequestMeta(req, 'postponed') + : undefined // If PPR is enabled, and this is a RSC request (but not a prefetch), then // we can use this fact to only generate the flight data for the request // because we can't cache the HTML (as it's also dynamic). const isDynamicRSCRequest = - opts.experimental.ppr && isRSCRequest && !isPrefetchRSCRequest + isRoutePPREnabled && isRSCRequest && !isPrefetchRSCRequest // we need to ensure the status code if /404 is visited directly if (is404Page && !isDataReq && !isRSCRequest) { @@ -1991,9 +2031,9 @@ export default abstract class Server< } } - if (!query.amp) { - delete query.amp - } + // Ensure that if the `amp` query parameter is falsy that we remove it from + // the query object. This ensures it won't be found by the `in` operator. + if ('amp' in query && !query.amp) delete query.amp if (opts.supportsDynamicHTML === true) { const isBotRequest = isBot(req.headers['user-agent'] || '') @@ -2185,10 +2225,9 @@ export default abstract class Server< | 'https', })) + // TODO: investigate, this is not safe across multiple concurrent requests incrementalCache?.resetRequestCache() - const { routeModule } = components - type Renderer = (context: { /** * The postponed data for this render. This is only provided when resuming @@ -2197,14 +2236,6 @@ export default abstract class Server< postponed: string | undefined }) => Promise - // allow debugging the skeleton in dev with PPR - // instead of continuing to resume stream right away - const isDebugPPRSkeleton = Boolean( - this.nextConfig.experimental.ppr && - (this.renderOpts.dev || this.experimentalTestProxy) && - query.__nextppronly - ) - const doRender: Renderer = async ({ postponed }) => { // In development, we always want to generate dynamic HTML. let supportsDynamicHTML: boolean = @@ -2271,7 +2302,10 @@ export default abstract class Server< query: origQuery, }) : resolvedUrl, - + experimental: { + ...opts.experimental, + isRoutePPREnabled, + }, supportsDynamicHTML, isOnDemandRevalidate, isDraftMode: isPreviewMode, @@ -2310,8 +2344,6 @@ export default abstract class Server< params: opts.params, prerenderManifest, renderOpts: { - // App Route's cannot postpone, so don't enable it. - experimental: { ppr: false }, originalPathname: components.ComponentMod.originalPathname, supportsDynamicHTML, incrementalCache, @@ -2451,7 +2483,7 @@ export default abstract class Server< isSSG && metadata.revalidate === 0 && !this.renderOpts.dev && - !renderOpts.experimental.ppr + !isRoutePPREnabled ) { const staticBailoutInfo = metadata.staticBailoutInfo @@ -2510,163 +2542,162 @@ export default abstract class Server< } } - const cacheEntry = await this.responseCache.get( - ssgCacheKey, - async ( - hasResolved, - previousCacheEntry, - isRevalidating - ): Promise => { - const isProduction = !this.renderOpts.dev - const didRespond = hasResolved || res.sent - - if (!staticPaths) { - ;({ staticPaths, fallbackMode } = hasStaticPaths - ? await this.getStaticPaths({ - pathname, - requestHeaders: req.headers, - isAppPath, - page: components.page, - }) - : { staticPaths: undefined, fallbackMode: false }) - } + const responseGenerator: ResponseGenerator = async ( + hasResolved, + previousCacheEntry, + isRevalidating + ): Promise => { + const isProduction = !this.renderOpts.dev + const didRespond = hasResolved || res.sent - if ( - fallbackMode === 'static' && - isBot(req.headers['user-agent'] || '') - ) { - fallbackMode = 'blocking' - } - - // skip on-demand revalidate if cache is not present and - // revalidate-if-generated is set - if ( - isOnDemandRevalidate && - revalidateOnlyGenerated && - !previousCacheEntry && - !this.minimalMode - ) { - await this.render404(req, res) - return null - } - - if (previousCacheEntry?.isStale === -1) { - isOnDemandRevalidate = true - } - - // only allow on-demand revalidate for fallback: true/blocking - // or for prerendered fallback: false paths - if ( - isOnDemandRevalidate && - (fallbackMode !== false || previousCacheEntry) - ) { - fallbackMode = 'blocking' - } + if (!staticPaths) { + ;({ staticPaths, fallbackMode } = hasStaticPaths + ? await this.getStaticPaths({ + pathname, + requestHeaders: req.headers, + isAppPath, + page: components.page, + }) + : { staticPaths: undefined, fallbackMode: false }) + } - // We use `ssgCacheKey` here as it is normalized to match the encoding - // from getStaticPaths along with including the locale. - // - // We use the `resolvedUrlPathname` for the development case when this - // is an app path since it doesn't include locale information. - let staticPathKey = - ssgCacheKey ?? (opts.dev && isAppPath ? resolvedUrlPathname : null) - if (staticPathKey && query.amp) { - staticPathKey = staticPathKey.replace(/\.amp$/, '') - } + if (fallbackMode === 'static' && isBot(req.headers['user-agent'] || '')) { + fallbackMode = 'blocking' + } - const isPageIncludedInStaticPaths = - staticPathKey && staticPaths?.includes(staticPathKey) + // skip on-demand revalidate if cache is not present and + // revalidate-if-generated is set + if ( + isOnDemandRevalidate && + revalidateOnlyGenerated && + !previousCacheEntry && + !this.minimalMode + ) { + await this.render404(req, res) + return null + } - if ((this.nextConfig.experimental as any).isExperimentalCompile) { - fallbackMode = 'blocking' - } + if (previousCacheEntry?.isStale === -1) { + isOnDemandRevalidate = true + } - // When we did not respond from cache, we need to choose to block on - // rendering or return a skeleton. - // - // - Data requests always block. - // - Blocking mode fallback always blocks. - // - Preview mode toggles all pages to be resolved in a blocking manner. - // - Non-dynamic pages should block (though this is an impossible - // case in production). - // - Dynamic pages should return their skeleton if not defined in - // getStaticPaths, then finish the data request on the client-side. - // + // only allow on-demand revalidate for fallback: true/blocking + // or for prerendered fallback: false paths + if ( + isOnDemandRevalidate && + (fallbackMode !== false || previousCacheEntry) + ) { + fallbackMode = 'blocking' + } + + // We use `ssgCacheKey` here as it is normalized to match the encoding + // from getStaticPaths along with including the locale. + // + // We use the `resolvedUrlPathname` for the development case when this + // is an app path since it doesn't include locale information. + let staticPathKey = + ssgCacheKey ?? (opts.dev && isAppPath ? resolvedUrlPathname : null) + if (staticPathKey && query.amp) { + staticPathKey = staticPathKey.replace(/\.amp$/, '') + } + + const isPageIncludedInStaticPaths = + staticPathKey && staticPaths?.includes(staticPathKey) + + if ((this.nextConfig.experimental as any).isExperimentalCompile) { + fallbackMode = 'blocking' + } + + // When we did not respond from cache, we need to choose to block on + // rendering or return a skeleton. + // + // - Data requests always block. + // - Blocking mode fallback always blocks. + // - Preview mode toggles all pages to be resolved in a blocking manner. + // - Non-dynamic pages should block (though this is an impossible + // case in production). + // - Dynamic pages should return their skeleton if not defined in + // getStaticPaths, then finish the data request on the client-side. + // + if ( + process.env.NEXT_RUNTIME !== 'edge' && + !this.minimalMode && + fallbackMode !== 'blocking' && + staticPathKey && + !didRespond && + !isPreviewMode && + isDynamic && + (isProduction || !staticPaths || !isPageIncludedInStaticPaths) + ) { if ( - process.env.NEXT_RUNTIME !== 'edge' && - !this.minimalMode && - fallbackMode !== 'blocking' && - staticPathKey && - !didRespond && - !isPreviewMode && - isDynamic && - (isProduction || !staticPaths || !isPageIncludedInStaticPaths) + // In development, fall through to render to handle missing + // getStaticPaths. + (isProduction || (staticPaths && staticPaths?.length > 0)) && + // When fallback isn't present, abort this render so we 404 + fallbackMode !== 'static' ) { - if ( - // In development, fall through to render to handle missing - // getStaticPaths. - (isProduction || (staticPaths && staticPaths?.length > 0)) && - // When fallback isn't present, abort this render so we 404 - fallbackMode !== 'static' - ) { - throw new NoFallbackError() - } + throw new NoFallbackError() + } - if (!isDataReq) { - // Production already emitted the fallback as static HTML. - if (isProduction) { - const html = await this.getFallback( - locale ? `/${locale}${pathname}` : pathname - ) + if (!isDataReq) { + // Production already emitted the fallback as static HTML. + if (isProduction) { + const html = await this.getFallback( + locale ? `/${locale}${pathname}` : pathname + ) - return { - value: { - kind: 'PAGE', - html: RenderResult.fromStatic(html), - postponed: undefined, - status: undefined, - headers: undefined, - pageData: {}, - }, - } + return { + value: { + kind: 'PAGE', + html: RenderResult.fromStatic(html), + postponed: undefined, + status: undefined, + headers: undefined, + pageData: {}, + }, } - // We need to generate the fallback on-demand for development. - else { - query.__nextFallback = 'true' - - // We pass `undefined` as there cannot be a postponed state in - // development. - const result = await doRender({ postponed: undefined }) - if (!result) { - return null - } - // Prevent caching this result - delete result.revalidate - return result + } + // We need to generate the fallback on-demand for development. + else { + query.__nextFallback = 'true' + + // We pass `undefined` as there cannot be a postponed state in + // development. + const result = await doRender({ postponed: undefined }) + if (!result) { + return null } + // Prevent caching this result + delete result.revalidate + return result } } + } - const result = await doRender({ - // Only requests that aren't revalidating can be resumed. If we have the - // minimal postponed data, then we should resume the render with it. - postponed: - !isOnDemandRevalidate && !isRevalidating && minimalPostponed - ? minimalPostponed - : undefined, - }) - if (!result) { - return null - } + const result = await doRender({ + // Only requests that aren't revalidating can be resumed. If we have the + // minimal postponed data, then we should resume the render with it. + postponed: + !isOnDemandRevalidate && !isRevalidating && minimalPostponed + ? minimalPostponed + : undefined, + }) + if (!result) { + return null + } - return { - ...result, - revalidate: - result.revalidate !== undefined - ? result.revalidate - : /* default to minimum revalidate (this should be an invariant) */ 1, - } - }, + return { + ...result, + revalidate: + result.revalidate !== undefined + ? result.revalidate + : /* default to minimum revalidate (this should be an invariant) */ 1, + } + } + + const cacheEntry = await this.responseCache.get( + ssgCacheKey, + responseGenerator, { routeKind: routeModule?.definition.kind, incrementalCache, @@ -2688,7 +2719,8 @@ export default abstract class Server< } const didPostpone = - cacheEntry.value?.kind === 'PAGE' && !!cacheEntry.value.postponed + cacheEntry.value?.kind === 'PAGE' && + typeof cacheEntry.value.postponed === 'string' if ( isSSG && @@ -2736,7 +2768,7 @@ export default abstract class Server< this.minimalMode && isRSCRequest && !isPrefetchRSCRequest && - opts.experimental.ppr + isRoutePPREnabled ) { revalidate = 0 } else if ( @@ -2897,7 +2929,7 @@ export default abstract class Server< // If the request is a data request, then we shouldn't set the status code // from the response because it should always be 200. This should be gated // behind the experimental PPR flag. - if (cachedData.status && (!isDataReq || !opts.experimental.ppr)) { + if (cachedData.status && (!isDataReq || !isRoutePPREnabled)) { res.statusCode = cachedData.status } diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index fa3420a8bd762..3350e58c0fcad 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -35,8 +35,8 @@ const zExportMap: zod.ZodType = z.record( query: z.any(), // NextParsedUrlQuery // private optional properties _isAppDir: z.boolean().optional(), - _isAppPrefetch: z.boolean().optional(), _isDynamicError: z.boolean().optional(), + _isRoutePPREnabled: z.boolean().optional(), }) ) @@ -313,7 +313,10 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), parallelServerCompiles: z.boolean().optional(), parallelServerBuildTraces: z.boolean().optional(), - ppr: z.boolean().optional(), + ppr: z + .union([z.boolean(), z.literal('incremental')]) + .readonly() + .optional(), taint: z.boolean().optional(), prerenderEarlyExit: z.boolean().optional(), proxyTimeout: z.number().gte(0).optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 5bfba3d610b8f..7e34356e928a0 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -12,6 +12,7 @@ import type { NextParsedUrlQuery } from './request-meta' import type { SizeLimit } from '../types' import type { SwrDelta } from './lib/revalidate' import type { SupportedTestRunners } from '../cli/next-test' +import type { ExperimentalPPRConfig } from './lib/experimental/ppr' export type NextConfigComplete = Required & { images: Required @@ -382,7 +383,7 @@ export interface ExperimentalConfig { /** * Using this feature will enable the `react@experimental` for the `app` directory. */ - ppr?: boolean + ppr?: ExperimentalPPRConfig /** * Enables experimental taint APIs in React. @@ -466,9 +467,21 @@ export type ExportPathMap = { [path: string]: { page: string query?: NextParsedUrlQuery + + /** + * @internal + */ _isAppDir?: boolean - _isAppPrefetch?: boolean + + /** + * @internal + */ _isDynamicError?: boolean + + /** + * @internal + */ + _isRoutePPREnabled?: boolean } } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 14016fabc6d1c..86f9537c851ae 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -715,7 +715,6 @@ export default class DevServer extends Server { fetchCacheKeyPrefix: this.nextConfig.experimental.fetchCacheKeyPrefix, isrFlushToDisk: this.nextConfig.experimental.isrFlushToDisk, maxMemoryCacheSize: this.nextConfig.cacheMaxMemorySize, - ppr: 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 4d0d83064c0bf..4c5bd3e9ca126 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -38,7 +38,6 @@ export async function loadStaticPaths({ maxMemoryCacheSize, requestHeaders, cacheHandler, - ppr, }: { dir: string distDir: string @@ -54,7 +53,6 @@ export async function loadStaticPaths({ maxMemoryCacheSize?: number requestHeaders: IncrementalCache['requestHeaders'] cacheHandler?: string - ppr: boolean }): Promise<{ paths?: string[] encodedPaths?: string[] @@ -109,7 +107,6 @@ export async function loadStaticPaths({ isrFlushToDisk, fetchCacheKeyPrefix, maxMemoryCacheSize, - ppr, ComponentMod: components.ComponentMod, }) } diff --git a/packages/next/src/server/lib/experimental/ppr.ts b/packages/next/src/server/lib/experimental/ppr.ts new file mode 100644 index 0000000000000..5db6789a8128f --- /dev/null +++ b/packages/next/src/server/lib/experimental/ppr.ts @@ -0,0 +1,61 @@ +/** + * If set to `incremental`, only those leaf pages that export + * `experimental_ppr = true` will have partial prerendering enabled. If any + * page exports this value as `false` or does not export it at all will not + * have partial prerendering enabled. If set to a boolean, it the options for + * `experimental_ppr` will be ignored. + */ + +export type ExperimentalPPRConfig = boolean | 'incremental' + +/** + * Returns true if partial prerendering is enabled for the application. It does + * not tell you if a given route has PPR enabled, as that requires analysis of + * the route's configuration. + * + * @see {@link checkIsRoutePPREnabled} - for checking if a specific route has PPR enabled. + */ +export function checkIsAppPPREnabled( + config: ExperimentalPPRConfig | undefined +): boolean { + // If the config is undefined, partial prerendering is disabled. + if (typeof config === 'undefined') return false + + // If the config is a boolean, use it directly. + if (typeof config === 'boolean') return config + + // If the config is a string, it must be 'incremental' to enable partial + // prerendering. + if (config === 'incremental') return true + + return false +} + +/** + * Returns true if partial prerendering is supported for the current page with + * the provided app configuration. If the application doesn't have partial + * prerendering enabled, this function will always return false. If you want to + * check if the application has partial prerendering enabled + * + * @see {@link checkIsAppPPREnabled} for checking if the application has PPR enabled. + */ +export function checkIsRoutePPREnabled( + config: ExperimentalPPRConfig | undefined, + appConfig: { + experimental_ppr?: boolean + } +): boolean { + // If the config is undefined, partial prerendering is disabled. + if (typeof config === 'undefined') return false + + // If the config is a boolean, use it directly. + if (typeof config === 'boolean') return config + + // If the config is a string, it must be 'incremental' to enable partial + // prerendering. + if (config === 'incremental' && appConfig.experimental_ppr === true) { + return true + } + + return false +} diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index c1a7c3ef5bf20..fa28e86b45f11 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -19,7 +19,12 @@ type FileSystemCacheContext = Omit< > & { fs: CacheFs serverDistDir: string - experimental: { ppr: boolean } + + /** + * isAppPPREnabled is true when PPR has been enabled either globally or just for + * some pages via the `incremental` option. + */ + isAppPPREnabled: boolean } type TagsManifest = { @@ -37,7 +42,7 @@ export default class FileSystemCache implements CacheHandler { private pagesDir: boolean private tagsManifestPath?: string private revalidatedTags: string[] - private readonly experimental: { ppr: boolean } + private readonly isAppPPREnabled: boolean private debug: boolean constructor(ctx: FileSystemCacheContext) { @@ -47,7 +52,7 @@ export default class FileSystemCache implements CacheHandler { this.appDir = !!ctx._appDir this.pagesDir = !!ctx._pagesDir this.revalidatedTags = ctx.revalidatedTags - this.experimental = ctx.experimental + this.isAppPPREnabled = ctx.isAppPPREnabled this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE if (ctx.maxMemoryCacheSize && !memoryCache) { @@ -226,7 +231,7 @@ export default class FileSystemCache implements CacheHandler { ? await this.fs.readFile( this.getFilePath( `${key}${ - this.experimental.ppr ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX + this.isAppPPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX }`, 'app' ), @@ -370,7 +375,7 @@ export default class FileSystemCache implements CacheHandler { this.getFilePath( `${key}${ isAppPath - ? this.experimental.ppr + ? this.isAppPPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX : NEXT_DATA_SUFFIX diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 021ec29344d33..81df6ff31065a 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -31,7 +31,7 @@ export interface CacheHandlerContext { fetchCacheKeyPrefix?: string prerenderManifest?: PrerenderManifest revalidatedTags: string[] - experimental: { ppr: boolean } + isAppPPREnabled?: boolean _appDir: boolean _pagesDir: boolean _requestHeaders: IncrementalCache['requestHeaders'] @@ -104,7 +104,7 @@ export class IncrementalCache implements IncrementalCacheType { fetchCacheKeyPrefix, CurCacheHandler, allowedRevalidateHeaderKeys, - experimental, + isAppPPREnabled, }: { fs?: CacheFs dev: boolean @@ -121,7 +121,7 @@ export class IncrementalCache implements IncrementalCacheType { getPrerenderManifest: () => DeepReadonly fetchCacheKeyPrefix?: string CurCacheHandler?: typeof CacheHandler - experimental: { ppr: boolean } + isAppPPREnabled: boolean }) { const debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE this.hasCustomCacheHandler = Boolean(CurCacheHandler) @@ -193,7 +193,7 @@ export class IncrementalCache implements IncrementalCacheType { _appDir: !!appDir, _requestHeaders: requestHeaders, fetchCacheKeyPrefix, - experimental, + isAppPPREnabled, }) } } @@ -429,7 +429,7 @@ export class IncrementalCache implements IncrementalCacheType { cacheKey: string, ctx: { kindHint?: IncrementalCacheKindHint - revalidate?: number | false + revalidate?: Revalidate fetchUrl?: string fetchIdx?: number tags?: string[] @@ -551,7 +551,7 @@ export class IncrementalCache implements IncrementalCacheType { pathname: string, data: IncrementalCacheValue | null, ctx: { - revalidate?: number | false + revalidate?: Revalidate fetchCache?: boolean fetchUrl?: string fetchIdx?: number diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 8f6f3e5f8cd60..ad96b43ef48da 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -391,7 +391,7 @@ export default class NextNodeServer extends BaseServer< !this.minimalMode && this.nextConfig.experimental.isrFlushToDisk, getPrerenderManifest: () => this.getPrerenderManifest(), CurCacheHandler: CacheHandler, - experimental: this.renderOpts.experimental, + isAppPPREnabled: this.renderOpts.experimental.isAppPPREnabled, }) } diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 2419ed5477c34..2e41ea854e19b 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -93,8 +93,8 @@ export default class NextWebServer extends BaseServer< CurCacheHandler: this.serverOptions.webServerConfig.incrementalCacheHandler, getPrerenderManifest: () => this.getPrerenderManifest(), - // PPR is not supported in the edge runtime. - experimental: { ppr: false }, + // PPR is not supported in the Edge runtime. + isAppPPREnabled: false, }) } protected getResponseCache() { diff --git a/packages/next/src/server/web/edge-route-module-wrapper.ts b/packages/next/src/server/web/edge-route-module-wrapper.ts index 68a14fc80d0e2..acd2c57b32816 100644 --- a/packages/next/src/server/web/edge-route-module-wrapper.ts +++ b/packages/next/src/server/web/edge-route-module-wrapper.ts @@ -104,8 +104,6 @@ export class EdgeRouteModuleWrapper { }, renderOpts: { supportsDynamicHTML: true, - // App Route's cannot be postponed. - experimental: { ppr: false }, }, } diff --git a/test/e2e/app-dir/ppr-full/ppr-full.test.ts b/test/e2e/app-dir/ppr-full/ppr-full.test.ts index 18eff0dff5265..5d583e65117c4 100644 --- a/test/e2e/app-dir/ppr-full/ppr-full.test.ts +++ b/test/e2e/app-dir/ppr-full/ppr-full.test.ts @@ -317,6 +317,7 @@ describe('ppr-full', () => { if (signal === 'redirect()') { const location = res.headers.get('location') + expect(location).not.toBeNull() expect(typeof location).toEqual('string') // The URL returned in `Location` is absolute, so we need to parse it diff --git a/test/e2e/app-dir/ppr-incremental/app/disabled/page.jsx b/test/e2e/app-dir/ppr-incremental/app/disabled/page.jsx new file mode 100644 index 0000000000000..8392b5a308c99 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/disabled/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../lib/page' +export const experimental_ppr = false diff --git a/test/e2e/app-dir/ppr-incremental/app/dynamic/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/dynamic/[slug]/page.jsx new file mode 100644 index 0000000000000..4289a943119a4 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/dynamic/[slug]/page.jsx @@ -0,0 +1 @@ +export { default } from '../../../lib/page' diff --git a/test/e2e/app-dir/ppr-incremental/app/dynamic/disabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/dynamic/disabled/[slug]/page.jsx new file mode 100644 index 0000000000000..f8c79f1161346 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/dynamic/disabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../lib/page' +export const experimental_ppr = false diff --git a/test/e2e/app-dir/ppr-incremental/app/dynamic/enabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/dynamic/enabled/[slug]/page.jsx new file mode 100644 index 0000000000000..ceb3c03ca6ac6 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/dynamic/enabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../lib/page' +export const experimental_ppr = true diff --git a/test/e2e/app-dir/ppr-incremental/app/enabled/page.jsx b/test/e2e/app-dir/ppr-incremental/app/enabled/page.jsx new file mode 100644 index 0000000000000..dd11b10cc2362 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/enabled/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../lib/page' +export const experimental_ppr = true diff --git a/test/e2e/app-dir/ppr-incremental/app/layout.jsx b/test/e2e/app-dir/ppr-incremental/app/layout.jsx new file mode 100644 index 0000000000000..4ee00a218505a --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/layout.jsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/disabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/[slug]/page.jsx new file mode 100644 index 0000000000000..1caf61a9cde3e --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/[slug]/page.jsx @@ -0,0 +1 @@ +export { default } from '../../../../lib/page' diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/disabled/disabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/disabled/[slug]/page.jsx new file mode 100644 index 0000000000000..60f8d726a343a --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/disabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../../lib/page' +export const experimental_ppr = false diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/disabled/enabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/enabled/[slug]/page.jsx new file mode 100644 index 0000000000000..3f8212e203117 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/enabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../../lib/page' +export const experimental_ppr = true diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/disabled/layout.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/layout.jsx new file mode 100644 index 0000000000000..9ecbd4926f98e --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/disabled/layout.jsx @@ -0,0 +1,5 @@ +export const experimental_ppr = false + +export default function Layout({ children }) { + return children +} diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/enabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/[slug]/page.jsx new file mode 100644 index 0000000000000..1caf61a9cde3e --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/[slug]/page.jsx @@ -0,0 +1 @@ +export { default } from '../../../../lib/page' diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/enabled/disabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/disabled/[slug]/page.jsx new file mode 100644 index 0000000000000..60f8d726a343a --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/disabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../../lib/page' +export const experimental_ppr = false diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/enabled/enabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/enabled/[slug]/page.jsx new file mode 100644 index 0000000000000..3f8212e203117 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/enabled/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default } from '../../../../../lib/page' +export const experimental_ppr = true diff --git a/test/e2e/app-dir/ppr-incremental/app/nested/enabled/layout.jsx b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/layout.jsx new file mode 100644 index 0000000000000..2b910640eaa90 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/nested/enabled/layout.jsx @@ -0,0 +1,5 @@ +export const experimental_ppr = true + +export default function Layout({ children }) { + return children +} diff --git a/test/e2e/app-dir/ppr-incremental/app/omitted/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/omitted/[slug]/page.jsx new file mode 100644 index 0000000000000..a5a689cf94ebf --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/omitted/[slug]/page.jsx @@ -0,0 +1,2 @@ +export { default, generateStaticParams } from '../../../lib/page' +export const dynamicParams = false diff --git a/test/e2e/app-dir/ppr-incremental/app/omitted/disabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/omitted/disabled/[slug]/page.jsx new file mode 100644 index 0000000000000..159def7e20d1d --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/omitted/disabled/[slug]/page.jsx @@ -0,0 +1,3 @@ +export { default, generateStaticParams } from '../../../../lib/page' +export const experimental_ppr = false +export const dynamicParams = false diff --git a/test/e2e/app-dir/ppr-incremental/app/omitted/enabled/[slug]/page.jsx b/test/e2e/app-dir/ppr-incremental/app/omitted/enabled/[slug]/page.jsx new file mode 100644 index 0000000000000..e57bf67d6b9a0 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/omitted/enabled/[slug]/page.jsx @@ -0,0 +1,3 @@ +export { default, generateStaticParams } from '../../../../lib/page' +export const experimental_ppr = true +export const dynamicParams = false diff --git a/test/e2e/app-dir/ppr-incremental/app/page.jsx b/test/e2e/app-dir/ppr-incremental/app/page.jsx new file mode 100644 index 0000000000000..6db682e021aa0 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/app/page.jsx @@ -0,0 +1 @@ +export { default } from '../lib/page' diff --git a/test/e2e/app-dir/ppr-incremental/lib/page.jsx b/test/e2e/app-dir/ppr-incremental/lib/page.jsx new file mode 100644 index 0000000000000..c2777fbcd4874 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/lib/page.jsx @@ -0,0 +1,29 @@ +import { unstable_noStore } from 'next/cache' +import Link from 'next/link' +import { Suspense } from 'react' + +function Dynamic() { + unstable_noStore() + return
Dynamic
+} + +export default function Page() { + return ( + <> +
  • + Root + Enabled + Disabled +
  • + Loading...}> + + + + ) +} + +export const slugs = ['a', 'b', 'c'] + +export const generateStaticParams = () => { + return slugs.map((slug) => ({ slug })) +} diff --git a/test/e2e/app-dir/ppr-incremental/next.config.js b/test/e2e/app-dir/ppr-incremental/next.config.js new file mode 100644 index 0000000000000..4120ad23231ea --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +module.exports = { + experimental: { + ppr: 'incremental', + }, +} diff --git a/test/e2e/app-dir/ppr-incremental/ppr-incremental.test.ts b/test/e2e/app-dir/ppr-incremental/ppr-incremental.test.ts new file mode 100644 index 0000000000000..deb5b7e2f2315 --- /dev/null +++ b/test/e2e/app-dir/ppr-incremental/ppr-incremental.test.ts @@ -0,0 +1,177 @@ +import { nextTestSetup, isNextDev } from 'e2e-utils' + +type Route = { + route: string + enabled: boolean + pathnames: string[] +} + +const routes: ReadonlyArray = [ + { + route: '/', + pathnames: ['/'], + enabled: false, + }, + { + route: '/disabled', + pathnames: ['/disabled'], + enabled: false, + }, + { + route: '/enabled', + pathnames: ['/enabled'], + enabled: true, + }, + { + route: '/omitted/[slug]', + pathnames: ['/omitted/a', '/omitted/b', '/omitted/c'], + enabled: false, + }, + { + route: '/omitted/disabled/[slug]', + pathnames: [ + '/omitted/disabled/a', + '/omitted/disabled/b', + '/omitted/disabled/c', + ], + enabled: false, + }, + { + route: '/omitted/enabled/[slug]', + pathnames: [ + '/omitted/enabled/a', + '/omitted/enabled/b', + '/omitted/enabled/c', + ], + enabled: true, + }, + { + route: '/dynamic/[slug]', + pathnames: ['/dynamic/a', '/dynamic/b', '/dynamic/c'], + enabled: false, + }, + { + route: '/dynamic/disabled/[slug]', + pathnames: [ + '/dynamic/disabled/a', + '/dynamic/disabled/b', + '/dynamic/disabled/c', + ], + enabled: false, + }, + { + route: '/dynamic/enabled/[slug]', + pathnames: [ + '/dynamic/enabled/a', + '/dynamic/enabled/b', + '/dynamic/enabled/c', + ], + enabled: true, + }, + { + route: '/nested/enabled/[slug]', + pathnames: ['/nested/enabled/a', '/nested/enabled/b', '/nested/enabled/c'], + enabled: true, + }, + { + route: '/nested/enabled/disabled/[slug]', + pathnames: [ + '/nested/enabled/disabled/a', + '/nested/enabled/disabled/b', + '/nested/enabled/disabled/c', + ], + enabled: false, + }, + { + route: '/nested/enabled/enabled/[slug]', + pathnames: [ + '/nested/enabled/enabled/a', + '/nested/enabled/enabled/b', + '/nested/enabled/enabled/c', + ], + enabled: true, + }, + { + route: '/nested/disabled/[slug]', + pathnames: [ + '/nested/disabled/a', + '/nested/disabled/b', + '/nested/disabled/c', + ], + enabled: false, + }, + { + route: '/nested/disabled/disabled/[slug]', + pathnames: [ + '/nested/disabled/disabled/a', + '/nested/disabled/disabled/b', + '/nested/disabled/disabled/c', + ], + enabled: false, + }, + { + route: '/nested/disabled/enabled/[slug]', + pathnames: [ + '/nested/disabled/enabled/a', + '/nested/disabled/enabled/b', + '/nested/disabled/enabled/c', + ], + enabled: true, + }, +] + +describe('ppr-incremental', () => { + // We don't perform static builds and partial prerendering in development + // mode. + if (isNextDev) return it.skip('should skip next dev', () => {}) + + const { next } = nextTestSetup({ files: __dirname }) + + describe('ppr disabled', () => { + describe.each(routes.filter(({ enabled }) => !enabled))( + '$route', + ({ pathnames }) => { + // When PPR is disabled, we won't include the fallback in the initial + // load because the dynamic render will not suspend. + describe('should render without the fallback in the initial load', () => { + it.each(pathnames)('%s', async (pathname) => { + const $ = await next.render$(pathname) + expect($('#fallback')).toHaveLength(0) + }) + }) + + describe('should not have the dynamic content hidden', () => { + it.each(pathnames)('%s', async (pathname) => { + const $ = await next.render$(pathname) + expect($('#dynamic')).toHaveLength(1) + expect($('#dynamic').parent('[hidden]')).toHaveLength(0) + }) + }) + } + ) + }) + + describe('ppr enabled', () => { + describe.each(routes.filter(({ enabled }) => enabled))( + '$route', + ({ pathnames }) => { + // When PPR is enabled, we will always include the fallback in the + // initial load because the dynamic component uses `unstable_noStore()`. + describe('should render with the fallback in the initial load', () => { + it.each(pathnames)('%s', async (pathname) => { + const $ = await next.render$(pathname) + expect($('#fallback')).toHaveLength(1) + }) + }) + + describe('should have the dynamic content hidden', () => { + it.each(pathnames)('%s', async (pathname) => { + const $ = await next.render$(pathname) + expect($('#dynamic')).toHaveLength(1) + expect($('#dynamic').parent('[hidden]')).toHaveLength(1) + }) + }) + } + ) + }) +}) diff --git a/test/unit/incremental-cache/file-system-cache.test.ts b/test/unit/incremental-cache/file-system-cache.test.ts index 7e22c3eac76f2..34267dc6a15e7 100644 --- a/test/unit/incremental-cache/file-system-cache.test.ts +++ b/test/unit/incremental-cache/file-system-cache.test.ts @@ -15,9 +15,7 @@ describe('FileSystemCache', () => { fs: nodeFs, serverDistDir: cacheDir, revalidatedTags: [], - experimental: { - ppr: false, - }, + isAppPPREnabled: false, }) const binary = await fs.readFile( @@ -37,7 +35,7 @@ describe('FileSystemCache', () => { {} ) - expect((await fsCache.get('icon.png')).value).toEqual({ + expect((await fsCache.get('icon.png'))?.value).toEqual({ body: binary, headers: { 'Content-Type': 'image/png', @@ -57,9 +55,7 @@ describe('FileSystemCache (isrMemory 0)', () => { fs: nodeFs, serverDistDir: cacheDir, revalidatedTags: [], - experimental: { - ppr: false, - }, + isAppPPREnabled: false, maxMemoryCacheSize: 0, // disable memory cache }) @@ -90,7 +86,7 @@ describe('FileSystemCache (isrMemory 0)', () => { kindHint: 'fetch', }) - expect(res.value).toEqual({ + expect(res?.value).toEqual({ kind: 'FETCH', data: { headers: {}, @@ -119,7 +115,7 @@ describe('FileSystemCache (isrMemory 0)', () => { kindHint: 'fetch', }) - expect(res.value).toEqual({ + expect(res?.value).toEqual({ kind: 'FETCH', data: { headers: {}, body: '1700056381', status: 200, url: '' }, revalidate: 30,