Skip to content

Commit

Permalink
[PPR] Enable incremental adoption (#63847)
Browse files Browse the repository at this point in the history
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
```

<details>
<summary>An aside on the choice of <code>experimental_ppr</code>
name</summary>
<blockquote>
<p>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.</p>
<p>Additionally, this mirrors what we've done for unstable API's like
`unstable_cache`.</p>
</blockquote>
</details> 

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 <mail@hendrik-liebau.de>
  • Loading branch information
wyattjoh and unstubbable authored May 6, 2024
1 parent 53c0843 commit cdb4154
Show file tree
Hide file tree
Showing 52 changed files with 823 additions and 345 deletions.
18 changes: 18 additions & 0 deletions packages/next-swc/crates/next-core/src/app_segment_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub struct NextSegmentConfig {
pub fetch_cache: Option<NextSegmentFetchCache>,
pub runtime: Option<NextRuntime>,
pub preferred_region: Option<Vec<String>>,
pub experimental_ppr: Option<bool>,
}

#[turbo_tasks::value_impl]
Expand All @@ -93,13 +94,15 @@ impl NextSegmentConfig {
fetch_cache,
runtime,
preferred_region,
experimental_ppr,
} = self;
*dynamic = dynamic.or(parent.dynamic);
*dynamic_params = dynamic_params.or(parent.dynamic_params);
*revalidate = revalidate.or(parent.revalidate);
*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
Expand Down Expand Up @@ -133,6 +136,7 @@ impl NextSegmentConfig {
fetch_cache,
runtime,
preferred_region,
experimental_ppr,
} = self;
merge_parallel(dynamic, &parallel_config.dynamic, "dynamic")?;
merge_parallel(
Expand All @@ -148,6 +152,11 @@ impl NextSegmentConfig {
&parallel_config.preferred_region,
"referredRegion",
)?;
merge_parallel(
experimental_ppr,
&parallel_config.experimental_ppr,
"experimental_ppr",
)?;
Ok(())
}
}
Expand Down Expand Up @@ -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);
}
_ => {}
}
}
Expand Down
78 changes: 76 additions & 2 deletions packages/next-swc/crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ pub struct ExperimentalConfig {
output_file_tracing_root: Option<String>,
/// Using this feature will enable the `react@experimental` for the `app`
/// directory.
ppr: Option<bool>,
ppr: Option<ExperimentalPartialPrerendering>,
taint: Option<bool>,
proxy_timeout: Option<f64>,
/// enables the minification of server code.
Expand All @@ -542,6 +542,49 @@ pub struct ExperimentalConfig {
worker_threads: Option<bool>,
}

#[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::<ExperimentalConfig>(json);
assert!(config.is_err());
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)]
#[serde(rename_all = "camelCase")]
pub struct SubResourceIntegrity {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -934,7 +996,19 @@ impl NextConfig {

#[turbo_tasks::function]
pub async fn enable_ppr(self: Vc<Self>) -> Result<Vc<bool>> {
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]
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/build/analysis/get-page-static-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) {

export function getMiddlewareMatchers(
matcherOrMatchers: unknown,
nextConfig: NextConfig
nextConfig: Pick<NextConfig, 'basePath' | 'i18n'>
): MiddlewareMatcher[] {
let matchers: unknown[] = []
if (Array.isArray(matcherOrMatchers)) {
Expand Down
66 changes: 24 additions & 42 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -1740,7 +1744,6 @@ export default async function build(
const additionalSsgPaths = new Map<string, Array<string>>()
const additionalSsgPathsEncoded = new Map<string, Array<string>>()
const appStaticPaths = new Map<string, Array<string>>()
const appPrefetchPaths = new Map<string, string>()
const appStaticPathsEncoded = new Map<string, Array<string>>()
const appNormalizedPaths = new Map<string, string>()
const appDynamicParamPaths = new Set<string>()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
)
Expand All @@ -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

Expand Down Expand Up @@ -2174,15 +2177,14 @@ export default async function build(
])
isStatic = true
} else if (
isDynamic &&
!hasGenerateStaticParams &&
(appConfig.dynamic === 'error' ||
appConfig.dynamic === 'force-static')
) {
appStaticPaths.set(originalAppPath, [])
appStaticPathsEncoded.set(originalAppPath, [])
isStatic = true
isPPR = false
isRoutePPREnabled = false
}
}
}
Expand All @@ -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)) {
Expand Down Expand Up @@ -2328,7 +2318,7 @@ export default async function build(
totalSize,
isStatic,
isSSG,
isPPR,
isRoutePPREnabled,
isHybridAmp,
ssgPageRoutes,
initialRevalidateSeconds: false,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2682,6 +2662,7 @@ export default async function build(
}
}
}

return defaultMap
},
}
Expand Down Expand Up @@ -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

Expand Down
Loading

0 comments on commit cdb4154

Please sign in to comment.