From 8bc5716e1ed818adc6837b73705e1ce55229bf81 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 2 Sep 2025 11:36:29 +0200 Subject: [PATCH] fix: support ppr dynamic shells --- src/build/content/prerendered.ts | 33 +++++++++++------ src/build/plugin-context.ts | 29 +++++++++++++-- tests/fixtures/ppr/app/[dynamic]/page.js | 45 ++++++++++++++++++++++++ tests/integration/simple-app.test.ts | 11 ++++++ 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/ppr/app/[dynamic]/page.js diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 6511b16c13..a05fc7db79 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -209,17 +209,30 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise await writeCacheEntry(key, value, lastModified, ctx) }), ), - ...ctx.getFallbacks(manifest).map(async (route) => { - const key = routeToFilePath(route) - const value = await buildPagesCacheValue( - join(ctx.publishDir, 'server/pages', key), - undefined, - shouldUseEnumKind, - true, // there is no corresponding json file for fallback, so we are skipping it for this entry - ) + ...ctx.getFallbacks(manifest).map((route) => + limitConcurrentPrerenderContentHandling(async () => { + const key = routeToFilePath(route) + const value = await buildPagesCacheValue( + join(ctx.publishDir, 'server/pages', key), + undefined, + shouldUseEnumKind, + true, // there is no corresponding json file for fallback, so we are skipping it for this entry + ) - await writeCacheEntry(key, value, Date.now(), ctx) - }), + await writeCacheEntry(key, value, Date.now(), ctx) + }), + ), + ...ctx.getShells(manifest).map((route) => + limitConcurrentPrerenderContentHandling(async () => { + const key = routeToFilePath(route) + const value = await buildAppCacheValue( + join(ctx.publishDir, 'server/app', key), + shouldUseAppPageKind, + ) + + await writeCacheEntry(key, value, Date.now(), ctx) + }), + ), ]) // app router 404 pages are not in the prerender manifest diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 9148d0dd56..c3f0345a57 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -10,10 +10,13 @@ import type { NetlifyPluginOptions, NetlifyPluginUtils, } from '@netlify/build' -import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js' import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js' import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js' import type { NextConfigComplete } from 'next/dist/server/config-shared.js' +import type { + PrerenderManifest, + RoutesManifest, +} from 'next-with-cache-handler-v2/dist/build/index.js' import { satisfies } from 'semver' const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) @@ -337,7 +340,7 @@ export class PluginContext { #fallbacks: string[] | null = null /** - * Get an array of localized fallback routes + * Get an array of localized fallback routes for Pages Router * * Example return value for non-i18n site: `['blog/[slug]']` * @@ -356,7 +359,7 @@ export class PluginContext { // - `string` - when user use pages router with `fallback: true`, and then it's html file path // - `null` - when user use pages router with `fallback: 'block'` or app router with `export const dynamicParams = true` // - `false` - when user use pages router with `fallback: false` or app router with `export const dynamicParams = false` - if (typeof meta.fallback === 'string') { + if (typeof meta.fallback === 'string' && meta.renderingMode !== 'PARTIALLY_STATIC') { for (const locale of locales) { const localizedRoute = posixJoin(locale, route.replace(/^\/+/g, '')) fallbacks.push(localizedRoute) @@ -400,6 +403,26 @@ export class PluginContext { return this.#fullyStaticHtmlPages } + #shells: string[] | null = null + /** + * Get an array of static shells for App Router's PPR dynamic routes + */ + getShells(prerenderManifest: PrerenderManifest): string[] { + if (!this.#shells) { + this.#shells = Object.entries(prerenderManifest.dynamicRoutes).reduce( + (shells, [route, meta]) => { + if (typeof meta.fallback === 'string' && meta.renderingMode === 'PARTIALLY_STATIC') { + shells.push(route) + } + return shells + }, + [] as string[], + ) + } + + return this.#shells + } + /** Fails a build with a message and an optional error */ failBuild(message: string, error?: unknown): never { return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined) diff --git a/tests/fixtures/ppr/app/[dynamic]/page.js b/tests/fixtures/ppr/app/[dynamic]/page.js new file mode 100644 index 0000000000..72c6c9bc94 --- /dev/null +++ b/tests/fixtures/ppr/app/[dynamic]/page.js @@ -0,0 +1,45 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +export async function generateStaticParams() { + return [{ dynamic: '1' }, { dynamic: '2' }] +} + +async function getData(params) { + await connection() + const res = await fetch(`https://api.tvmaze.com/shows/${params.id}`, { + next: { + tags: [`show-${params.id}`], + }, + }) + await new Promise((res) => setTimeout(res, 3000)) + return res.json() +} + +async function Content(params) { + const data = await getData(params) + + return ( +
+
Show
+
{data.name}
+
Param
+
{params.id}
+
Time
+
{new Date().toISOString()}
+
+ ) +} + +export default async function DynamicPage({ params }) { + const { dynamic } = await params + + return ( +
+

Dynamic Page: {dynamic}

+ loading...}> + + +
+ ) +} diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 0afb9472b8..d49dcc9e3d 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -394,7 +394,10 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')( const blobEntries = await getBlobEntries(ctx) expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ + '/1', + '/2', '/404', + '/[dynamic]', shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/index', @@ -407,6 +410,14 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')( const home = await invokeFunction(ctx) expect(home.statusCode).toBe(200) expect(load(home.body)('h1').text()).toBe('Home') + + const dynamicPrerendered = await invokeFunction(ctx, { url: '/1' }) + expect(dynamicPrerendered.statusCode).toBe(200) + expect(load(dynamicPrerendered.body)('h1').text()).toBe('Dynamic Page: 1') + + const dynamicNotPrerendered = await invokeFunction(ctx, { url: '/3' }) + expect(dynamicNotPrerendered.statusCode).toBe(200) + expect(load(dynamicNotPrerendered.body)('h1').text()).toBe('Dynamic Page: 3') }, )