[PR #812] [MERGED] fix: invalidate cached headers/cookies snapshot in applyMiddlewareRequestHeaders #862

Closed
opened 2026-05-06 13:10:31 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/812
Author: @Shorebirdmgmt
Created: 4/10/2026
Status: Merged
Merged: 4/10/2026
Merged by: @james-elicx

Base: mainHead: fix/invalidate-readonly-headers-after-middleware


📝 Commits (1)

  • cff52ac fix: invalidate cached headers/cookies snapshot in applyMiddlewareRequestHeaders

📊 Changes

5 files changed (+185 additions, -2 deletions)

View changed files

📝 packages/vinext/src/shims/headers.ts (+18 -1)
📝 tests/app-router.test.ts (+44 -0)
tests/fixtures/app-basic/app/header-override-after-prior-access/page.tsx (+33 -0)
📝 tests/fixtures/app-basic/middleware.ts (+30 -1)
📝 tests/shims.test.ts (+60 -0)

📄 Description

Summary

applyMiddlewareRequestHeaders() rebuilds ctx.headers in place (and, when the cookie header changes, ctx.cookies), but does not invalidate the sealed read-only snapshots cached on the same HeadersContext. When a middleware reads next/headersheaders() (or cookies()) before returning a request-header override via NextResponse.next({ request: { headers } }) or NextResponse.rewrite(..., { request: { headers } }), the sealed snapshot is built from the pre-override request, and every subsequent headers() / cookies() call from the Server Component render returns that stale view. Middleware-injected request headers are missing from the render, and middleware-deleted credential headers are still visible.

Any middleware using the common "read the request, then rewrite via `NextResponse.next({ request: { headers } })`" pattern is affected — authentication, logging/tracing, feature flagging, or any `next/headers`-aware pre-processing. The bug was discovered via `@clerk/nextjs`, whose `clerkClient()` reads `headers()` through its internal `buildRequestLike()` helper during middleware execution. Clerk's `auth()` in a Server Component then throws

Clerk: auth() was called but Clerk can't detect usage of clerkMiddleware()

because Clerk's own `x-clerk-auth-*` request header overrides never reach the render pipeline. That same mechanism is what unblocks `@clerk/nextjs` from `partial` (#803) to `supported` in `check.ts` as a downstream consequence of this fix — a follow-up PR will update the compat status once this one releases.

Root cause

`_getReadonlyHeaders(ctx)` caches `ctx.readonlyHeaders = _sealHeaders(ctx.headers)` on first access. When middleware reads `headers()` before returning, that cache is built from the original request headers. `applyMiddlewareRequestHeaders()` then replaces `ctx.headers` with the middleware-modified view, but the cached sealed snapshot is never invalidated. The Server Component's later `headers()` call returns the stale snapshot. The same race exists for `ctx.readonlyCookies` and `ctx.mutableCookies` when middleware mutates the cookie header.

Captured via `dist/` instrumentation against a production build of a minimal reproduction app (vinext@0.0.41 + @clerk/nextjs@7.0.12):

```
Error: trace
at _getReadonlyHeaders (dist/server/assets/headers-.js:424)
at headers (dist/server/assets/headers-
.js:490)
at buildRequestLike (dist/server/index.js:1799) // @clerk/nextjs
at async clerkClient (dist/server/index.js:2591) // @clerk/nextjs
at async _handleRequest (dist/server/index.js:8635) // vinext RSC entry
```

The stack trace confirms Clerk's middleware-phase `headers()` call primes the sealed snapshot before vinext's own `applyMiddlewareRequestHeaders()` replaces the underlying `ctx.headers`.

Fix

`applyMiddlewareRequestHeaders()` now sets `ctx.readonlyHeaders = undefined` immediately after replacing `ctx.headers`, and clears `ctx.readonlyCookies` / `ctx.mutableCookies` in the cookie-rebuild branch. The next `headers()` / `cookies()` call rebuilds the sealed view from the post-override state.

Tests

Two new regression tests, both verified to fail on `main` and pass with the fix:

  • Unit — `tests/shims.test.ts`: drives `headers()` → `applyMiddlewareRequestHeaders()` → `headers()` directly and asserts the override view replaces the snapshot. Failure on `main`: `expected 'Bearer secret' to be null`.
  • Integration — `tests/app-router.test.ts`, inside the existing `App Router Production server (startProdServer)` describe block. Uses the `app-basic` fixture to reproduce the bug end-to-end against the inline RSC entry that wraps middleware execution in the headers context. The fixture middleware calls `await headers()` first (mirroring Clerk's `buildRequestLike()` pattern) and then returns a `NextResponse.next({ request: { headers } })` override. The Server Component asserts the middleware-deleted credentials are null and the middleware-injected `x-from-middleware` is visible. Failure on `main`: rendered HTML contains `id="authorization">Bearer secret<` and `id="middleware-header">null<`.

The integration test lives in the production-server describe block rather than the dev-server describe block because the dev-mode middleware path runs middleware before the headers context exists, so calling `headers()` from middleware is instead an immediate error there. The bug only manifests on the inline RSC entry path that matches production Cloudflare Workers.

Verified

With the fix applied:

Check Result
`pnpm test:unit` 2813 / 2813 pass
`pnpm test:integration` 1252 / 1252 pass (2 pre-existing skips)
`pnpm run check` (lint + format + types) 0 warnings, 0 errors
`pnpm test:e2e` — `app-router` project 302 / 302 pass
`pnpm test:e2e` — `cloudflare-workers` project (Miniflare) 37 / 37 pass

Both new tests were also run with the fix reverted on a clean tree to confirm they deterministically catch the regression.

End-to-end runtime verification against a real `@clerk/nextjs@7.0.12` instance on a production `vinext build` + `vinext start`:

  • Unauthenticated `/dashboard` request returns 200; `auth()` returns `{ userId: null, orgId: null, sessionId: null }` without throwing, and all 9 `x-clerk-auth-*` request headers are visible via `headers()` in the Server Component.
  • Signed in via Clerk's sign-in-token flow (admin API → Playwright handshake → session cookie): the Server Component's `auth()` returned the real `userId`, `orgId`, and `sessionId` from Clerk; `currentUser()` returned the real user object; `headers()` exposed all 21 request headers including a valid JWT in `x-clerk-auth-token`.

References

  • #803 — `@clerk/nextjs` compatibility marked `partial`
  • #809 — parallel App Route request-object fix that uses the same `applyMiddlewareRequestHeaders` machinery (this PR covers the remaining RSC render-side gap: sealed-snapshot invalidation)
  • #800 — original `@clerk/nextjs` compatibility tracking issue

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/812 **Author:** [@Shorebirdmgmt](https://github.com/Shorebirdmgmt) **Created:** 4/10/2026 **Status:** ✅ Merged **Merged:** 4/10/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/invalidate-readonly-headers-after-middleware` --- ### 📝 Commits (1) - [`cff52ac`](https://github.com/cloudflare/vinext/commit/cff52aca7be854f0dd0d51208287a8f400e033e9) fix: invalidate cached headers/cookies snapshot in applyMiddlewareRequestHeaders ### 📊 Changes **5 files changed** (+185 additions, -2 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/shims/headers.ts` (+18 -1) 📝 `tests/app-router.test.ts` (+44 -0) ➕ `tests/fixtures/app-basic/app/header-override-after-prior-access/page.tsx` (+33 -0) 📝 `tests/fixtures/app-basic/middleware.ts` (+30 -1) 📝 `tests/shims.test.ts` (+60 -0) </details> ### 📄 Description ## Summary `applyMiddlewareRequestHeaders()` rebuilds `ctx.headers` in place (and, when the cookie header changes, `ctx.cookies`), but does not invalidate the sealed read-only snapshots cached on the same `HeadersContext`. When a middleware reads `next/headers` → `headers()` (or `cookies()`) *before* returning a request-header override via `NextResponse.next({ request: { headers } })` or `NextResponse.rewrite(..., { request: { headers } })`, the sealed snapshot is built from the pre-override request, and every subsequent `headers()` / `cookies()` call from the Server Component render returns that stale view. Middleware-injected request headers are missing from the render, and middleware-deleted credential headers are still visible. Any middleware using the common "read the request, then rewrite via \`NextResponse.next({ request: { headers } })\`" pattern is affected — authentication, logging/tracing, feature flagging, or any \`next/headers\`-aware pre-processing. The bug was discovered via \`@clerk/nextjs\`, whose \`clerkClient()\` reads \`headers()\` through its internal \`buildRequestLike()\` helper during middleware execution. Clerk's \`auth()\` in a Server Component then throws > Clerk: auth() was called but Clerk can't detect usage of clerkMiddleware() because Clerk's own \`x-clerk-auth-*\` request header overrides never reach the render pipeline. That same mechanism is what unblocks \`@clerk/nextjs\` from \`partial\` (#803) to \`supported\` in \`check.ts\` as a downstream consequence of this fix — a follow-up PR will update the compat status once this one releases. ## Root cause \`_getReadonlyHeaders(ctx)\` caches \`ctx.readonlyHeaders = _sealHeaders(ctx.headers)\` on first access. When middleware reads \`headers()\` before returning, that cache is built from the original request headers. \`applyMiddlewareRequestHeaders()\` then replaces \`ctx.headers\` with the middleware-modified view, but the cached sealed snapshot is never invalidated. The Server Component's later \`headers()\` call returns the stale snapshot. The same race exists for \`ctx.readonlyCookies\` and \`ctx.mutableCookies\` when middleware mutates the cookie header. Captured via \`dist/\` instrumentation against a production build of a minimal reproduction app (vinext@0.0.41 + @clerk/nextjs@7.0.12): \`\`\` Error: trace at _getReadonlyHeaders (dist/server/assets/headers-*.js:424) at headers (dist/server/assets/headers-*.js:490) at buildRequestLike (dist/server/index.js:1799) // @clerk/nextjs at async clerkClient (dist/server/index.js:2591) // @clerk/nextjs at async _handleRequest (dist/server/index.js:8635) // vinext RSC entry \`\`\` The stack trace confirms Clerk's middleware-phase \`headers()\` call primes the sealed snapshot before vinext's own \`applyMiddlewareRequestHeaders()\` replaces the underlying \`ctx.headers\`. ## Fix \`applyMiddlewareRequestHeaders()\` now sets \`ctx.readonlyHeaders = undefined\` immediately after replacing \`ctx.headers\`, and clears \`ctx.readonlyCookies\` / \`ctx.mutableCookies\` in the cookie-rebuild branch. The next \`headers()\` / \`cookies()\` call rebuilds the sealed view from the post-override state. ## Tests Two new regression tests, both verified to fail on \`main\` and pass with the fix: - **Unit** — \`tests/shims.test.ts\`: drives \`headers()\` → \`applyMiddlewareRequestHeaders()\` → \`headers()\` directly and asserts the override view replaces the snapshot. Failure on \`main\`: \`expected 'Bearer secret' to be null\`. - **Integration** — \`tests/app-router.test.ts\`, inside the existing \`App Router Production server (startProdServer)\` describe block. Uses the \`app-basic\` fixture to reproduce the bug end-to-end against the inline RSC entry that wraps middleware execution in the headers context. The fixture middleware calls \`await headers()\` first (mirroring Clerk's \`buildRequestLike()\` pattern) and then returns a \`NextResponse.next({ request: { headers } })\` override. The Server Component asserts the middleware-deleted credentials are null and the middleware-injected \`x-from-middleware\` is visible. Failure on \`main\`: rendered HTML contains \`id=\"authorization\">Bearer secret<\` and \`id=\"middleware-header\">null<\`. The integration test lives in the production-server describe block rather than the dev-server describe block because the dev-mode middleware path runs middleware before the headers context exists, so calling \`headers()\` from middleware is instead an immediate error there. The bug only manifests on the inline RSC entry path that matches production Cloudflare Workers. ## Verified With the fix applied: | Check | Result | |---|---| | \`pnpm test:unit\` | **2813 / 2813** pass | | \`pnpm test:integration\` | **1252 / 1252** pass (2 pre-existing skips) | | \`pnpm run check\` (lint + format + types) | **0** warnings, **0** errors | | \`pnpm test:e2e\` — \`app-router\` project | **302 / 302** pass | | \`pnpm test:e2e\` — \`cloudflare-workers\` project (Miniflare) | **37 / 37** pass | Both new tests were also run with the fix reverted on a clean tree to confirm they deterministically catch the regression. End-to-end runtime verification against a real \`@clerk/nextjs@7.0.12\` instance on a production \`vinext build\` + \`vinext start\`: - **Unauthenticated** \`/dashboard\` request returns 200; \`auth()\` returns \`{ userId: null, orgId: null, sessionId: null }\` without throwing, and all 9 \`x-clerk-auth-*\` request headers are visible via \`headers()\` in the Server Component. - **Signed in** via Clerk's sign-in-token flow (admin API → Playwright handshake → session cookie): the Server Component's \`auth()\` returned the real \`userId\`, \`orgId\`, and \`sessionId\` from Clerk; \`currentUser()\` returned the real user object; \`headers()\` exposed all 21 request headers including a valid JWT in \`x-clerk-auth-token\`. ## References - #803 — \`@clerk/nextjs\` compatibility marked \`partial\` - #809 — parallel App Route request-object fix that uses the same \`applyMiddlewareRequestHeaders\` machinery (this PR covers the remaining RSC render-side gap: sealed-snapshot invalidation) - #800 — original \`@clerk/nextjs\` compatibility tracking issue --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:10:31 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#862
No description provided.