[next] Support pre-generated pages without fallbacks with Partial Prerendering (#11183)

This commit is contained in:
Wyatt Johnson
2024-03-01 18:29:31 -07:00
committed by GitHub
parent 24ec5c5aca
commit b1d8b83abb
8 changed files with 327 additions and 108 deletions

View File

@@ -0,0 +1,5 @@
---
'@vercel/next': patch
---
Enable partial prerendering support for pre-generated pages

View File

@@ -1142,6 +1142,10 @@ export const build: BuildV2 = async ({
appPathRoutesManifest,
});
/**
* This is a detection for preview mode that's required for the pages
* router.
*/
const canUsePreviewMode = Object.keys(pages).some(page =>
isApiPage(pages[page].fsPath)
);
@@ -1316,6 +1320,22 @@ export const build: BuildV2 = async ({
}
}
/**
* All of the routes that have `experimentalPPR` enabled.
*/
const experimentalPPRRoutes = new Set<string>();
for (const [route, { experimentalPPR }] of [
...Object.entries(prerenderManifest.staticRoutes),
...Object.entries(prerenderManifest.blockingFallbackRoutes),
...Object.entries(prerenderManifest.fallbackRoutes),
...Object.entries(prerenderManifest.omittedRoutes),
]) {
if (!experimentalPPR) continue;
experimentalPPRRoutes.add(route);
}
if (requiredServerFilesManifest) {
if (!routesManifest) {
throw new Error(
@@ -1371,6 +1391,7 @@ export const build: BuildV2 = async ({
hasIsr404Page,
hasIsr500Page,
variantsManifest,
experimentalPPRRoutes,
});
}
@@ -1883,17 +1904,18 @@ export const build: BuildV2 = async ({
);
}
dynamicRoutes = await getDynamicRoutes(
dynamicRoutes = await getDynamicRoutes({
entryPath,
entryDirectory,
dynamicPages,
false,
isDev: false,
routesManifest,
omittedPrerenderRoutes,
omittedRoutes: omittedPrerenderRoutes,
canUsePreviewMode,
prerenderManifest.bypassToken || '',
isServerMode
).then(arr =>
bypassToken: prerenderManifest.bypassToken || '',
isServerMode,
experimentalPPRRoutes,
}).then(arr =>
localizeDynamicRoutes(
arr,
dynamicPrefix,
@@ -1912,17 +1934,18 @@ export const build: BuildV2 = async ({
// we need to include the prerenderManifest.omittedRoutes here
// for the page to be able to be matched in the lambda for preview mode
const completeDynamicRoutes = await getDynamicRoutes(
const completeDynamicRoutes = await getDynamicRoutes({
entryPath,
entryDirectory,
dynamicPages,
false,
isDev: false,
routesManifest,
undefined,
omittedRoutes: undefined,
canUsePreviewMode,
prerenderManifest.bypassToken || '',
isServerMode
).then(arr =>
bypassToken: prerenderManifest.bypassToken || '',
isServerMode,
experimentalPPRRoutes,
}).then(arr =>
arr.map(route => {
route.src = route.src.replace('^', `^${dynamicPrefix}`);
return route;
@@ -2119,22 +2142,33 @@ export const build: BuildV2 = async ({
appPathRoutesManifest,
isSharedLambdas,
canUsePreviewMode,
omittedPrerenderRoutes,
});
Object.keys(prerenderManifest.staticRoutes).forEach(route =>
prerenderRoute(route, { isBlocking: false, isFallback: false })
await Promise.all(
Object.keys(prerenderManifest.staticRoutes).map(route =>
prerenderRoute(route, {})
)
);
Object.keys(prerenderManifest.fallbackRoutes).forEach(route =>
prerenderRoute(route, { isBlocking: false, isFallback: true })
await Promise.all(
Object.keys(prerenderManifest.fallbackRoutes).map(route =>
prerenderRoute(route, { isFallback: true })
)
);
Object.keys(prerenderManifest.blockingFallbackRoutes).forEach(route =>
prerenderRoute(route, { isBlocking: true, isFallback: false })
await Promise.all(
Object.keys(prerenderManifest.blockingFallbackRoutes).map(route =>
prerenderRoute(route, { isBlocking: true })
)
);
if (static404Page && canUsePreviewMode) {
omittedPrerenderRoutes.forEach(route => {
prerenderRoute(route, { isOmitted: true });
});
await Promise.all(
Array.from(omittedPrerenderRoutes).map(route =>
prerenderRoute(route, { isOmitted: true })
)
);
}
// We still need to use lazyRoutes if the dataRoutes field

View File

@@ -52,6 +52,7 @@ import {
RSC_PREFETCH_SUFFIX,
normalizePrefetches,
CreateLambdaFromPseudoLayersOptions,
getPostponeResumePathname,
} from './utils';
import {
nodeFileTrace,
@@ -142,6 +143,7 @@ export async function serverBuild({
lambdaCompressedByteLimit,
requiredServerFilesManifest,
variantsManifest,
experimentalPPRRoutes,
}: {
appPathRoutesManifest?: Record<string, string>;
dynamicPages: string[];
@@ -183,6 +185,7 @@ export async function serverBuild({
prerenderManifest: NextPrerenderedRoutes;
requiredServerFilesManifest: NextRequiredServerFilesManifest;
variantsManifest: VariantsManifestLegacy | null;
experimentalPPRRoutes: ReadonlySet<string>;
}): Promise<BuildResult> {
lambdaPages = Object.assign({}, lambdaPages, lambdaAppPaths);
@@ -353,18 +356,6 @@ export async function serverBuild({
internalPages.push('404.js');
}
const experimentalPPRRoutes = new Set<string>();
for (const [route, { experimentalPPR }] of [
...Object.entries(prerenderManifest.staticRoutes),
...Object.entries(prerenderManifest.blockingFallbackRoutes),
...Object.entries(prerenderManifest.fallbackRoutes),
]) {
if (!experimentalPPR) continue;
experimentalPPRRoutes.add(route);
}
const prerenderRoutes: ReadonlySet<string> = new Set<string>([
...(canUsePreviewMode ? omittedPrerenderRoutes : []),
...Object.keys(prerenderManifest.blockingFallbackRoutes),
@@ -1185,7 +1176,7 @@ export async function serverBuild({
// lambda for the page for revalidation.
let revalidate: NodejsLambda | undefined;
if (isPPR) {
if (isPPR && !options.isStreaming) {
if (!options.isStreaming) {
throw new Error("Invariant: PPR lambda isn't streaming");
}
@@ -1197,24 +1188,28 @@ export async function serverBuild({
});
}
for (const page of group.pages) {
const pageNoExt = page.replace(/\.js$/, '');
let isPrerender = prerenderRoutes.has(
path.join('/', pageNoExt === 'index' ? '' : pageNoExt)
);
for (const pageFilename of group.pages) {
// This is the name of the page, where the root is `index`.
const pageName = pageFilename.replace(/\.js$/, '');
// This is the name of the page prefixed with a `/`, where the root is
// `/index`.
const pagePath = path.posix.join('/', pageName);
// This is the routable pathname for the page, where the root is `/`.
const pagePathname = pagePath === '/index' ? '/' : pagePath;
let isPrerender = prerenderRoutes.has(pagePathname);
if (!isPrerender && routesManifest?.i18n) {
isPrerender = routesManifest.i18n.locales.some(locale => {
return prerenderRoutes.has(
path.join('/', locale, pageNoExt === 'index' ? '' : pageNoExt)
path.join('/', locale, pageName === 'index' ? '' : pageName)
);
});
}
let outputName = path.posix.join(entryDirectory, pageNoExt);
if (!group.isAppRouter && !group.isAppRouteHandler) {
outputName = normalizeIndexOutput(outputName, true);
}
let outputName = path.posix.join(entryDirectory, pageName);
// If this is a PPR page, then we should prefix the output name.
if (isPPR) {
@@ -1222,24 +1217,56 @@ export async function serverBuild({
throw new Error("Invariant: PPR lambda isn't set");
}
// Get the get the base path prefixed route, without the index
// normalization.
outputName = path.posix.join(entryDirectory, pageNoExt);
// Assign the revalidate lambda to the output name. That's used to
// perform the initial static shell render.
lambdas[outputName] = revalidate;
const pprOutputName = path.posix.join(
entryDirectory,
'/_next/postponed/resume',
pageNoExt
);
lambdas[pprOutputName] = lambda;
// If this isn't an omitted page, then we should add the link from the
// page to the postpone resume lambda.
if (!omittedPrerenderRoutes.has(pagePathname)) {
const key = getPostponeResumePathname(entryDirectory, pageName);
lambdas[key] = lambda;
// We want to add the `experimentalStreamingLambdaPath` to this
// output.
experimentalStreamingLambdaPaths.set(outputName, pprOutputName);
experimentalStreamingLambdaPaths.set(outputName, key);
} else {
// As this is an omitted page, we should generate the experimental
// partial prerendering resume route for each of these routes that
// support partial prerendering. This is because the routes that
// haven't been omitted will have rewrite rules in place to rewrite
// the original request `/blog/my-slug` to the dynamic path
// `/blog/[slug]?nxtPslug=my-slug`.
for (const [
routePathname,
{ srcRoute, experimentalPPR },
] of Object.entries(prerenderManifest.staticRoutes)) {
// If the srcRoute doesn't match or this doesn't support
// experimental partial prerendering, then we can skip this route.
if (srcRoute !== pagePathname || !experimentalPPR) continue;
// If this route is the same as the page route, then we can skip
// it, because we've already added the lambda to the output.
if (routePathname === pagePathname) continue;
const key = getPostponeResumePathname(
entryDirectory,
routePathname
);
lambdas[key] = lambda;
outputName = path.posix.join(entryDirectory, routePathname);
experimentalStreamingLambdaPaths.set(outputName, key);
}
}
continue;
}
if (!group.isAppRouter && !group.isAppRouteHandler) {
outputName = normalizeIndexOutput(outputName, true);
}
// we add locale prefixed outputs for SSR pages,
// this is handled in onPrerenderRoute for SSG pages
if (
@@ -1247,7 +1274,7 @@ export async function serverBuild({
!isPrerender &&
!group.isAppRouter &&
(!isCorrectLocaleAPIRoutes ||
!(pageNoExt === 'api' || pageNoExt.startsWith('api/')))
!(pageName === 'api' || pageName.startsWith('api/')))
) {
for (const locale of i18n.locales) {
lambdas[
@@ -1255,7 +1282,7 @@ export async function serverBuild({
path.posix.join(
entryDirectory,
locale,
pageNoExt === 'index' ? '' : pageNoExt
pageName === 'index' ? '' : pageName
),
true
)
@@ -1288,6 +1315,7 @@ export async function serverBuild({
hasPages404: routesManifest.pages404,
isCorrectNotFoundRoutes,
isEmptyAllowQueryForPrendered,
omittedPrerenderRoutes,
});
await Promise.all(
@@ -1295,11 +1323,13 @@ export async function serverBuild({
prerenderRoute(route, {})
)
);
await Promise.all(
Object.keys(prerenderManifest.fallbackRoutes).map(route =>
prerenderRoute(route, { isFallback: true })
)
);
await Promise.all(
Object.keys(prerenderManifest.blockingFallbackRoutes).map(route =>
prerenderRoute(route, { isBlocking: true })
@@ -1308,9 +1338,9 @@ export async function serverBuild({
if (static404Page && canUsePreviewMode) {
await Promise.all(
[...omittedPrerenderRoutes].map(route => {
return prerenderRoute(route, { isOmitted: true });
})
Array.from(omittedPrerenderRoutes).map(route =>
prerenderRoute(route, { isOmitted: true })
)
);
}
@@ -1319,6 +1349,7 @@ export async function serverBuild({
if (routesManifest?.i18n) {
route = normalizeLocalePath(route, routesManifest.i18n.locales).pathname;
}
delete lambdas[
normalizeIndexOutput(
path.posix.join('./', entryDirectory, route === '/' ? '/index' : route),
@@ -1342,19 +1373,19 @@ export async function serverBuild({
middleware.staticRoutes.length > 0 &&
semver.gte(nextVersion, NEXT_DATA_MIDDLEWARE_RESOLVING_VERSION);
const dynamicRoutes = await getDynamicRoutes(
const dynamicRoutes = await getDynamicRoutes({
entryPath,
entryDirectory,
dynamicPages,
false,
isDev: false,
routesManifest,
omittedPrerenderRoutes,
omittedRoutes: omittedPrerenderRoutes,
canUsePreviewMode,
prerenderManifest.bypassToken || '',
true,
middleware.dynamicRouteMap,
experimental.ppr
).then(arr =>
bypassToken: prerenderManifest.bypassToken || '',
isServerMode: true,
dynamicMiddlewareRouteMap: middleware.dynamicRouteMap,
experimentalPPRRoutes,
}).then(arr =>
localizeDynamicRoutes(
arr,
dynamicPrefix,
@@ -1560,6 +1591,46 @@ export async function serverBuild({
throw new Error("Invariant: cannot use PPR without 'rsc.prefetchHeader'");
}
// If we're using the Experimental Partial Prerendering, we should ensure that
// all the routes that support it (and are listed) have configured lambdas.
// This only applies to routes that do not have fallbacks enabled (these are
// routes that have `dynamicParams = false` defined.
if (experimental.ppr) {
for (const { srcRoute, dataRoute, experimentalPPR } of Object.values(
prerenderManifest.staticRoutes
)) {
// Only apply this to the routes that support experimental PPR and
// that also have their `dataRoute` and `srcRoute` defined.
if (!experimentalPPR || !dataRoute || !srcRoute) continue;
// If the srcRoute is not omitted, then we don't need to do anything. This
// is the indicator that a route should only have it's prerender defined
// and not a lambda.
if (!omittedPrerenderRoutes.has(srcRoute)) continue;
// The lambda paths have their leading `/` stripped.
const srcPathname = srcRoute.substring(1);
const dataPathname = dataRoute.substring(1);
// If we already have an associated lambda for the `.rsc` route, then
// we can skip this.
const dataPathnameExists = dataPathname in lambdas;
if (dataPathnameExists) continue;
// We require that the source route has a lambda associated with it. If
// it doesn't this is an error.
const srcPathnameExists = srcPathname in lambdas;
if (!srcPathnameExists) {
throw new Error(
`Invariant: Expected to have a lambda for the source route: ${srcPathname}`
);
}
// Associate the data pathname with the source pathname's lambda.
lambdas[dataPathname] = lambdas[srcPathname];
}
}
return {
wildcard: wildcardConfig,
images: getImagesConfig(imagesManifest),

View File

@@ -304,19 +304,31 @@ export async function getRoutesManifest(
return routesManifest;
}
export async function getDynamicRoutes(
entryPath: string,
entryDirectory: string,
dynamicPages: ReadonlyArray<string>,
isDev?: boolean,
routesManifest?: RoutesManifest,
omittedRoutes?: ReadonlySet<string>,
canUsePreviewMode?: boolean,
bypassToken?: string,
isServerMode?: boolean,
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>,
experimentalPPR?: boolean
): Promise<RouteWithSrc[]> {
export async function getDynamicRoutes({
entryPath,
entryDirectory,
dynamicPages,
isDev,
routesManifest,
omittedRoutes,
canUsePreviewMode,
bypassToken,
isServerMode,
dynamicMiddlewareRouteMap,
experimentalPPRRoutes,
}: {
entryPath: string;
entryDirectory: string;
dynamicPages: string[];
isDev?: boolean;
routesManifest?: RoutesManifest;
omittedRoutes?: ReadonlySet<string>;
canUsePreviewMode?: boolean;
bypassToken?: string;
isServerMode?: boolean;
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>;
experimentalPPRRoutes: ReadonlySet<string>;
}): Promise<RouteWithSrc[]> {
if (routesManifest) {
switch (routesManifest.version) {
case 1:
@@ -389,7 +401,7 @@ export async function getDynamicRoutes(
];
}
if (experimentalPPR) {
if (experimentalPPRRoutes.has(page)) {
let dest = route.dest?.replace(/($|\?)/, '.prefetch.rsc$1');
if (page === '/' || page === '/index') {
@@ -919,6 +931,10 @@ export type NextPrerenderedRoutes = {
};
};
/**
* Routes that have their fallback behavior is disabled. All routes would've
* been provided in the top-level `routes` key (`staticRoutes`).
*/
omittedRoutes: {
[route: string]: {
routeRegex: string;
@@ -1298,8 +1314,6 @@ export async function getPrerenderManifest(
prefetchDataRouteRegex,
};
} else {
// Fallback behavior is disabled, all routes would've been provided
// in the top-level `routes` key (`staticRoutes`).
ret.omittedRoutes[lazyRoute] = {
experimentalBypassFor,
experimentalPPR,
@@ -1923,6 +1937,7 @@ type OnPrerenderRouteArgs = {
routesManifest?: RoutesManifest;
isCorrectNotFoundRoutes?: boolean;
isEmptyAllowQueryForPrendered?: boolean;
omittedPrerenderRoutes: ReadonlySet<string>;
};
let prerenderGroup = 1;
@@ -1959,6 +1974,7 @@ export const onPrerenderRoute =
routesManifest,
isCorrectNotFoundRoutes,
isEmptyAllowQueryForPrendered,
omittedPrerenderRoutes,
} = prerenderRouteArgs;
if (isBlocking && isFallback) {
@@ -2383,25 +2399,31 @@ export const onPrerenderRoute =
sourcePath = srcRoute;
}
// The `experimentalStreamingLambdaPaths` stores the page without the
// leading `/` and with the `/` rewritten to be `index`. We should
// normalize the key so that it matches that key in the map.
let key = srcRoute || routeKey;
if (key === '/') {
key = 'index';
let experimentalStreamingLambdaPath: string | undefined;
if (experimentalPPR) {
if (!experimentalStreamingLambdaPaths) {
throw new Error(
"Invariant: experimentalStreamingLambdaPaths doesn't exist"
);
}
// If a source route exists, and it's not listed as an omitted route,
// then use the src route as the basis for the experimental streaming
// lambda path. If the route doesn't have a source route or it's not
// omitted, then use the more specific `routeKey` as the basis.
if (srcRoute && !omittedPrerenderRoutes.has(srcRoute)) {
experimentalStreamingLambdaPath =
experimentalStreamingLambdaPaths.get(
pathnameToOutputName(entryDirectory, srcRoute)
);
} else {
if (!key.startsWith('/')) {
throw new Error("Invariant: key doesn't start with /");
experimentalStreamingLambdaPath =
experimentalStreamingLambdaPaths.get(
pathnameToOutputName(entryDirectory, routeKey)
);
}
key = key.substring(1);
}
key = path.posix.join(entryDirectory, key);
const experimentalStreamingLambdaPath =
experimentalStreamingLambdaPaths?.get(key);
prerenders[outputPathPage] = new Prerender({
expiration: initialRevalidate,
lambda,
@@ -2604,6 +2626,10 @@ export async function getStaticFiles(
};
}
/**
* Strips the trailing `/index` from the output name if it's not the root if
* the server mode is enabled.
*/
export function normalizeIndexOutput(
outputName: string,
isServerMode: boolean
@@ -2624,6 +2650,19 @@ export function getNextServerPath(nextVersion: string) {
: 'next/dist/next-server/server';
}
export function pathnameToOutputName(entryDirectory: string, pathname: string) {
if (pathname === '/') pathname = '/index';
return path.posix.join(entryDirectory, pathname);
}
export function getPostponeResumePathname(
entryDirectory: string,
pathname: string
): string {
if (pathname === '/') pathname = '/index';
return path.posix.join(entryDirectory, '_next/postponed/resume', pathname);
}
// update to leverage
export function updateRouteSrc(
route: Route,

View File

@@ -0,0 +1,18 @@
import React, { Suspense } from 'react'
import { Dynamic } from '../../../components/dynamic'
export const dynamicParams = false;
const slugs = ['a', 'b', 'c'];
export function generateStaticParams() {
return slugs.map((slug) => ({ slug }));
}
export default function NoFallbackPage({ params: { slug } }) {
return (
<Suspense fallback={<Dynamic pathname={`/no-fallback/${slug}`} fallback />}>
<Dynamic pathname={`/no-fallback/${slug}`} />
</Suspense>
)
}

View File

@@ -19,11 +19,23 @@ const pages = [
{ pathname: '/no-suspense/nested/a', dynamic: true },
{ pathname: '/no-suspense/nested/b', dynamic: true },
{ pathname: '/no-suspense/nested/c', dynamic: true },
{ pathname: '/no-fallback/a', dynamic: true },
{ pathname: '/no-fallback/b', dynamic: true },
{ pathname: '/no-fallback/c', dynamic: true },
// TODO: uncomment when we've fixed the 404 case for force-dynamic pages
// { pathname: '/dynamic/force-dynamic', dynamic: 'force-dynamic' },
{ pathname: '/dynamic/force-static', dynamic: 'force-static' },
];
const cases = {
404: [
// For routes that do not support fallback (they had `dynamicParams` set to
// `false`), we shouldn't see any fallback behavior for routes not defined
// in `getStaticParams`.
{ pathname: '/no-fallback/non-existent' },
],
};
const ctx = {};
describe(`${__dirname.split(path.sep).pop()}`, () => {
@@ -49,6 +61,14 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
expect(html).toContain('</html>');
}
);
it.each(cases[404])(
'should return 404 for $pathname',
async ({ pathname }) => {
const res = await fetch(`${ctx.deploymentUrl}${pathname}`);
expect(res.status).toEqual(404);
}
);
});
describe('prefetch RSC payloads should return', () => {
@@ -88,6 +108,16 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
}
}
);
it.each(cases[404])(
'should return 404 for $pathname',
async ({ pathname }) => {
const res = await fetch(`${ctx.deploymentUrl}${pathname}`, {
headers: { RSC: 1, 'Next-Router-Prefetch': '1' },
});
expect(res.status).toEqual(404);
}
);
});
describe('dynamic RSC payloads should return', () => {
@@ -122,5 +152,15 @@ describe(`${__dirname.split(path.sep).pop()}`, () => {
expect(text).not.toContain(expected);
}
});
it.each(cases[404])(
'should return 404 for $pathname',
async ({ pathname }) => {
const res = await fetch(`${ctx.deploymentUrl}${pathname}`, {
headers: { RSC: 1 },
});
expect(res.status).toEqual(404);
}
);
});
});

View File

@@ -1,8 +1,8 @@
{
"dependencies": {
"next": "canary",
"react": "experimental",
"react-dom": "experimental"
"react": "18.2.0",
"react-dom": "18.2.0"
},
"ignoreNextjsUpdates": true
}

View File

@@ -119,16 +119,28 @@ async function disableSSO(deploymentId, useTeam = true) {
}
);
const text = await deployRes.text();
if (!deployRes.ok) {
throw new Error(
`Failed to get deployment info (status: ${
deployRes.status
}, body: ${await deployRes.text()})`
`Failed to get deployment info (status: ${deployRes.status}, body: ${text})`
);
}
const deploymentInfo = await deployRes.json();
const { projectId, url: deploymentUrl } = deploymentInfo;
let info;
try {
info = JSON.parse(text);
} catch (err) {
throw new Error('Failed to parse deployment info JSON', { cause: err });
}
const { projectId, url: deploymentUrl } = info;
if (!deploymentUrl || typeof deploymentUrl !== 'string') {
throw new Error(
`Failed to get deployment URL (status: ${deployRes.status}, body: ${text})`
);
}
const settingRes = await fetchWithAuth(
`https://vercel.com/api/v5/projects/${encodeURIComponent(projectId)}`,
@@ -160,7 +172,7 @@ async function disableSSO(deploymentId, useTeam = true) {
`Disabled deployment protection for deploymentId: ${deploymentId} projectId: ${projectId}`
);
} else {
console.error(settingRes.status, await settingRes.text(), deploymentInfo);
console.error(settingRes.status, await settingRes.text(), text);
throw new Error(
`Failed to disable deployment protection projectId: ${projectId} deploymentId ${deploymentId}`
);