mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Fix PPR rendering for configured HTML bots (#96364)
## Summary When PPR is enabled, requests matching a custom `htmlLimitedBots` pattern could still use the prerendered route path. As a result, the configured blocking-metadata behavior was not applied consistently. Use the existing streaming-metadata decision when selecting the PPR render path. Built-in bots retain their existing fully buffered behavior, custom HTML-limited bots can continue streaming body content after metadata resolves, and regular user agents remain unchanged. ## Verification - HEADLESS=true pnpm test-start-turbo test/e2e/app-dir/html-limited-bots-ppr/html-limited-bots-ppr.test.ts - HEADLESS=true pnpm test-start-webpack test/e2e/app-dir/html-limited-bots-ppr/html-limited-bots-ppr.test.ts Co-authored-by: Sebastian Silbermann <[email protected]>
This commit is contained in:
co-authored by
Sebastian Silbermann
parent
5cdcbbb05b
commit
67f16a522d
@@ -546,14 +546,17 @@ export function createAppPageEntrypoint({
|
||||
? true
|
||||
: shouldServeStreamingMetadata(userAgent, nextConfig.htmlLimitedBots)
|
||||
|
||||
// PPR shells are generated for streaming metadata. Requests that require
|
||||
// blocking metadata must bypass the shell so the prerender and dynamic
|
||||
// render use the same metadata tree.
|
||||
const shouldForceDynamicPPRRender =
|
||||
isRoutePPREnabled && !serveStreamingMetadata
|
||||
|
||||
const isSSG = Boolean(
|
||||
(prerenderInfo ||
|
||||
isPrerendered ||
|
||||
prerenderManifest.routes[normalizedSrcPage]) &&
|
||||
// If this is a bot request and PPR is enabled, then we don't want
|
||||
// to serve a static response. This applies to both DOM bots (like Googlebot)
|
||||
// and HTML-limited bots.
|
||||
!(botType && isRoutePPREnabled)
|
||||
!shouldForceDynamicPPRRender
|
||||
)
|
||||
|
||||
// When a page supports cacheComponents, we can support RDC for Navigations
|
||||
@@ -585,8 +588,9 @@ export function createAppPageEntrypoint({
|
||||
: // Otherwise, we can support dynamic responses if it's a dynamic RSC request.
|
||||
isDynamicRSCRequest)
|
||||
|
||||
// When bots request PPR page, perform the full dynamic rendering.
|
||||
// This applies to both DOM bots (like Googlebot) and HTML-limited bots.
|
||||
// The fixed bot list historically waits for the entire PPR render. A bot
|
||||
// configured only through htmlLimitedBots may stream after its metadata
|
||||
// resolves.
|
||||
const shouldWaitOnAllReady = Boolean(botType) && isRoutePPREnabled
|
||||
const remainingPrerenderableParams =
|
||||
prerenderInfo?.remainingPrerenderableParams ?? []
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ReactNode } from 'react'
|
||||
export default function Root({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <p>hello world</p>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Suspense } from 'react'
|
||||
import { connection } from 'next/server'
|
||||
|
||||
async function Dynamic({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ stream?: string }>
|
||||
}) {
|
||||
await connection()
|
||||
|
||||
if ((await searchParams).stream === '1') {
|
||||
await new Promise<never>(() => {})
|
||||
}
|
||||
|
||||
return <p id="dynamic-content">dynamic content</p>
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ stream?: string }>
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 id="static-content">static shell</h1>
|
||||
<Suspense fallback={<p id="dynamic-fallback">dynamic fallback</p>}>
|
||||
<Dynamic searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
await connection()
|
||||
return {
|
||||
title: 'dynamic title',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isNextDev, nextTestSetup } from 'e2e-utils'
|
||||
import cheerio from 'cheerio'
|
||||
;(isNextDev ? describe.skip : describe)('html-limited-bots-ppr', () => {
|
||||
const { next, isNextDeploy } = nextTestSetup({
|
||||
files: __dirname,
|
||||
})
|
||||
|
||||
it('should serve a fully dynamic render with blocking metadata to a configured HTML-limited bot', async () => {
|
||||
const res = await next.fetch('/partial', {
|
||||
headers: {
|
||||
'user-agent': 'MyBot',
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The deployment proxy consumes this internal header.
|
||||
if (!isNextDeploy) {
|
||||
expect(res.headers.get('x-nextjs-postponed')).toBeNull()
|
||||
}
|
||||
|
||||
const $ = cheerio.load(await res.text())
|
||||
expect($('head title').text()).toBe('dynamic title')
|
||||
expect($('body title').length).toBe(0)
|
||||
expect($('#dynamic-content').text()).toBe('dynamic content')
|
||||
expect($('#dynamic-fallback').length).toBe(0)
|
||||
})
|
||||
|
||||
it('should serve the partially prerendered shell with streamed metadata to regular user agents', async () => {
|
||||
const res = await next.fetch('/partial')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The deployment proxy consumes this internal header.
|
||||
if (!isNextDeploy) {
|
||||
expect(res.headers.get('x-nextjs-postponed')).toBe('1')
|
||||
}
|
||||
|
||||
const $ = cheerio.load(await res.text())
|
||||
expect($('body title').text()).toBe('dynamic title')
|
||||
expect($('#dynamic-content').text()).toBe('dynamic content')
|
||||
})
|
||||
|
||||
it('should continue streaming the body after blocking metadata for a configured HTML-limited bot', async () => {
|
||||
const abortController = new AbortController()
|
||||
let body:
|
||||
| (AsyncIterable<Uint8Array> & {
|
||||
destroy: () => void
|
||||
})
|
||||
| undefined
|
||||
|
||||
try {
|
||||
const res = await next.fetch('/partial?stream=1', {
|
||||
headers: {
|
||||
'user-agent': 'MyBot',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The deployment proxy consumes this internal header.
|
||||
if (!isNextDeploy) {
|
||||
expect(res.headers.get('x-nextjs-postponed')).toBeNull()
|
||||
}
|
||||
expect(res.body).not.toBeNull()
|
||||
|
||||
body = res.body! as unknown as AsyncIterable<Uint8Array> & {
|
||||
destroy: () => void
|
||||
}
|
||||
let initialHtml = ''
|
||||
|
||||
for await (const chunk of body) {
|
||||
initialHtml += Buffer.from(chunk).toString()
|
||||
if (initialHtml.includes('dynamic-fallback')) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(initialHtml).toContain('<title>dynamic title</title>')
|
||||
expect(initialHtml).toContain('dynamic-fallback')
|
||||
expect(initialHtml).not.toContain('dynamic-content')
|
||||
} finally {
|
||||
abortController.abort()
|
||||
body?.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
cacheComponents: true,
|
||||
htmlLimitedBots: /MyBot/i,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
Reference in New Issue
Block a user