Skip to content

Commit 962e5b1

Browse files
lllomhijjk
andauthored
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 <jj@jjsweb.site>
1 parent fb85660 commit 962e5b1

3 files changed

Lines changed: 31 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,13 @@ pnpm test-dev-turbo test/development/
148148
Generating tests using `pnpm new-test` is mandatory.
149149

150150
```bash
151-
# Use --args for non-interactive mode
152-
# Format: pnpm new-test --args <appDir> <name> <type>
151+
# Use --args for non-interactive mode (forward args to the script using `--`)
152+
# Format: pnpm new-test -- --args <appDir> <name> <type>
153153
# appDir: true/false (is this for app directory?)
154154
# name: test name (e.g. "my-feature")
155155
# type: e2e | production | development | unit
156156

157-
pnpm new-test --args true my-feature e2e
157+
pnpm new-test -- --args true my-feature e2e
158158
```
159159

160160
**Analyzing test output efficiently:**
@@ -400,7 +400,7 @@ Core runtime/bundling rules (always apply; skills above expand on these with ver
400400
### Test Gotchas
401401

402402
- **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.
403-
- **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.
403+
-- **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.
404404
- Mode-specific tests need `skipStart: true` + manual `next.start()` in `beforeAll` after mode check
405405
- Don't rely on exact log messages - filter by content patterns, find sequences not positions
406406
- **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).

packages/next/src/server/route-modules/pages/pages-handler.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -526,16 +526,13 @@ export const getHandler = ({
526526
return {
527527
value: {
528528
kind: CachedRouteKind.PAGES,
529-
html: new RenderResult(
530-
Buffer.from(previousCacheEntry.value.html),
531-
{
532-
contentType: HTML_CONTENT_TYPE_HEADER,
533-
metadata: {
534-
statusCode: previousCacheEntry.value.status,
535-
headers: previousCacheEntry.value.headers,
536-
},
537-
}
538-
),
529+
html: new RenderResult(previousCacheEntry.value.html, {
530+
contentType: HTML_CONTENT_TYPE_HEADER,
531+
metadata: {
532+
statusCode: previousCacheEntry.value.status,
533+
headers: previousCacheEntry.value.headers,
534+
},
535+
}),
539536
pageData: {},
540537
status: previousCacheEntry.value.status,
541538
headers: previousCacheEntry.value.headers,
@@ -740,13 +737,10 @@ export const getHandler = ({
740737
// anymore
741738
result:
742739
isNextDataRequest && !isErrorPage && !is500Page
743-
? new RenderResult(
744-
Buffer.from(JSON.stringify(result.value.pageData)),
745-
{
746-
contentType: JSON_CONTENT_TYPE_HEADER,
747-
metadata: result.value.html.metadata,
748-
}
749-
)
740+
? new RenderResult(JSON.stringify(result.value.pageData), {
741+
contentType: JSON_CONTENT_TYPE_HEADER,
742+
metadata: result.value.html.metadata,
743+
})
750744
: result.value.html,
751745
generateEtags: nextConfig.generateEtags,
752746
poweredByHeader: nextConfig.poweredByHeader,

test/e2e/prerender.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2063,7 +2063,10 @@ describe('Prerender', () => {
20632063

20642064
it('should handle revalidating JSON correctly', async () => {
20652065
const route = `/_next/data/${next.buildId}/blog/post-2/comment-3.json`
2066-
const initialJson = await renderViaHTTP(next.url, route)
2066+
const initialRes = await fetchViaHTTP(next.url, route)
2067+
const initialJson = await initialRes.text()
2068+
expect(initialRes.headers.get('Content-Length')).toBeDefined()
2069+
expect(initialRes.headers.get('ETag')).toBeDefined()
20672070
expect(initialJson).toMatch(/post-2/)
20682071
expect(initialJson).toMatch(/comment-3/)
20692072

@@ -2078,7 +2081,10 @@ describe('Prerender', () => {
20782081
await renderViaHTTP(next.url, route)
20792082

20802083
await check(async () => {
2081-
newJson = await renderViaHTTP(next.url, route)
2084+
const newRes = await fetchViaHTTP(next.url, route)
2085+
expect(newRes.headers.get('Content-Length')).toBeDefined()
2086+
expect(newRes.headers.get('ETag')).toBeDefined()
2087+
newJson = await newRes.text()
20822088
return newJson !== initialJson ? 'success' : newJson
20832089
}, 'success')
20842090

@@ -2135,7 +2141,10 @@ describe('Prerender', () => {
21352141

21362142
it('should handle revalidating HTML correctly with blocking and seed', async () => {
21372143
const route = '/blocking-fallback/a'
2138-
const initialHtml = await renderViaHTTP(next.url, route)
2144+
const initialRes = await fetchViaHTTP(next.url, route)
2145+
const initialHtml = await initialRes.text()
2146+
expect(initialRes.headers.get('Content-Length')).toBeDefined()
2147+
expect(initialRes.headers.get('ETag')).toBeDefined()
21392148
const $initial = cheerio.load(initialHtml)
21402149
expect($initial('p').text()).toBe('Post: a')
21412150

@@ -2150,7 +2159,10 @@ describe('Prerender', () => {
21502159
await renderViaHTTP(next.url, route)
21512160

21522161
await check(async () => {
2153-
newHtml = await renderViaHTTP(next.url, route)
2162+
const newRes = await fetchViaHTTP(next.url, route)
2163+
expect(newRes.headers.get('Content-Length')).toBeDefined()
2164+
expect(newRes.headers.get('ETag')).toBeDefined()
2165+
newHtml = await newRes.text()
21542166
return newHtml !== initialHtml ? 'success' : newHtml
21552167
}, 'success')
21562168

0 commit comments

Comments
 (0)