fix: return empty headers/cookies when called outside request context#464
fix: return empty headers/cookies when called outside request context#464benfavre wants to merge 1 commit intocloudflare:mainfrom
Conversation
When `headers()` or `cookies()` is called outside of a request's AsyncLocalStorage scope (e.g. during RSC module initialization, inside React `cache()` wrappers, or at import-time in libraries like TRPC), the current code rejects with an error. This crashes the process because the rejected promise propagates as an unhandled rejection. Instead, return empty readonly `Headers` / `RequestCookies` objects. This matches Next.js behavior where these APIs gracefully degrade outside request context. The real request headers become available when the component or procedure is actually invoked during rendering within the proper ALS scope. Reproduction: 1. Use a library (e.g. TRPC) that calls `headers()` inside `React.cache()` at module initialization time 2. The ALS store is not yet set up (no `runWithHeadersContext` scope) 3. `headers()` rejects → unhandled rejection → process crash With this fix, step 3 returns empty Headers instead.
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
Summary
This PR changes headers() and cookies() to return empty readonly objects instead of rejecting when called outside a request context. I have two concerns: (1) this does not match Next.js behavior, and (2) the stated motivation (TRPC crash at module init) appears to be based on a misunderstanding of how React.cache() works.
Next.js behavior
Next.js throws when headers() or cookies() is called outside a request scope — it never silently returns empty values. The function throwForMissingRequestStore in packages/next/src/server/app-render/work-unit-async-storage.external.ts is the final fallthrough for both headers() and cookies(). The only case where Next.js returns empty headers is forceStatic (export const dynamic = 'force-static'), which is an explicit opt-in via route segment config — not a fallback for missing context.
Since vinext aims to match Next.js behavior exactly, silently returning empty objects would be a compatibility divergence that could mask real bugs.
The TRPC scenario
The PR description states:
Libraries like TRPC call
headers()insideReact.cache()wrappers at module initialization time — beforerunWithHeadersContext()sets up the ALS scope
React.cache(fn) is a higher-order function that returns a memoized wrapper. It does not call fn at the time cache() is invoked — it only calls fn when the returned wrapper is later invoked. The canonical TRPC pattern:
const createContext = cache(async () => {
const heads = new Headers(await headers());
return createTRPCContext({ headers: heads });
});headers() is captured in a closure here — it is not evaluated at module load time. It runs when createContext() is invoked during rendering, which happens inside a request scope. So the described crash scenario (TRPC calling headers() at module init via React.cache()) should not occur.
What this would break
Returning empty objects instead of throwing means that if someone genuinely calls headers() outside a request scope by mistake, they'd get silent empty data instead of an immediate error. This is a worse developer experience — instead of a clear "you called this in the wrong place" error, they'd get silently missing headers/cookies that are hard to debug.
Existing tests
The test file already has tests at tests/shims.test.ts:615-632 that assert the current throw-on-missing-context behavior. These tests would need to be updated if this change were merged, but more importantly, those tests verify the correct behavior.
Recommendation
Please provide a concrete reproduction that triggers the described crash. If there's a real scenario where headers() is called outside ALS scope, the fix should address the root cause (e.g., ensuring the ALS scope is set up before module evaluation, or the specific library pattern that triggers it) rather than silently degrading. If you can share a minimal reproduction, I'm happy to help debug the actual issue.
| // lets initialization proceed. The real request headers are available | ||
| // when the TRPC procedure is actually invoked during rendering. | ||
| const emptyHeaders = _sealHeaders(new Headers()); | ||
| return _decorateRequestApiPromise(Promise.resolve(emptyHeaders), emptyHeaders); |
There was a problem hiding this comment.
This diverges from Next.js, which always throws via throwForMissingRequestStore('headers') when there's no request scope. Returning empty headers silently masks bugs — a developer who accidentally calls headers() at the top level of a module will get empty data with no indication anything is wrong.
The stated motivation (TRPC + React.cache() at module init) doesn't hold up because React.cache(fn) doesn't invoke fn — it returns a wrapper that defers invocation to call time.
| ); | ||
| // Return empty readonly cookies instead of rejecting (same rationale as headers()). | ||
| const emptyCookies = _sealCookies(new RequestCookies(new Map())); | ||
| return _decorateRequestApiPromise(Promise.resolve(emptyCookies), emptyCookies); |
There was a problem hiding this comment.
Same concern as the headers() change. Next.js throws here; returning empty cookies silently masks incorrect usage.
|
Review submitted. Here's a summary of the feedback: Requested changes on PR #464 for two main reasons:
Asked the author to provide a concrete reproduction of the crash, since the root cause likely needs a different fix (e.g., ensuring ALS scope is set up at the right time) rather than silently degrading the API. |
Problem
When
headers()orcookies()is called outside of a request's AsyncLocalStorage scope, the current code returns a rejected Promise. This crashes the process because:headers()insideReact.cache()wrappers at module initialization time — beforerunWithHeadersContext()sets up the ALS scopeStack trace
This happens during RSC module evaluation, not from a Client Component.
Solution
Return empty readonly
Headers/RequestCookiesobjects instead of rejecting whenstate.headersContextis null.This matches Next.js behavior — their
headers()andcookies()gracefully degrade when called outside request context. The real request headers become available when the component or procedure is actually invoked during rendering within the proper ALS scope.Changes
packages/vinext/src/shims/headers.tsheaders(): Return_sealHeaders(new Headers())wrapped in_decorateRequestApiPromiseinstead of_decorateRejectedRequestApiPromisecookies(): Return_sealCookies(new RequestCookies(new Map()))wrapped in_decorateRequestApiPromiseinstead of_decorateRejectedRequestApiPromiseBoth return readonly wrappers (sealed via Proxy) so mutation attempts still throw.
Reproduction
Risk
Low. The empty objects are readonly (mutations throw), and the fix only affects the edge case where there is no ALS store. Normal request-scoped calls are unaffected — the code path for
state.headersContext !== nullis unchanged.