Skip to content

Commit

Permalink
Partial Fallback Prerendering (#68958)
Browse files Browse the repository at this point in the history
This work introduces the new concept of **Partial Fallback Prerendering
(PFPR)**.

Traditionally, when a dynamic page needed to be routed to that wasn't
pregenerated, it required a render to generate even the first few bytes
of the static page itself. This resulted in slow page loads for pages
not frequently visited and a reduced Time to First Byte (TTFB) score on
Core Web Vitals (CWV).

PFPR takes advantage of the new systems of Partial Prerendering (PPR)
that allows the application to suspend at different points mid-render,
and resume it later. We mark any unknown parameter access as dynamic
access, and suspend the rendering up to the next suspense boundaries at
those points. Under ideal conditions (correctly placed `<Suspense />`
boundaries or `loading.jsx` files) this generates a static shell that
can be served to users as soon as the request hits Next.js, right out of
the static cache. This minimizes the TTFB for all requests, dynamic or
not for those pages that enable PPR. For example, the following page
would create a usable shell:

```jsx
// /app/users/[userID]/page.jsx
import { Suspense } from 'react'

function Profile({ params }) {
  const { userID } = params
  return <div>Hello {userID}!</div>
}

export default function ProfilePage({ params }) {
  return (
    <div>
      <h1>User Profile</h1>
      <Suspense fallback="Loading...">
        <Profile params={params} />
      </Suspense>
    </div>
  )
}
```

Due to the way that suspense works within React components, access of
params within the root page component would cause the whole page to
suspend. Thankfully, that's where the `loading.jsx` comes in handy.
Adding a `loading.jsx` at a segment will automatically wrap the
`page.jsx` with a suspense boundary, setting the contents of the root
`loading.jsx` as the fallback component to use for it. This lets you
maintain your existing style of accessing parameters at the root of the
components while also taking advantage of PFPR.

To enable this feature, you first need to enable both PPR and PFPR:

```js
module.exports = {
  experimental: {
    ppr: true,
    pprFallbacks: true,
  }
}
```

Once PFPR has stabilized with hosting providers, the experimental flag
will go away and it will become the default with the PPR flag.
  • Loading branch information
wyattjoh committed Aug 28, 2024
1 parent f149d08 commit f4c1a40
Show file tree
Hide file tree
Showing 85 changed files with 1,906 additions and 560 deletions.
402 changes: 239 additions & 163 deletions packages/next/src/build/index.ts

Large diffs are not rendered by default.

201 changes: 148 additions & 53 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ import { checkIsRoutePPREnabled } from '../server/lib/experimental/ppr'
import type { Params } from '../client/components/params'
import { FallbackMode } from '../lib/fallback'
import {
fallbackToStaticPathsResult,
parseFallbackStaticPathsResult,
fallbackModeToStaticPathsResult,
parseStaticPathsResult,
} from '../lib/fallback'
import { getParamKeys } from '../client/components/fallback-params'
import type { OutgoingHttpHeaders } from 'http'

export type ROUTER_TYPE = 'pages' | 'app'

Expand Down Expand Up @@ -944,11 +946,20 @@ export async function getJsPageSizeInKb(
return [-1, -1]
}

export type PrerenderedRoute = {
type StaticPrerenderedRoute = {
path: string
encoded: string
fallbackRouteParams: undefined
}

type FallbackPrerenderedRoute = {
path: string
encoded: string
fallbackRouteParams: readonly string[]
}

export type PrerenderedRoute = StaticPrerenderedRoute | FallbackPrerenderedRoute

export type StaticPathsResult = {
fallbackMode: FallbackMode
prerenderedRoutes: PrerenderedRoute[]
Expand All @@ -971,12 +982,12 @@ export async function buildStaticPaths({
defaultLocale?: string
appDir?: boolean
}): Promise<StaticPathsResult> {
const prerenderRoutes: PrerenderedRoute[] = []
const prerenderedRoutes: PrerenderedRoute[] = []
const _routeRegex = getRouteRegex(page)
const _routeMatcher = getRouteMatcher(_routeRegex)

// Get the default list of allowed params.
const _validParamKeys = Object.keys(_routeMatcher(page))
const routeParameterKeys = Object.keys(_routeMatcher(page))

if (!staticPathsResult) {
if (getStaticPaths) {
Expand Down Expand Up @@ -1060,14 +1071,15 @@ export async function buildStaticPaths({
// If leveraging the string paths variant the entry should already be
// encoded so we decode the segments ensuring we only escape path
// delimiters
prerenderRoutes.push({
prerenderedRoutes.push({
path: entry
.split('/')
.map((segment) =>
escapePathDelimiters(decodeURIComponent(segment), true)
)
.join('/'),
encoded: entry,
fallbackRouteParams: undefined,
})
}
// For the object-provided path, we must make sure it specifies all
Expand All @@ -1081,7 +1093,7 @@ export async function buildStaticPaths({
throw new Error(
`Additional keys were returned from \`getStaticPaths\` in page "${page}". ` +
`URL Parameters intended for this dynamic route must be nested under the \`params\` key, i.e.:` +
`\n\n\treturn { params: { ${_validParamKeys
`\n\n\treturn { params: { ${routeParameterKeys
.map((k) => `${k}: ...`)
.join(', ')} } }` +
`\n\nKeys that need to be moved: ${invalidKeys.join(', ')}.\n`
Expand All @@ -1092,7 +1104,7 @@ export async function buildStaticPaths({
let builtPage = page
let encodedBuiltPage = page

_validParamKeys.forEach((validParamKey) => {
routeParameterKeys.forEach((validParamKey) => {
const { repeat, optional } = _routeRegex.groups[validParamKey]
let paramValue = params[validParamKey]
if (
Expand All @@ -1108,9 +1120,8 @@ export async function buildStaticPaths({
(repeat && !Array.isArray(paramValue)) ||
(!repeat && typeof paramValue !== 'string')
) {
// If from appDir and not all params were provided from
// generateStaticParams we can just filter this entry out
// as it's meant to be generated at runtime
// If this is from app directory, and not all params were provided,
// then filter this out if the route is not PPR enabled.
if (appDir && typeof paramValue === 'undefined') {
builtPage = ''
encodedBuiltPage = ''
Expand Down Expand Up @@ -1163,22 +1174,23 @@ export async function buildStaticPaths({
}
const curLocale = entry.locale || defaultLocale || ''

prerenderRoutes.push({
prerenderedRoutes.push({
path: `${curLocale ? `/${curLocale}` : ''}${
curLocale && builtPage === '/' ? '' : builtPage
}`,
encoded: `${curLocale ? `/${curLocale}` : ''}${
curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage
}`,
fallbackRouteParams: undefined,
})
}
})

const seen = new Set<string>()

return {
fallbackMode: parseFallbackStaticPathsResult(staticPathsResult.fallback),
prerenderedRoutes: prerenderRoutes.filter((route) => {
fallbackMode: parseStaticPathsResult(staticPathsResult.fallback),
prerenderedRoutes: prerenderedRoutes.filter((route) => {
if (seen.has(route.path)) return false

// Filter out duplicate paths.
Expand Down Expand Up @@ -1352,6 +1364,8 @@ export async function buildAppStaticPaths({
fetchCacheKeyPrefix,
nextConfigOutput,
ComponentMod,
isRoutePPREnabled,
isAppPPRFallbacksEnabled,
}: {
dir: string
page: string
Expand All @@ -1365,6 +1379,8 @@ export async function buildAppStaticPaths({
requestHeaders: IncrementalCache['requestHeaders']
nextConfigOutput: 'standalone' | 'export' | undefined
ComponentMod: AppPageModule
isRoutePPREnabled: boolean | undefined
isAppPPRFallbacksEnabled: boolean | undefined
}): Promise<PartialStaticPathsResult> {
ComponentMod.patchFetch()

Expand Down Expand Up @@ -1401,6 +1417,9 @@ export async function buildAppStaticPaths({
ComponentMod.staticGenerationAsyncStorage,
{
page,
// We're discovering the parameters here, so we don't have any unknown
// ones.
fallbackRouteParams: null,
renderOpts: {
incrementalCache,
supportsDynamicResponse: true,
Expand Down Expand Up @@ -1429,17 +1448,17 @@ export async function buildAppStaticPaths({
paramsItems: Params[] = [{}],
idx = 0
): Promise<Params[]> => {
const curGenerate = generateParams[idx]
const current = generateParams[idx]

if (idx === generateParams.length) {
return paramsItems
}

if (
typeof curGenerate.generateStaticParams !== 'function' &&
typeof current.generateStaticParams !== 'function' &&
idx < generateParams.length
) {
if (curGenerate.isDynamicSegment) {
if (current.isDynamicSegment) {
// This dynamic level has no generateStaticParams so we change
// this flag to false, but it could be covered by a later
// generateStaticParams so it could be set back to true.
Expand All @@ -1451,26 +1470,26 @@ export async function buildAppStaticPaths({

const newParams: Params[] = []

if (curGenerate.generateStaticParams) {
const curStore =
ComponentMod.staticGenerationAsyncStorage.getStore()
if (current.generateStaticParams) {
const store = ComponentMod.staticGenerationAsyncStorage.getStore()

if (curStore) {
if (typeof curGenerate?.config?.fetchCache !== 'undefined') {
curStore.fetchCache = curGenerate.config.fetchCache
if (store) {
if (typeof current?.config?.fetchCache !== 'undefined') {
store.fetchCache = current.config.fetchCache
}
if (typeof curGenerate?.config?.revalidate !== 'undefined') {
curStore.revalidate = curGenerate.config.revalidate
if (typeof current?.config?.revalidate !== 'undefined') {
store.revalidate = current.config.revalidate
}
if (curGenerate?.config?.dynamic === 'force-dynamic') {
curStore.forceDynamic = true
if (current?.config?.dynamic === 'force-dynamic') {
store.forceDynamic = true
}
}

for (const params of paramsItems) {
const result = await curGenerate.generateStaticParams({
const result = await current.generateStaticParams({
params,
})

// TODO: validate the result is valid here or wait for buildStaticPaths to validate?
for (const item of result) {
newParams.push({ ...params, ...item })
Expand Down Expand Up @@ -1498,35 +1517,57 @@ export async function buildAppStaticPaths({
)
}

const fallbackMode = !generateParams.some(
// TODO: dynamic params should be allowed
// to be granular per segment but we need
// additional information stored/leveraged in
// the prerender-manifest to allow this behavior
(generate) => generate.config?.dynamicParams === false
// TODO: dynamic params should be allowed to be granular per segment but
// we need additional information stored/leveraged in the prerender
// manifest to allow this behavior.
const dynamicParams = generateParams.every(
(param) => param.config?.dynamicParams !== false
)
? FallbackMode.BLOCKING_STATIC_RENDER

const isProduction = process.env.NODE_ENV === 'production'

const supportsStaticGeneration = hadAllParamsGenerated || isProduction

const supportsPPRFallbacks =
isRoutePPREnabled && isAppPPRFallbacksEnabled

const fallbackMode = dynamicParams
? supportsStaticGeneration
? supportsPPRFallbacks
? FallbackMode.PRERENDER
: FallbackMode.BLOCKING_STATIC_RENDER
: undefined
: FallbackMode.NOT_FOUND

if (!hadAllParamsGenerated) {
return {
fallbackMode:
process.env.NODE_ENV === 'production' && isDynamicRoute(page)
? FallbackMode.BLOCKING_STATIC_RENDER
: undefined,
prerenderedRoutes: undefined,
}
let result: PartialStaticPathsResult = {
fallbackMode,
prerenderedRoutes: undefined,
}

return buildStaticPaths({
staticPathsResult: {
fallback: fallbackToStaticPathsResult(fallbackMode),
paths: builtParams.map((params) => ({ params })),
},
page,
configFileName,
appDir: true,
})
if (hadAllParamsGenerated && fallbackMode) {
result = await buildStaticPaths({
staticPathsResult: {
fallback: fallbackModeToStaticPathsResult(fallbackMode),
paths: builtParams.map((params) => ({ params })),
},
page,
configFileName,
appDir: true,
})
}

// If the fallback mode is a prerender, we want to include the dynamic
// route in the prerendered routes too.
if (isRoutePPREnabled && isAppPPRFallbacksEnabled) {
result.prerenderedRoutes ??= []
result.prerenderedRoutes.unshift({
path: page,
encoded: page,
fallbackRouteParams: getParamKeys(page),
})
}

return result
}
}
)
Expand Down Expand Up @@ -1566,6 +1607,7 @@ export async function isPageStatic({
nextConfigOutput,
cacheHandler,
pprConfig,
isAppPPRFallbacksEnabled,
}: {
dir: string
page: string
Expand All @@ -1585,6 +1627,7 @@ export async function isPageStatic({
cacheHandler?: string
nextConfigOutput: 'standalone' | 'export' | undefined
pprConfig: ExperimentalPPRConfig | undefined
isAppPPRFallbacksEnabled: boolean | undefined
}): Promise<PageIsStaticResult> {
const isPageStaticSpan = trace('is-page-static-utils', parentId)
return isPageStaticSpan
Expand Down Expand Up @@ -1708,6 +1751,8 @@ export async function isPageStatic({
cacheHandler,
ComponentMod,
nextConfigOutput,
isRoutePPREnabled,
isAppPPRFallbacksEnabled,
}))
}
} else {
Expand Down Expand Up @@ -1817,7 +1862,9 @@ type ReducedAppConfig = Pick<
* @param segments the generate param segments
* @returns the reduced app config
*/
function reduceAppConfig(segments: GenerateParamsResults): ReducedAppConfig {
export function reduceAppConfig(
segments: GenerateParamsResults
): ReducedAppConfig {
const config: ReducedAppConfig = {}

for (const segment of segments) {
Expand Down Expand Up @@ -2330,3 +2377,51 @@ export function isWebpackBundledLayer(
): boolean {
return Boolean(layer && WEBPACK_LAYERS.GROUP.bundled.includes(layer as any))
}

export function collectMeta({
status,
headers,
}: {
status?: number
headers?: OutgoingHttpHeaders
}): {
status?: number
headers?: Record<string, string>
} {
const meta: {
status?: number
headers?: Record<string, string>
} = {}

if (status !== 200) {
meta.status = status
}

if (headers && Object.keys(headers).length) {
meta.headers = {}

// normalize header values as initialHeaders
// must be Record<string, string>
for (const key in headers) {
// set-cookie is already handled - the middleware cookie setting case
// isn't needed for the prerender manifest since it can't read cookies
if (key === 'x-middleware-set-cookie') continue

let value = headers[key]

if (Array.isArray(value)) {
if (key === 'set-cookie') {
value = value.join(',')
} else {
value = value[value.length - 1]
}
}

if (typeof value === 'string') {
meta.headers[key] = value
}
}
}

return meta
}
Loading

0 comments on commit f4c1a40

Please sign in to comment.