Skip to content

fix: return empty headers/cookies when called outside request context#464

Open
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/headers-cookies-graceful-fallback
Open

fix: return empty headers/cookies when called outside request context#464
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/headers-cookies-graceful-fallback

Conversation

@benfavre
Copy link
Contributor

Problem

When headers() or cookies() is called outside of a request's AsyncLocalStorage scope, the current code returns a rejected Promise. This crashes the process because:

  1. Libraries like TRPC call headers() inside React.cache() wrappers at module initialization time — before runWithHeadersContext() sets up the ALS scope
  2. The rejected Promise propagates as an unhandled rejection
  3. The process crashes before any request is served

Stack trace

Error: headers() can only be called from a Server Component, Route Handler,
or Server Action. Make sure you're not calling it from a Client Component.

This happens during RSC module evaluation, not from a Client Component.

Solution

Return empty readonly Headers / RequestCookies objects instead of rejecting when state.headersContext is null.

This matches Next.js behavior — their headers() and cookies() 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.ts

  • headers(): Return _sealHeaders(new Headers()) wrapped in _decorateRequestApiPromise instead of _decorateRejectedRequestApiPromise
  • cookies(): Return _sealCookies(new RequestCookies(new Map())) wrapped in _decorateRequestApiPromise instead of _decorateRejectedRequestApiPromise

Both return readonly wrappers (sealed via Proxy) so mutation attempts still throw.

Reproduction

// In a TRPC router (RSC module scope):
import { headers } from "next/headers";

const getHeaders = React.cache(() => headers());

// This runs at module evaluation time, before any request context exists.
// Current: unhandled rejection → crash
// Fixed: returns empty Headers, real headers available at call time

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 !== null is unchanged.

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.
@james-elicx
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@464

commit: ba5ce5d

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() inside React.cache() wrappers at module initialization time — before runWithHeadersContext() 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern as the headers() change. Next.js throws here; returning empty cookies silently masks incorrect usage.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review submitted. Here's a summary of the feedback:

Requested changes on PR #464 for two main reasons:

  1. Does not match Next.js behavior. Next.js always throws (throwForMissingRequestStore) when headers() or cookies() is called outside a request scope. The only case where empty values are returned is forceStatic (export const dynamic = 'force-static'), which is an explicit opt-in. Silently returning empty objects is a compatibility divergence that masks real bugs.

  2. The stated TRPC motivation appears incorrect. React.cache(fn) is a higher-order function — it returns a memoized wrapper without invoking fn. The canonical TRPC pattern (cache(async () => { const heads = await headers(); ... })) captures headers() in a closure; it doesn't evaluate it at module load time. The function only runs during rendering, inside a request scope.

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.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants