Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reland static prefetches & fix prefetch bailout behavior #56228

Merged
merged 16 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import { eventSwcPlugins } from '../telemetry/events/swc-plugins'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import {
ACTION,
NEXT_ROUTER_PREFETCH,
RSC,
RSC_CONTENT_TYPE_HEADER,
RSC_VARY_HEADER,
Expand Down Expand Up @@ -227,6 +228,7 @@ export type RoutesManifest = {
rsc: {
header: typeof RSC
varyHeader: typeof RSC_VARY_HEADER
prefetchHeader: typeof NEXT_ROUTER_PREFETCH
}
skipMiddlewareUrlNormalize?: boolean
caseSensitive?: boolean
Expand Down Expand Up @@ -795,6 +797,7 @@ export default async function build(
rsc: {
header: RSC,
varyHeader: RSC_VARY_HEADER,
prefetchHeader: NEXT_ROUTER_PREFETCH,
contentTypeHeader: RSC_CONTENT_TYPE_HEADER,
},
skipMiddlewareUrlNormalize: config.skipMiddlewareUrlNormalize,
Expand Down Expand Up @@ -1055,6 +1058,7 @@ export default async function build(
const additionalSsgPaths = new Map<string, Array<string>>()
const additionalSsgPathsEncoded = new Map<string, Array<string>>()
const appStaticPaths = new Map<string, Array<string>>()
const appPrefetchPaths = new Map<string, string>()
const appStaticPathsEncoded = new Map<string, Array<string>>()
const appNormalizedPaths = new Map<string, string>()
const appDynamicParamPaths = new Set<string>()
Expand Down Expand Up @@ -1554,6 +1558,14 @@ export default async function build(
appDynamicParamPaths.add(originalAppPath)
}
appDefaultConfigs.set(originalAppPath, appConfig)

if (
!isStatic &&
!isAppRouteRoute(originalAppPath) &&
!isDynamicRoute(originalAppPath)
) {
appPrefetchPaths.set(originalAppPath, page)
}
}
} else {
if (isEdgeRuntime(pageRuntime)) {
Expand Down Expand Up @@ -2001,6 +2013,15 @@ export default async function build(
})
})

for (const [originalAppPath, page] of appPrefetchPaths) {
defaultMap[page] = {
page: originalAppPath,
query: {},
_isAppDir: true,
_isAppPrefetch: true,
}
}

if (i18n) {
for (const page of [
...staticPages,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function createRouterCacheKey(
withoutSearchParameters: boolean = false
) {
return Array.isArray(segment)
? `${segment[0]}|${segment[1]}|${segment[2]}`
? `${segment[0]}|${segment[1]}|${segment[2]}`.toLowerCase()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we not enforce the segments on the server to always be lowercased instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will follow up to see where the casing diff was coming from and if there’s a better spot to address the differences here 👍

: withoutSearchParameters && segment.startsWith('__PAGE__')
? '__PAGE__'
: segment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface StaticGenerationStore {
dynamicUsageDescription?: string
dynamicUsageStack?: string
dynamicUsageErr?: DynamicServerError
staticPrefetchBailout?: boolean

nextFetchId?: number
pathWasRevalidated?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export const staticGenerationBailout: StaticGenerationBailout = (

if (staticGenerationStore) {
staticGenerationStore.revalidate = 0

if (!opts?.dynamic) {
// we can statically prefetch pages that opt into dynamic,
// but not things like headers/cookies
staticGenerationStore.staticPrefetchBailout = true
}
}

if (staticGenerationStore?.isStaticGeneration) {
Expand Down
63 changes: 62 additions & 1 deletion packages/next/src/export/routes/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type { NextParsedUrlQuery } from '../../server/request-meta'

import fs from 'fs/promises'
import { MockedRequest, MockedResponse } from '../../server/lib/mock-request'
import {
RSC,
NEXT_URL,
NEXT_ROUTER_PREFETCH,
} from '../../client/components/app-router-headers'
import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
import { hasNextSupport } from '../../telemetry/ci-info'
Expand All @@ -19,6 +24,37 @@ const render: AppPageRender = (...args) => {
)
}

export async function generatePrefetchRsc(
req: MockedRequest,
path: string,
res: MockedResponse,
pathname: string,
htmlFilepath: string,
renderOpts: RenderOpts
) {
req.headers[RSC.toLowerCase()] = '1'
req.headers[NEXT_URL.toLowerCase()] = path
req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] = '1'

renderOpts.supportsDynamicHTML = true
renderOpts.isPrefetch = true
delete renderOpts.isRevalidate

const prefetchRenderResult = await render(req, res, pathname, {}, renderOpts)

prefetchRenderResult.pipe(res)
await res.hasStreamed

const prefetchRscData = Buffer.concat(res.buffers)

if ((renderOpts as any).store.staticPrefetchBailout) return

await fs.writeFile(
htmlFilepath.replace(/\.html$/, '.prefetch.rsc'),
prefetchRscData
)
}

export async function exportAppPage(
req: MockedRequest,
res: MockedResponse,
Expand All @@ -29,14 +65,28 @@ export async function exportAppPage(
renderOpts: RenderOpts,
htmlFilepath: string,
debugOutput: boolean,
isDynamicError: boolean
isDynamicError: boolean,
isAppPrefetch: boolean
): Promise<ExportPageResult> {
// If the page is `/_not-found`, then we should update the page to be `/404`.
if (page === '/_not-found') {
pathname = '/404'
}

try {
if (isAppPrefetch) {
await generatePrefetchRsc(
req,
path,
res,
pathname,
htmlFilepath,
renderOpts
)

return { fromBuildExportRevalidate: 0 }
}

const result = await render(req, res, pathname, query, renderOpts)
const html = result.toUnchunkedString()
const { metadata } = result
Expand All @@ -50,6 +100,17 @@ export async function exportAppPage(
)
}

if (!(renderOpts as any).store.staticPrefetchBailout) {
await generatePrefetchRsc(
req,
path,
res,
pathname,
htmlFilepath,
renderOpts
)
}

const { staticBailoutInfo = {} } = metadata

if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) {
Expand Down
6 changes: 5 additions & 1 deletion packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ async function exportPageImpl(input: ExportPageInput) {
// Check if this is an `app/` page.
_isAppDir: isAppDir = false,

// Check if this is an `app/` prefix request.
_isAppPrefetch: isAppPrefetch = false,

// Check if this should error when dynamic usage is detected.
_isDynamicError: isDynamicError = false,

Expand Down Expand Up @@ -306,7 +309,8 @@ async function exportPageImpl(input: ExportPageInput) {
renderOpts,
htmlFilepath,
debugOutput,
isDynamicError
isDynamicError,
isAppPrefetch
)
}

Expand Down
15 changes: 9 additions & 6 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,13 @@ export const renderToHTMLOrFlight: AppPageRender = (
// Explicit refresh
flightRouterState[3] === 'refetch'

const shouldSkipComponentTree =
isPrefetch &&
!Boolean(components.loading) &&
(flightRouterState ||
// If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root
!hasLoadingComponentInTree(loaderTree))

if (!parentRendered && renderComponentsOnThisLevel) {
const overriddenSegment =
flightRouterState &&
Expand All @@ -1122,9 +1129,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
getDynamicParamFromSegment,
query
),
isPrefetch &&
!Boolean(components.loading) &&
!hasLoadingComponentInTree(loaderTree)
shouldSkipComponentTree
? null
: // Create component tree using the slice of the loaderTree
// @ts-expect-error TODO-APP: fix async component type
Expand All @@ -1147,9 +1152,7 @@ export const renderToHTMLOrFlight: AppPageRender = (

return <Component />
}),
isPrefetch &&
!Boolean(components.loading) &&
!hasLoadingComponentInTree(loaderTree)
shouldSkipComponentTree
? null
: (() => {
const { layoutOrPagePath } =
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/app-render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export type RenderOptsPartial = {
) => Promise<NextConfigComplete>
serverActionsBodySizeLimit?: SizeLimit
params?: ParsedUrlQuery
isPrefetch?: boolean
}

export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type StaticGenerationContext = {
isRevalidate?: boolean
isOnDemandRevalidate?: boolean
isBot?: boolean
isPrefetch?: boolean
nextExport?: boolean
fetchCache?: StaticGenerationStore['fetchCache']
isDraftMode?: boolean
Expand Down
23 changes: 23 additions & 0 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import {
FLIGHT_PARAMETERS,
NEXT_RSC_UNION_QUERY,
ACTION,
NEXT_ROUTER_PREFETCH,
RSC_CONTENT_TYPE_HEADER,
} from '../client/components/app-router-headers'
import {
MatchOptions,
Expand Down Expand Up @@ -2124,6 +2126,27 @@ export default abstract class Server<ServerOptions extends Options = Options> {
} else if (
components.routeModule?.definition.kind === RouteKind.APP_PAGE
) {
const isAppPrefetch = req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()]

if (isAppPrefetch && process.env.NODE_ENV === 'production') {
try {
const prefetchRsc = await this.getPrefetchRsc(resolvedUrlPathname)

if (prefetchRsc) {
res.setHeader(
'cache-control',
'private, no-cache, no-store, max-age=0, must-revalidate'
)
res.setHeader('content-type', RSC_CONTENT_TYPE_HEADER)
res.body(prefetchRsc).send()
return null
}
} catch (_) {
// we fallback to invoking the function if prefetch
// data is not available
}
}

const module = components.routeModule as AppPageRouteModule

// Due to the way we pass data by mutating `renderOpts`, we can't extend the
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export type RenderOptsPartial = {
deploymentId?: string
isServerAction?: boolean
isExperimentalCompile?: boolean
isPrefetch?: boolean
}

export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
Expand Down
39 changes: 39 additions & 0 deletions test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createNextDescribe } from '../../../lib/e2e-utils'
import { waitFor } from '../../../lib/next-test-utils'

createNextDescribe(
'app-prefetch-static',
{
files: __dirname,
},
({ next, isNextDev }) => {
if (isNextDev) {
it('should skip next dev', () => {})
return
}

it('should correctly navigate between static & dynamic pages', async () => {
const browser = await next.browser('/')
// Ensure the page is prefetched
await waitFor(1000)

await browser.elementByCss('#static-prefetch').click()

expect(await browser.elementByCss('#static-prefetch-page').text()).toBe(
'Hello from Static Page'
)

await browser.elementByCss('#dynamic-prefetch').click()

expect(await browser.elementByCss('#dynamic-prefetch-page').text()).toBe(
'Hello from Dynamic Page'
)

await browser.elementByCss('#static-prefetch').click()

expect(await browser.elementByCss('#static-prefetch-page').text()).toBe(
'Hello from Static Page'
)
})
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default async function DynamicPage({ params, searchParams }) {
return <div id="dynamic-prefetch-page">Hello from Dynamic Page</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const regions = ['SE', 'DE']

export default async function Layout({ children, params }) {
return children
}

export function generateStaticParams() {
return regions.map((region) => ({
region,
}))
}

export const dynamicParams = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const dynamic = 'force-dynamic'

export default async function StaticPrefetchPage({ params }) {
return (
<div id="static-prefetch-page">
<h1>Hello from Static Page</h1>
</div>
)
}
17 changes: 17 additions & 0 deletions test/e2e/app-dir/app-prefetch-static/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Link from 'next/link'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}

<Link href="/se/static-prefetch" id="static-prefetch">
Static Prefetch
</Link>
<Link href="/se/dynamic-area/slug" id="dynamic-prefetch">
Dynamic Prefetch
</Link>
</body>
</html>
)
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/app-prefetch-static/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Main() {
return <div>Main Page</div>
}
Loading
Loading