Skip to content

Commit e970e05

Browse files
authoredOct 2, 2023
Reland static prefetches & fix prefetch bailout behavior (#56228)
Reland #54403 Also modifies the implementation of #55950 to not change the prefetch behavior when there is flight router state - we should only check the entire loader tree in the static prefetch case, otherwise we're inadvertently rendering the component tree for prefetches that match the current flight router state segment. ([slack x-ref](https://vercel.slack.com/archives/C03S8ED1DKM/p1695862974745849)) This includes a few other misc fixes for static prefetch generation: - `next start` was not serving them (which also meant tests weren't catching a few small bugs) - the router cache key casing can differ between build and runtime, resulting in a bad cache lookup which results suspending indefinitely during navigation - We cannot generate static prefetches for pages that opt into `searchParams`, as the prefetch response won't have the right cache key in the RSC payload - Layouts that use headers/cookies shouldn't use a static prefetch because it can result in unexpected behavior (ie, being redirected to a login page, if the prefetch contains redirect logic for unauthed users) Closes NEXT-1665 Closes NEXT-1643
1 parent be952fb commit e970e05

File tree

31 files changed

+474
-10
lines changed

31 files changed

+474
-10
lines changed
 

‎packages/next/src/build/index.ts

+21
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import { eventSwcPlugins } from '../telemetry/events/swc-plugins'
129129
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
130130
import {
131131
ACTION,
132+
NEXT_ROUTER_PREFETCH,
132133
RSC,
133134
RSC_CONTENT_TYPE_HEADER,
134135
RSC_VARY_HEADER,
@@ -227,6 +228,7 @@ export type RoutesManifest = {
227228
rsc: {
228229
header: typeof RSC
229230
varyHeader: typeof RSC_VARY_HEADER
231+
prefetchHeader: typeof NEXT_ROUTER_PREFETCH
230232
}
231233
skipMiddlewareUrlNormalize?: boolean
232234
caseSensitive?: boolean
@@ -795,6 +797,7 @@ export default async function build(
795797
rsc: {
796798
header: RSC,
797799
varyHeader: RSC_VARY_HEADER,
800+
prefetchHeader: NEXT_ROUTER_PREFETCH,
798801
contentTypeHeader: RSC_CONTENT_TYPE_HEADER,
799802
},
800803
skipMiddlewareUrlNormalize: config.skipMiddlewareUrlNormalize,
@@ -1055,6 +1058,7 @@ export default async function build(
10551058
const additionalSsgPaths = new Map<string, Array<string>>()
10561059
const additionalSsgPathsEncoded = new Map<string, Array<string>>()
10571060
const appStaticPaths = new Map<string, Array<string>>()
1061+
const appPrefetchPaths = new Map<string, string>()
10581062
const appStaticPathsEncoded = new Map<string, Array<string>>()
10591063
const appNormalizedPaths = new Map<string, string>()
10601064
const appDynamicParamPaths = new Set<string>()
@@ -1554,6 +1558,14 @@ export default async function build(
15541558
appDynamicParamPaths.add(originalAppPath)
15551559
}
15561560
appDefaultConfigs.set(originalAppPath, appConfig)
1561+
1562+
if (
1563+
!isStatic &&
1564+
!isAppRouteRoute(originalAppPath) &&
1565+
!isDynamicRoute(originalAppPath)
1566+
) {
1567+
appPrefetchPaths.set(originalAppPath, page)
1568+
}
15571569
}
15581570
} else {
15591571
if (isEdgeRuntime(pageRuntime)) {
@@ -2001,6 +2013,15 @@ export default async function build(
20012013
})
20022014
})
20032015

2016+
for (const [originalAppPath, page] of appPrefetchPaths) {
2017+
defaultMap[page] = {
2018+
page: originalAppPath,
2019+
query: {},
2020+
_isAppDir: true,
2021+
_isAppPrefetch: true,
2022+
}
2023+
}
2024+
20042025
if (i18n) {
20052026
for (const page of [
20062027
...staticPages,

‎packages/next/src/client/components/router-reducer/create-router-cache-key.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export function createRouterCacheKey(
55
withoutSearchParameters: boolean = false
66
) {
77
return Array.isArray(segment)
8-
? `${segment[0]}|${segment[1]}|${segment[2]}`
8+
? `${segment[0]}|${segment[1]}|${segment[2]}`.toLowerCase()
99
: withoutSearchParameters && segment.startsWith('__PAGE__')
1010
? '__PAGE__'
1111
: segment

‎packages/next/src/client/components/static-generation-async-storage.external.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface StaticGenerationStore {
3030
dynamicUsageDescription?: string
3131
dynamicUsageStack?: string
3232
dynamicUsageErr?: DynamicServerError
33+
staticPrefetchBailout?: boolean
3334

3435
nextFetchId?: number
3536
pathWasRevalidated?: boolean

‎packages/next/src/client/components/static-generation-bailout.ts

+6
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export const staticGenerationBailout: StaticGenerationBailout = (
3838

3939
if (staticGenerationStore) {
4040
staticGenerationStore.revalidate = 0
41+
42+
if (!opts?.dynamic) {
43+
// we can statically prefetch pages that opt into dynamic,
44+
// but not things like headers/cookies
45+
staticGenerationStore.staticPrefetchBailout = true
46+
}
4147
}
4248

4349
if (staticGenerationStore?.isStaticGeneration) {

‎packages/next/src/export/routes/app-page.ts

+62-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { NextParsedUrlQuery } from '../../server/request-meta'
66

77
import fs from 'fs/promises'
88
import { MockedRequest, MockedResponse } from '../../server/lib/mock-request'
9+
import {
10+
RSC,
11+
NEXT_URL,
12+
NEXT_ROUTER_PREFETCH,
13+
} from '../../client/components/app-router-headers'
914
import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
1015
import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
1116
import { hasNextSupport } from '../../telemetry/ci-info'
@@ -19,6 +24,37 @@ const render: AppPageRender = (...args) => {
1924
)
2025
}
2126

27+
export async function generatePrefetchRsc(
28+
req: MockedRequest,
29+
path: string,
30+
res: MockedResponse,
31+
pathname: string,
32+
htmlFilepath: string,
33+
renderOpts: RenderOpts
34+
) {
35+
req.headers[RSC.toLowerCase()] = '1'
36+
req.headers[NEXT_URL.toLowerCase()] = path
37+
req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] = '1'
38+
39+
renderOpts.supportsDynamicHTML = true
40+
renderOpts.isPrefetch = true
41+
delete renderOpts.isRevalidate
42+
43+
const prefetchRenderResult = await render(req, res, pathname, {}, renderOpts)
44+
45+
prefetchRenderResult.pipe(res)
46+
await res.hasStreamed
47+
48+
const prefetchRscData = Buffer.concat(res.buffers)
49+
50+
if ((renderOpts as any).store.staticPrefetchBailout) return
51+
52+
await fs.writeFile(
53+
htmlFilepath.replace(/\.html$/, '.prefetch.rsc'),
54+
prefetchRscData
55+
)
56+
}
57+
2258
export async function exportAppPage(
2359
req: MockedRequest,
2460
res: MockedResponse,
@@ -29,14 +65,28 @@ export async function exportAppPage(
2965
renderOpts: RenderOpts,
3066
htmlFilepath: string,
3167
debugOutput: boolean,
32-
isDynamicError: boolean
68+
isDynamicError: boolean,
69+
isAppPrefetch: boolean
3370
): Promise<ExportPageResult> {
3471
// If the page is `/_not-found`, then we should update the page to be `/404`.
3572
if (page === '/_not-found') {
3673
pathname = '/404'
3774
}
3875

3976
try {
77+
if (isAppPrefetch) {
78+
await generatePrefetchRsc(
79+
req,
80+
path,
81+
res,
82+
pathname,
83+
htmlFilepath,
84+
renderOpts
85+
)
86+
87+
return { fromBuildExportRevalidate: 0 }
88+
}
89+
4090
const result = await render(req, res, pathname, query, renderOpts)
4191
const html = result.toUnchunkedString()
4292
const { metadata } = result
@@ -50,6 +100,17 @@ export async function exportAppPage(
50100
)
51101
}
52102

103+
if (!(renderOpts as any).store.staticPrefetchBailout) {
104+
await generatePrefetchRsc(
105+
req,
106+
path,
107+
res,
108+
pathname,
109+
htmlFilepath,
110+
renderOpts
111+
)
112+
}
113+
53114
const { staticBailoutInfo = {} } = metadata
54115

55116
if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) {

‎packages/next/src/export/worker.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ async function exportPageImpl(input: ExportPageInput) {
108108
// Check if this is an `app/` page.
109109
_isAppDir: isAppDir = false,
110110

111+
// Check if this is an `app/` prefix request.
112+
_isAppPrefetch: isAppPrefetch = false,
113+
111114
// Check if this should error when dynamic usage is detected.
112115
_isDynamicError: isDynamicError = false,
113116

@@ -306,7 +309,8 @@ async function exportPageImpl(input: ExportPageInput) {
306309
renderOpts,
307310
htmlFilepath,
308311
debugOutput,
309-
isDynamicError
312+
isDynamicError,
313+
isAppPrefetch
310314
)
311315
}
312316

‎packages/next/src/server/app-render/app-render.tsx

+9-6
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,13 @@ export const renderToHTMLOrFlight: AppPageRender = (
11061106
// Explicit refresh
11071107
flightRouterState[3] === 'refetch'
11081108

1109+
const shouldSkipComponentTree =
1110+
isPrefetch &&
1111+
!Boolean(components.loading) &&
1112+
(flightRouterState ||
1113+
// If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root
1114+
!hasLoadingComponentInTree(loaderTree))
1115+
11091116
if (!parentRendered && renderComponentsOnThisLevel) {
11101117
const overriddenSegment =
11111118
flightRouterState &&
@@ -1122,9 +1129,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
11221129
getDynamicParamFromSegment,
11231130
query
11241131
),
1125-
isPrefetch &&
1126-
!Boolean(components.loading) &&
1127-
!hasLoadingComponentInTree(loaderTree)
1132+
shouldSkipComponentTree
11281133
? null
11291134
: // Create component tree using the slice of the loaderTree
11301135
// @ts-expect-error TODO-APP: fix async component type
@@ -1147,9 +1152,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
11471152

11481153
return <Component />
11491154
}),
1150-
isPrefetch &&
1151-
!Boolean(components.loading) &&
1152-
!hasLoadingComponentInTree(loaderTree)
1155+
shouldSkipComponentTree
11531156
? null
11541157
: (() => {
11551158
const { layoutOrPagePath } =

‎packages/next/src/server/app-render/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export type RenderOptsPartial = {
131131
) => Promise<NextConfigComplete>
132132
serverActionsBodySizeLimit?: SizeLimit
133133
params?: ParsedUrlQuery
134+
isPrefetch?: boolean
134135
}
135136

136137
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial

‎packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type StaticGenerationContext = {
1212
isRevalidate?: boolean
1313
isOnDemandRevalidate?: boolean
1414
isBot?: boolean
15+
isPrefetch?: boolean
1516
nextExport?: boolean
1617
fetchCache?: StaticGenerationStore['fetchCache']
1718
isDraftMode?: boolean

‎packages/next/src/server/base-server.ts

+23
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ import {
8686
FLIGHT_PARAMETERS,
8787
NEXT_RSC_UNION_QUERY,
8888
ACTION,
89+
NEXT_ROUTER_PREFETCH,
90+
RSC_CONTENT_TYPE_HEADER,
8991
} from '../client/components/app-router-headers'
9092
import {
9193
MatchOptions,
@@ -2124,6 +2126,27 @@ export default abstract class Server<ServerOptions extends Options = Options> {
21242126
} else if (
21252127
components.routeModule?.definition.kind === RouteKind.APP_PAGE
21262128
) {
2129+
const isAppPrefetch = req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()]
2130+
2131+
if (isAppPrefetch && process.env.NODE_ENV === 'production') {
2132+
try {
2133+
const prefetchRsc = await this.getPrefetchRsc(resolvedUrlPathname)
2134+
2135+
if (prefetchRsc) {
2136+
res.setHeader(
2137+
'cache-control',
2138+
'private, no-cache, no-store, max-age=0, must-revalidate'
2139+
)
2140+
res.setHeader('content-type', RSC_CONTENT_TYPE_HEADER)
2141+
res.body(prefetchRsc).send()
2142+
return null
2143+
}
2144+
} catch (_) {
2145+
// we fallback to invoking the function if prefetch
2146+
// data is not available
2147+
}
2148+
}
2149+
21272150
const module = components.routeModule as AppPageRouteModule
21282151

21292152
// Due to the way we pass data by mutating `renderOpts`, we can't extend the

‎packages/next/src/server/render.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export type RenderOptsPartial = {
283283
deploymentId?: string
284284
isServerAction?: boolean
285285
isExperimentalCompile?: boolean
286+
isPrefetch?: boolean
286287
}
287288

288289
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createNextDescribe } from 'e2e-utils'
2+
import { waitFor } from 'next-test-utils'
3+
4+
createNextDescribe(
5+
'app-prefetch-static',
6+
{
7+
files: __dirname,
8+
},
9+
({ next, isNextDev }) => {
10+
if (isNextDev) {
11+
it('should skip next dev', () => {})
12+
return
13+
}
14+
15+
it('should correctly navigate between static & dynamic pages', async () => {
16+
const browser = await next.browser('/')
17+
// Ensure the page is prefetched
18+
await waitFor(1000)
19+
20+
await browser.elementByCss('#static-prefetch').click()
21+
22+
expect(await browser.elementByCss('#static-prefetch-page').text()).toBe(
23+
'Hello from Static Prefetch Page'
24+
)
25+
26+
await browser.elementByCss('#dynamic-prefetch').click()
27+
28+
expect(await browser.elementByCss('#dynamic-prefetch-page').text()).toBe(
29+
'Hello from Dynamic Prefetch Page'
30+
)
31+
32+
await browser.elementByCss('#static-prefetch').click()
33+
34+
expect(await browser.elementByCss('#static-prefetch-page').text()).toBe(
35+
'Hello from Static Prefetch Page'
36+
)
37+
})
38+
}
39+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default async function DynamicPage({ params, searchParams }) {
2+
return <div id="dynamic-prefetch-page">Hello from Dynamic Prefetch Page</div>
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const regions = ['SE', 'DE']
2+
3+
export default async function Layout({ children, params }) {
4+
return children
5+
}
6+
7+
export function generateStaticParams() {
8+
return regions.map((region) => ({
9+
region,
10+
}))
11+
}
12+
13+
export const dynamicParams = false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const dynamic = 'force-dynamic'
2+
3+
export default async function StaticPrefetchPage({ params }) {
4+
return (
5+
<div id="static-prefetch-page">
6+
<h1>Hello from Static Prefetch Page</h1>
7+
</div>
8+
)
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Link from 'next/link'
2+
export default function RootLayout({ children }) {
3+
return (
4+
<html>
5+
<body>
6+
{children}
7+
8+
<Link href="/se/static-prefetch" id="static-prefetch">
9+
Static Prefetch
10+
</Link>
11+
<Link href="/se/dynamic-area/slug" id="dynamic-prefetch">
12+
Dynamic Prefetch
13+
</Link>
14+
</body>
15+
</html>
16+
)
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Main() {
2+
return <div>Main Page</div>
3+
}

0 commit comments

Comments
 (0)
Please sign in to comment.