Skip to content

Commit bfed78e

Browse files
authored
fix: support ppr shells for dynamic page routes (#3092)
1 parent ddfe7f4 commit bfed78e

File tree

4 files changed

+106
-14
lines changed

4 files changed

+106
-14
lines changed

src/build/content/prerendered.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,17 +209,30 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise<void>
209209
await writeCacheEntry(key, value, lastModified, ctx)
210210
}),
211211
),
212-
...ctx.getFallbacks(manifest).map(async (route) => {
213-
const key = routeToFilePath(route)
214-
const value = await buildPagesCacheValue(
215-
join(ctx.publishDir, 'server/pages', key),
216-
undefined,
217-
shouldUseEnumKind,
218-
true, // there is no corresponding json file for fallback, so we are skipping it for this entry
219-
)
212+
...ctx.getFallbacks(manifest).map((route) =>
213+
limitConcurrentPrerenderContentHandling(async () => {
214+
const key = routeToFilePath(route)
215+
const value = await buildPagesCacheValue(
216+
join(ctx.publishDir, 'server/pages', key),
217+
undefined,
218+
shouldUseEnumKind,
219+
true, // there is no corresponding json file for fallback, so we are skipping it for this entry
220+
)
220221

221-
await writeCacheEntry(key, value, Date.now(), ctx)
222-
}),
222+
await writeCacheEntry(key, value, Date.now(), ctx)
223+
}),
224+
),
225+
...ctx.getShells(manifest).map((route) =>
226+
limitConcurrentPrerenderContentHandling(async () => {
227+
const key = routeToFilePath(route)
228+
const value = await buildAppCacheValue(
229+
join(ctx.publishDir, 'server/app', key),
230+
shouldUseAppPageKind,
231+
)
232+
233+
await writeCacheEntry(key, value, Date.now(), ctx)
234+
}),
235+
),
223236
])
224237

225238
// app router 404 pages are not in the prerender manifest

src/build/plugin-context.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import type {
1010
NetlifyPluginOptions,
1111
NetlifyPluginUtils,
1212
} from '@netlify/build'
13-
import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js'
1413
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
1514
import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js'
1615
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
17-
import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
16+
import type {
17+
FunctionsConfigManifest,
18+
PrerenderManifest,
19+
RoutesManifest,
20+
} from 'next-with-cache-handler-v2/dist/build/index.js'
1821
import { satisfies } from 'semver'
1922

2023
const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
@@ -355,7 +358,7 @@ export class PluginContext {
355358

356359
#fallbacks: string[] | null = null
357360
/**
358-
* Get an array of localized fallback routes
361+
* Get an array of localized fallback routes for Pages Router
359362
*
360363
* Example return value for non-i18n site: `['blog/[slug]']`
361364
*
@@ -374,7 +377,7 @@ export class PluginContext {
374377
// - `string` - when user use pages router with `fallback: true`, and then it's html file path
375378
// - `null` - when user use pages router with `fallback: 'block'` or app router with `export const dynamicParams = true`
376379
// - `false` - when user use pages router with `fallback: false` or app router with `export const dynamicParams = false`
377-
if (typeof meta.fallback === 'string') {
380+
if (typeof meta.fallback === 'string' && meta.renderingMode !== 'PARTIALLY_STATIC') {
378381
for (const locale of locales) {
379382
const localizedRoute = posixJoin(locale, route.replace(/^\/+/g, ''))
380383
fallbacks.push(localizedRoute)
@@ -418,6 +421,26 @@ export class PluginContext {
418421
return this.#fullyStaticHtmlPages
419422
}
420423

424+
#shells: string[] | null = null
425+
/**
426+
* Get an array of static shells for App Router's PPR dynamic routes
427+
*/
428+
getShells(prerenderManifest: PrerenderManifest): string[] {
429+
if (!this.#shells) {
430+
this.#shells = Object.entries(prerenderManifest.dynamicRoutes).reduce(
431+
(shells, [route, meta]) => {
432+
if (typeof meta.fallback === 'string' && meta.renderingMode === 'PARTIALLY_STATIC') {
433+
shells.push(route)
434+
}
435+
return shells
436+
},
437+
[] as string[],
438+
)
439+
}
440+
441+
return this.#shells
442+
}
443+
421444
/** Fails a build with a message and an optional error */
422445
failBuild(message: string, error?: unknown): never {
423446
return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Suspense } from 'react'
2+
import { connection } from 'next/server'
3+
4+
export async function generateStaticParams() {
5+
return [{ dynamic: '1' }, { dynamic: '2' }]
6+
}
7+
8+
async function getData(params) {
9+
await connection()
10+
const res = await fetch(`https://api.tvmaze.com/shows/${params.id}`, {
11+
next: {
12+
tags: [`show-${params.id}`],
13+
},
14+
})
15+
await new Promise((res) => setTimeout(res, 3000))
16+
return res.json()
17+
}
18+
19+
async function Content(params) {
20+
const data = await getData(params)
21+
22+
return (
23+
<dl>
24+
<dt>Show</dt>
25+
<dd>{data.name}</dd>
26+
<dt>Param</dt>
27+
<dd>{params.id}</dd>
28+
<dt>Time</dt>
29+
<dd data-testid="date-now">{new Date().toISOString()}</dd>
30+
</dl>
31+
)
32+
}
33+
34+
export default async function DynamicPage({ params }) {
35+
const { dynamic } = await params
36+
37+
return (
38+
<main>
39+
<h1>Dynamic Page: {dynamic}</h1>
40+
<Suspense fallback={<div>loading...</div>}>
41+
<Content id={dynamic} />
42+
</Suspense>
43+
</main>
44+
)
45+
}

tests/integration/simple-app.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,10 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')<FixtureTestContext>(
394394
const blobEntries = await getBlobEntries(ctx)
395395
expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual(
396396
[
397+
'/1',
398+
'/2',
397399
'/404',
400+
'/[dynamic]',
398401
shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined,
399402
shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined,
400403
'/index',
@@ -407,6 +410,14 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')<FixtureTestContext>(
407410
const home = await invokeFunction(ctx)
408411
expect(home.statusCode).toBe(200)
409412
expect(load(home.body)('h1').text()).toBe('Home')
413+
414+
const dynamicPrerendered = await invokeFunction(ctx, { url: '/1' })
415+
expect(dynamicPrerendered.statusCode).toBe(200)
416+
expect(load(dynamicPrerendered.body)('h1').text()).toBe('Dynamic Page: 1')
417+
418+
const dynamicNotPrerendered = await invokeFunction(ctx, { url: '/3' })
419+
expect(dynamicNotPrerendered.statusCode).toBe(200)
420+
expect(load(dynamicNotPrerendered.body)('h1').text()).toBe('Dynamic Page: 3')
410421
},
411422
)
412423

0 commit comments

Comments
 (0)