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:
Zack Tanner
2026-07-29 15:49:57 -07:00
committed by GitHub
co-authored by Sebastian Silbermann
parent 5cdcbbb05b
commit 67f16a522d
6 changed files with 153 additions and 6 deletions
@@ -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