From cdb415451a77998a9c4a4e3d7eb3b348ec306608 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 May 2024 14:55:00 -0600 Subject: [PATCH] [PPR] Enable incremental adoption (#63847) Enabling Partial Prerendering (PPR) for an entire application is ideally, the goal for teams wanting to test out the feature or adopt it in their applications to get ready for when it becomes the default rendering pattern. For large applications, with many routes the new behaviours of old API's may prove a difficult pill to swallow all at once. This aims to enable incremental adoption of PPR for pages and routes that want to support it in a similar way to how existing segment-level configurations. Segments can now add: ```ts export const experimental_ppr = true ``` To enable PPR for that segment and those descending segments. Any subset of those routes that have it enabled can add: ```ts export const experimental_ppr = false ```
An aside on the choice of experimental_ppr name

It is against common JS semantics to use snake-case, and preference is given to camel-case instead. The choice to make this snake-case was to re-enforce that this is an experimental feature, an ugly incremental path, and ideally, developers should aim to remove all references of it from their codebase.

Additionally, this mirrors what we've done for unstable API's like `unstable_cache`.

To disable PPR for that segment and those descending segments. To use this new option, the `experimental.ppr` configuration in `next.config.js` must be set to `"incremental"`: ```js // next.config.js module.exports = { experimental: { ppr: "incremental", }, } ``` If a segment does not export a `experimental_ppr` boolean, it is inferred from it's parent. If no parent has it defined, it's default value is `false` and therefore disabled. Once all your segments have PPR enabled via this config, it would be considered safe for teams to set their `experimental.ppr` value in the `next.config.js` to `true`, enabling it for the entire app and for all future routes. ### Aside I also took the liberty to rename `isPPR` and `supportsPPR` to be the clearer `isAppPPREnabled` and `isRoutePPREnabled`. --------- Co-authored-by: Hendrik Liebau --- .../next-core/src/app_segment_config.rs | 18 + .../crates/next-core/src/next_config.rs | 78 +++- .../build/analysis/get-page-static-info.ts | 2 +- packages/next/src/build/index.ts | 66 ++- packages/next/src/build/utils.ts | 77 ++-- .../webpack/plugins/define-env-plugin.ts | 3 +- .../plugins/next-types-plugin/index.ts | 1 + .../helpers/create-incremental-cache.ts | 6 +- packages/next/src/export/index.ts | 3 +- packages/next/src/export/routes/app-page.ts | 16 +- packages/next/src/export/routes/app-route.ts | 1 - packages/next/src/export/worker.ts | 15 +- .../next/src/server/app-render/app-render.tsx | 75 ++-- .../app-render/create-component-tree.tsx | 2 +- .../app-render/static/static-renderer.ts | 10 +- packages/next/src/server/app-render/types.ts | 11 +- .../walk-tree-with-flight-router-state.tsx | 2 +- ...static-generation-async-storage-wrapper.ts | 4 +- packages/next/src/server/base-server.ts | 380 ++++++++++-------- packages/next/src/server/config-schema.ts | 7 +- packages/next/src/server/config-shared.ts | 17 +- .../next/src/server/dev/next-dev-server.ts | 1 - .../src/server/dev/static-paths-worker.ts | 3 - .../next/src/server/lib/experimental/ppr.ts | 61 +++ .../incremental-cache/file-system-cache.ts | 15 +- .../src/server/lib/incremental-cache/index.ts | 12 +- packages/next/src/server/next-server.ts | 2 +- packages/next/src/server/web-server.ts | 4 +- .../server/web/edge-route-module-wrapper.ts | 2 - test/e2e/app-dir/ppr-full/ppr-full.test.ts | 1 + .../ppr-incremental/app/disabled/page.jsx | 2 + .../app/dynamic/[slug]/page.jsx | 1 + .../app/dynamic/disabled/[slug]/page.jsx | 2 + .../app/dynamic/enabled/[slug]/page.jsx | 2 + .../ppr-incremental/app/enabled/page.jsx | 2 + .../app-dir/ppr-incremental/app/layout.jsx | 7 + .../app/nested/disabled/[slug]/page.jsx | 1 + .../nested/disabled/disabled/[slug]/page.jsx | 2 + .../nested/disabled/enabled/[slug]/page.jsx | 2 + .../app/nested/disabled/layout.jsx | 5 + .../app/nested/enabled/[slug]/page.jsx | 1 + .../nested/enabled/disabled/[slug]/page.jsx | 2 + .../nested/enabled/enabled/[slug]/page.jsx | 2 + .../app/nested/enabled/layout.jsx | 5 + .../app/omitted/[slug]/page.jsx | 2 + .../app/omitted/disabled/[slug]/page.jsx | 3 + .../app/omitted/enabled/[slug]/page.jsx | 3 + test/e2e/app-dir/ppr-incremental/app/page.jsx | 1 + test/e2e/app-dir/ppr-incremental/lib/page.jsx | 29 ++ .../app-dir/ppr-incremental/next.config.js | 8 + .../ppr-incremental/ppr-incremental.test.ts | 177 ++++++++ .../file-system-cache.test.ts | 14 +- 52 files changed, 823 insertions(+), 345 deletions(-) create mode 100644 packages/next/src/server/lib/experimental/ppr.ts create mode 100644 test/e2e/app-dir/ppr-incremental/app/disabled/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/dynamic/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/dynamic/disabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/dynamic/enabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/enabled/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/layout.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/disabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/disabled/disabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/disabled/enabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/disabled/layout.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/enabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/enabled/disabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/enabled/enabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/nested/enabled/layout.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/omitted/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/omitted/disabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/omitted/enabled/[slug]/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/app/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/lib/page.jsx create mode 100644 test/e2e/app-dir/ppr-incremental/next.config.js create mode 100644 test/e2e/app-dir/ppr-incremental/ppr-incremental.test.ts 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,