Fix invalid HTML response for route-level RSC requests in deployment adapter (#91541)

In a deployed environment, `onCacheEntryV2` determines whether a
postponed response is HTML-origin or RSC-origin by checking whether
meta.url ends with `.rsc`.

For some dynamic full-route RSC requests, Next was passing a URL with
query params into onCacheEntry, for example:
`/[tenant]/samples.rsc?nxtPtenant=tenant-x`

Because that string does not end with .rsc, `onCacheEntryV2`
misclassified the request as HTML and treated it as text/html instead of
an RSC response.

This PR normalizes the URL before passing it to `onCacheEntry`, so the
callback receives the pathname rather than the full URL-with-query.

---------

Co-authored-by: JJ Kasper <[email protected]>
This commit is contained in:
Zack Tanner
2026-03-18 12:17:31 -07:00
committed by GitHub
co-authored by JJ Kasper
parent 0cf61307c6
commit 56d75a0b77
6 changed files with 184 additions and 1 deletions
@@ -73,6 +73,7 @@ import {
getPostponedStateExceededErrorMessage,
readBodyWithSizeLimit,
} from '../../server/lib/postponed-request-body'
import { parseUrl } from '../../lib/url'
// These are injected by the loader afterwards.
@@ -1572,9 +1573,15 @@ export async function handler(
getRequestMeta(req, 'onCacheEntry'))
: getRequestMeta(req, 'onCacheEntry')
if (onCacheEntry) {
const rawCacheEntryUrl = getRequestMeta(req, 'initURL') ?? req.url
const cacheEntryUrl = rawCacheEntryUrl
? (parseUrl(rawCacheEntryUrl)?.pathname ?? rawCacheEntryUrl)
: undefined
const finished = await onCacheEntry(cacheEntry, {
url: getRequestMeta(req, 'initURL') ?? req.url,
url: cacheEntryUrl,
})
if (finished) return null
}
@@ -0,0 +1,52 @@
import { Suspense } from 'react'
import type { ReactNode } from 'react'
export function generateStaticParams() {
return [{ tenant: 'tenant-a' }, { tenant: 'tenant-b' }]
}
async function CachedTenantMarker({
params,
}: {
params: Promise<{ tenant: string }>
}) {
'use cache'
return (
<div hidden id="cached-tenant-marker">
{(await params).tenant}
</div>
)
}
async function EnsureTenant({
params,
}: {
params: Promise<{ tenant: string }>
}) {
const { tenant } = await params
if (typeof tenant !== 'string') {
throw new Error('Failed to read `tenant` from params')
}
return null
}
export default function TenantLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ tenant: string }>
}) {
return (
<>
<Suspense>{children}</Suspense>
<Suspense fallback={null}>
<CachedTenantMarker params={params} />
</Suspense>
<EnsureTenant params={params} />
</>
)
}
@@ -0,0 +1,63 @@
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
import { connection } from 'next/server'
async function CachedShell({ tenant }: { tenant: string }) {
'use cache'
cacheLife({
stale: 300,
revalidate: 300,
expire: 3600,
})
cacheTag(`sample-shell:${tenant}`)
return (
<>
<p id="cached-tenant">
Tenant: <strong>{tenant}</strong>
</p>
<p id="cached-at">
Cached shell token: <strong>{crypto.randomUUID()}</strong>
</p>
</>
)
}
async function DynamicRequestContent({
params,
}: {
params: Promise<{ tenant: string }>
}) {
const { tenant } = await params
await connection()
return (
<p id="dynamic">
Request-time content for <strong>{tenant}</strong>.
</p>
)
}
export default function SamplesPage({
params,
}: {
params: Promise<{ tenant: string }>
}) {
const shell = params.then(async ({ tenant }) => (
<CachedShell tenant={tenant} />
))
return (
<main>
<h1>Samples Route</h1>
<Suspense fallback={<p id="shell-fallback">Loading cached shell...</p>}>
{shell}
</Suspense>
<Suspense fallback={<p id="fallback">Loading request-time content...</p>}>
<DynamicRequestContent params={params} />
</Suspense>
</main>
)
}
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,11 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
cacheComponents: true,
experimental: {
rootParams: true,
},
}
module.exports = nextConfig
@@ -0,0 +1,41 @@
import { nextTestSetup } from 'e2e-utils'
describe('ppr-root-param-rsc-fallback', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
it('skipped in dev', () => {})
return
}
it('does not return HTML for a pregenerated root param full-route rsc request', async () => {
const response = await next.fetch(`/tenant-a/samples`, {
headers: {
RSC: '1',
},
})
const body = await response.text()
const contentType = response.headers.get('content-type') ?? ''
expect(response.status).toBe(200)
expect(contentType).toContain('text/x-component')
expect(body).not.toContain('<!DOCTYPE html>')
})
it('does not return HTML for a non-pregenerated root param full-route rsc request', async () => {
const tenant = `tenant-${Date.now()}`
const response = await next.fetch(`/${tenant}/samples`, {
headers: {
RSC: '1',
},
})
const body = await response.text()
const contentType = response.headers.get('content-type') ?? ''
expect(response.status).toBe(200)
expect(contentType).toContain('text/x-component')
expect(body).not.toContain('<!DOCTYPE html>')
})
})