Fix(pages-router): restore Content-Length and ETag for /_next/data/ JSON responses (#90304)

## What

Removes the `Buffer.from()` wrapper when constructing `RenderResult` for
`/_next/data/` JSON responses in the Pages Router handler.

## Why

PR #80189 introduced
`Buffer.from(JSON.stringify(result.value.pageData))`
when building the data response. Since `RenderResult.isDynamic` checks
`typeof this.response !== 'string'`, passing a `Buffer` (not a `string`)
caused it to return `true`, making `sendRenderResult` treat the response
as a dynamic stream — skipping `Content-Length` and `ETag` generation
and
falling back to `Transfer-Encoding: chunked`.

This is a regression from v15.4.0 and breaks CDN-side compression for
self-hosted deployments (e.g. CloudFront requires `Content-Length` to
compress origin responses on-the-fly).

## Fix

```diff
- Buffer.from(JSON.stringify(result.value.pageData)),
+ JSON.stringify(result.value.pageData),
```
## Testing
- Reproduction steps verified against the reporter's repro repo:
https://github.com/bbrouse/nextjs-content-length-repro
- ```diffcurl -sD - on /_next/data/<BUILD_ID>/index.json``` now returns
Content-Length and ETag headers.
## Affected Area
- Pages Router — /_next/data/ responses only
- No impact on App Router
- Single-line change, minimal blast radius

Fixes #90281

---------

Co-authored-by: JJ Kasper <[email protected]>
This commit is contained in:
Henry
2026-03-18 15:24:23 -07:00
committed by GitHub
co-authored by JJ Kasper
parent fb85660ab1
commit 962e5b166c
3 changed files with 31 additions and 25 deletions
+4 -4
View File
@@ -148,13 +148,13 @@ pnpm test-dev-turbo test/development/
Generating tests using `pnpm new-test` is mandatory.
```bash
# Use --args for non-interactive mode
# Format: pnpm new-test --args <appDir> <name> <type>
# Use --args for non-interactive mode (forward args to the script using `--`)
# Format: pnpm new-test -- --args <appDir> <name> <type>
# appDir: true/false (is this for app directory?)
# name: test name (e.g. "my-feature")
# type: e2e | production | development | unit
pnpm new-test --args true my-feature e2e
pnpm new-test -- --args true my-feature e2e
```
**Analyzing test output efficiently:**
@@ -400,7 +400,7 @@ Core runtime/bundling rules (always apply; skills above expand on these with ver
### Test Gotchas
- **Cache components enables PPR by default**: When `__NEXT_CACHE_COMPONENTS=true`, most app-dir pages use PPR implicitly. Dedicated `ppr-full/` and `ppr/` test suites are mostly `describe.skip` (migrating to cache components). To test PPR codepaths, run normal app-dir e2e tests with `__NEXT_CACHE_COMPONENTS=true` rather than looking for explicit PPR test suites.
- **Quick smoke testing with toy apps**: For fast feedback, generate a minimal test fixture with `pnpm new-test --args true <name> e2e`, then run the dev server directly with `node packages/next/dist/bin/next dev --port <port>` and `curl --max-time 10`. This avoids the overhead of the full test harness and gives immediate feedback on hangs/crashes.
-- **Quick smoke testing with toy apps**: For fast feedback, generate a minimal test fixture with `pnpm new-test -- --args true <name> e2e`, then run the dev server directly with `node packages/next/dist/bin/next dev --port <port>` and `curl --max-time 10`. This avoids the overhead of the full test harness and gives immediate feedback on hangs/crashes.
- Mode-specific tests need `skipStart: true` + manual `next.start()` in `beforeAll` after mode check
- Don't rely on exact log messages - filter by content patterns, find sequences not positions
- **Snapshot tests vary by env flags**: Tests with inline snapshots can produce different output depending on env flags. When updating snapshots, always run the test with the exact env flags the CI job uses (check `.github/workflows/build_and_test.yml` `afterBuild:` sections). Turbopack resolves `react-dom/server.edge` (no Node APIs like `renderToPipeableStream`), while webpack resolves the `.node` build (has them).
@@ -526,16 +526,13 @@ export const getHandler = ({
return {
value: {
kind: CachedRouteKind.PAGES,
html: new RenderResult(
Buffer.from(previousCacheEntry.value.html),
{
contentType: HTML_CONTENT_TYPE_HEADER,
metadata: {
statusCode: previousCacheEntry.value.status,
headers: previousCacheEntry.value.headers,
},
}
),
html: new RenderResult(previousCacheEntry.value.html, {
contentType: HTML_CONTENT_TYPE_HEADER,
metadata: {
statusCode: previousCacheEntry.value.status,
headers: previousCacheEntry.value.headers,
},
}),
pageData: {},
status: previousCacheEntry.value.status,
headers: previousCacheEntry.value.headers,
@@ -740,13 +737,10 @@ export const getHandler = ({
// anymore
result:
isNextDataRequest && !isErrorPage && !is500Page
? new RenderResult(
Buffer.from(JSON.stringify(result.value.pageData)),
{
contentType: JSON_CONTENT_TYPE_HEADER,
metadata: result.value.html.metadata,
}
)
? new RenderResult(JSON.stringify(result.value.pageData), {
contentType: JSON_CONTENT_TYPE_HEADER,
metadata: result.value.html.metadata,
})
: result.value.html,
generateEtags: nextConfig.generateEtags,
poweredByHeader: nextConfig.poweredByHeader,
+16 -4
View File
@@ -2063,7 +2063,10 @@ describe('Prerender', () => {
it('should handle revalidating JSON correctly', async () => {
const route = `/_next/data/${next.buildId}/blog/post-2/comment-3.json`
const initialJson = await renderViaHTTP(next.url, route)
const initialRes = await fetchViaHTTP(next.url, route)
const initialJson = await initialRes.text()
expect(initialRes.headers.get('Content-Length')).toBeDefined()
expect(initialRes.headers.get('ETag')).toBeDefined()
expect(initialJson).toMatch(/post-2/)
expect(initialJson).toMatch(/comment-3/)
@@ -2078,7 +2081,10 @@ describe('Prerender', () => {
await renderViaHTTP(next.url, route)
await check(async () => {
newJson = await renderViaHTTP(next.url, route)
const newRes = await fetchViaHTTP(next.url, route)
expect(newRes.headers.get('Content-Length')).toBeDefined()
expect(newRes.headers.get('ETag')).toBeDefined()
newJson = await newRes.text()
return newJson !== initialJson ? 'success' : newJson
}, 'success')
@@ -2135,7 +2141,10 @@ describe('Prerender', () => {
it('should handle revalidating HTML correctly with blocking and seed', async () => {
const route = '/blocking-fallback/a'
const initialHtml = await renderViaHTTP(next.url, route)
const initialRes = await fetchViaHTTP(next.url, route)
const initialHtml = await initialRes.text()
expect(initialRes.headers.get('Content-Length')).toBeDefined()
expect(initialRes.headers.get('ETag')).toBeDefined()
const $initial = cheerio.load(initialHtml)
expect($initial('p').text()).toBe('Post: a')
@@ -2150,7 +2159,10 @@ describe('Prerender', () => {
await renderViaHTTP(next.url, route)
await check(async () => {
newHtml = await renderViaHTTP(next.url, route)
const newRes = await fetchViaHTTP(next.url, route)
expect(newRes.headers.get('Content-Length')).toBeDefined()
expect(newRes.headers.get('ETag')).toBeDefined()
newHtml = await newRes.text()
return newHtml !== initialHtml ? 'success' : newHtml
}, 'success')