mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user