fix: next/headers readonly semantics and legacy sync compatibility#402
Conversation
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1657d7e3b4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
/bigbonk review |
There was a problem hiding this comment.
Review Summary
Well-structured PR that brings headers() and cookies() significantly closer to Next.js semantics. The decorated Promise approach for sync/async compatibility, phase-aware cookie mutability, and the accessError consolidation for dynamic = "error" are all clean designs. The test coverage is thorough across unit, integration, and E2E layers.
A few items to consider below — one correctness concern with draftMode(), one minor proxy observation, and a nit.
Re: the Codex review concern about error-path sync access — the _decorateRejectedRequestApiPromise implementation correctly handles this. The throwingTarget proxy throws normalizedError on any property access except then/catch/finally, so headers().get("x-test") outside a request context properly throws the context error, not .get is not a function. The test at line 451 of shims.test.ts confirms this.
| ); | ||
| } | ||
|
|
||
| static callable(): never { |
There was a problem hiding this comment.
Very minor: ReadonlyHeadersError.callable is returned as a function reference from the _sealHeaders proxy get trap. This means typeof headers().set returns "function" rather than throwing on property access. Calling .set() correctly throws. This is fine for practical purposes; just noting it differs slightly from Next.js where even accessing the method reference may throw. Not blocking.
|
The review was submitted successfully. Here's a summary of what I reviewed: PR #402: fix: next/headers readonly semantics and legacy sync compatibility I submitted a review with 4 inline comments:
I also confirmed in the review summary that the Codex reviewer's concern about error-path sync access is properly handled by the |
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
|
/bigbonk re-review |
There was a problem hiding this comment.
Review: next/headers readonly semantics and legacy sync compatibility
Well-designed PR. The decorated Promise approach for sync/async compatibility, phase-gated cookie mutability, and the accessError consolidation are all clean patterns that bring vinext meaningfully closer to Next.js behavior. Test coverage is thorough across unit, integration, and E2E layers.
I verified there's no dev/prod parity concern here — both the Node.js prod server (App Router mode) and the Cloudflare Worker entry delegate entirely to the generated RSC handler from app-rsc-entry.ts, so the setHeadersAccessPhase and accessError changes propagate to all deployment targets automatically.
A few items to address, one of which is a real correctness bug:
Issues
-
[Bug]
draftMode()callsmarkDynamicUsage()beforeaccessErrorcheck — Inheaders()andcookies(),markDynamicUsage()is called after theaccessErrorguard, which is correct (error path = no side effects). ButdraftMode()callsmarkDynamicUsage()at line 631 before checkingaccessErrorat line 634. This means adynamic = "error"page still gets its dynamic usage flag set even though the API call will throw. The flag itself is harmless since the page will error out anyway, but it's inconsistent with theheaders()/cookies()ordering and could matter if the error is caught. -
[Stale comment] A comment in
app-rsc-entry.tsat line 2434 still references the old "throwing Proxy" pattern. The implementation now usesaccessErroron the context instead. -
[Nit] Double proxy for readonly cookies —
_getReadonlyCookieswraps_getMutableCookies's result... wait, actually looking again, it doesn't — it creates its ownnew RequestCookies(ctx.cookies)and seals it directly. This is fine.
Positive notes
- The
_decorateRejectedRequestApiPromiseimplementation correctly handles the Codex reviewer's concern — thethrowingTargetproxy throwsnormalizedErroron any property access exceptthen/catch/finally, soheaders().get("x-test")outside a request context properly throws the context error rather than.get is not a function. - The
try/finallysave-and-restore pattern for phase tracking is correct and handlesredirect()throws properly. - The E2E test for server action cookie writes (
action-cookie-phase) tests the full lifecycle including the retry logic for cold compilation, which is pragmatic. - Caching readonly proxies on the context (
ctx.readonlyHeaders,ctx.readonlyCookies, etc.) is a good optimization — avoids creating new Proxy wrappers on everyheaders()/cookies()call within the same request.
| return ctx.mutableCookies; | ||
| } | ||
|
|
||
| function _getReadonlyCookies(ctx: HeadersContext): RequestCookies { |
There was a problem hiding this comment.
Nit: _getReadonlyCookies creates a new RequestCookies(ctx.cookies) and seals it, while _getMutableCookies creates a different new RequestCookies(ctx.cookies) and wraps it with phase checks. Both share the same underlying ctx.cookies Map, so mutations through the mutable proxy are visible to the readonly one on subsequent reads. This is correct.
However, if _getMutableCookies was already called (e.g., during an action phase), and then the phase reverts to render, cookies() will return _getReadonlyCookies. A user who held a reference to the mutable proxy from the action phase would see their writes reflected in the readonly proxy because they share the Map. This is the intended behavior (the test at line 652 of shims.test.ts confirms it), but worth a brief comment on the shared-Map design to prevent future confusion.
|
Review submitted successfully. Here's a summary of what I reviewed: PR #402: fix: next/headers readonly semantics and legacy sync compatibility I submitted a review with 4 inline comments covering:
I also confirmed there's no dev/prod parity concern — both the Node.js prod server and Cloudflare Worker entry delegate entirely to the RSC handler, so changes propagate automatically. Additionally noted a stale comment at |
|
/bigbonk re-review |
There was a problem hiding this comment.
Review Summary
This is a well-structured PR that meaningfully improves Next.js compatibility for headers() and cookies(). The decorated Promise pattern for sync/async dual access, the phase-gated cookie mutability, and the accessError consolidation for dynamic = "error" are all clean designs.
The test coverage is thorough — unit tests for the proxy behavior, integration tests via real dev-server requests, and an E2E Playwright test for the full server-action cookie lifecycle. I verified there's no dev/prod parity concern since both the Node.js prod server and Cloudflare Worker entry delegate to the generated RSC handler.
Two minor items below, neither blocking.
Notes
-
The
_decorateRejectedRequestApiPromisecorrectly handles the error-path sync access concern (raised by the Codex reviewer). ThethrowingTargetproxy throwsnormalizedErroron any property access exceptthen/catch/finally, soheaders().get("x-test")outside a request context throws the proper context error. -
The
_sealHeadersand_sealCookiesproxies correctly throw on property access (not just on invocation), which matches the tests at lines 258-260 and 322-327 ofshims.test.ts. This is the right behavior. -
The
try/finallyphase tracking with save-and-restore inapp-rsc-entry.tsis well-implemented. It handlesredirect()throws correctly and isolates concurrent requests via ALS. -
The E2E test retry logic for cold compilation is pragmatic and appropriate for dev-mode testing.
| return ctx.mutableCookies; | ||
| } | ||
|
|
||
| function _getReadonlyCookies(ctx: HeadersContext): RequestCookies { |
There was a problem hiding this comment.
Nit: Both _getMutableCookies and _getReadonlyCookies create separate RequestCookies instances wrapping the same ctx.cookies Map. This is correct — mutations through the mutable proxy are visible to the readonly one — but the shared-Map design is non-obvious. The existing comment at line 399-400 partially explains this. Consider expanding it slightly to note that the shared backing Map is intentional for cross-proxy visibility:
| function _getReadonlyCookies(ctx: HeadersContext): RequestCookies { | |
| // Keep a separate readonly wrapper so render-path reads avoid the | |
| // mutable phase-checking proxy while still reflecting the shared | |
| // cookie map. Both _getMutableCookies and _getReadonlyCookies wrap | |
| // the same ctx.cookies Map, so mutations made during an action/ | |
| // route-handler phase are visible to subsequent readonly reads. |
| ); | ||
| } | ||
|
|
||
| static callable(): never { |
There was a problem hiding this comment.
Very minor: ReadonlyHeadersError.callable and ReadonlyRequestCookiesError.callable are defined but never referenced anywhere in this file. They're dead code — _sealHeaders and _sealCookies both throw directly in their proxy get traps rather than returning these callable sentinels. Consider removing them to avoid confusion.
(Not blocking — they're harmless, but their presence suggests a discarded approach that could mislead future readers.)
|
Review submitted successfully. Here's a summary: Approved PR #402 with two minor non-blocking comments:
The overall assessment is positive — the decorated Promise pattern, phase-gated cookie mutability, |
…api-readonly-compat
Fix
next/headerscompatibility gaps so vinext matches Next.js more closely for request APIs.This changes
headers()andcookies()to support the legacy sync access pattern while preserving their Promise-based API, makesheaders()read-only, and restrictscookies()mutation to route handlers and server actions instead of allowing writes during normal render paths.What changed
headers()andcookies()so legacy sync access likeheaders().get(...)andcookies().get(...)works.headers()result read-only and throw onset,append, anddelete.next/headersshim state.Tests
Added substantial regression coverage across unit, integration, and browser layers.
Unit / shim coverage
Expanded
tests/shims.test.tsto cover:headers()andcookies()headers()for sync and awaited accesscookies()during rendercookies()only in route-handler and action phasesHTTP integration coverage
Added
tests/nextjs-compat/request-apis.test.tsand new fixture routes/pages to verify:headers()andcookies()access in real App Router page requestscookies()headers()New fixtures:
tests/fixtures/app-basic/app/nextjs-compat/request-api-sync/page.tsxtests/fixtures/app-basic/app/nextjs-compat/request-api-readonly/page.tsxtests/fixtures/app-basic/app/nextjs-compat/api/request-api-sync/route.tstests/fixtures/app-basic/app/nextjs-compat/api/headers-readonly/route.tstests/fixtures/app-basic/app/nextjs-compat/api/request-api-repeat/route.tsBrowser / server-action coverage
Added a Playwright regression in
tests/e2e/app-router/server-actions.spec.tsplus a new fixture page/action:tests/fixtures/app-basic/app/nextjs-compat/action-cookie-phase/page.tsxtests/fixtures/app-basic/app/nextjs-compat/action-cookie-phase/actions.tsThis verifies that:
cookies()as read-onlyValidation
Ran:
pnpm test tests/shims.test.ts tests/nextjs-compat/request-apis.test.ts tests/nextjs-compat/set-cookies.test.ts tests/nextjs-compat/draft-mode.test.tspnpm test tests/nextjs-compat/request-apis.test.tsPLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/server-actions.spec.tspnpm run typecheckpnpm run lintNotes
This preserves vinext’s internal lazy mutable request header optimization for middleware forwarding, but seals the public API so user code can no longer mutate request headers or write cookies from normal render contexts.