mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[GH-ISSUE #660] bug: headers()/cookies() throw "can only be called from a Server Component" on warm requests — clearRequestContext() races the RSC/SSR stream #140
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#140
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @james-elicx on GitHub (Mar 22, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/660
Originally assigned to: @james-elicx on GitHub.
Bug
headers()andcookies()throw "can only be called from a Server Component, Route Handler, or Server Action" inside App Router Server Components on second and subsequent page loads. The first load always works.Root cause
The RSC and SSR pipelines are lazy
ReadableStreampipelines.renderToReadableStream()returns a stream handle — the Server Component tree has not executed yet. Components run lazily as the stream is pulled by the HTTP response consumer.However,
renderAppPageLifecycle()insrc/server/app-page-render.tscallsclearRequestContext()immediately after receiving the stream handle, before the stream has been consumed:The execution sequence:
renderToReadableStream(rscElement)→ returns RSC stream handle (components not yet run)handleSsr()→ returns HTML stream handle (still not run)clearRequestContext()called —headersContextset tonullon the live ALS store objectResponsesent to browser, which begins pulling the streamheaders()— butheadersContextis alreadynullWhy the first load works: On a cold module graph, module loading is slow enough that the stream happens to drain (steps 4–5) before the event loop gets to step 3. On subsequent warm loads modules are cached, so step 3 consistently wins the race.
Why
isInsideUnifiedScope()istrue: The ALS store itself is still set (the request scope is live).clearRequestContext()doesn't exit the ALS scope — it mutates the shared store object in-place, settingheadersContext = null. Since the stream holds a reference to the same object, it sees the null immediately.Affected files
src/server/app-page-render.ts— primary site (renderAppPageLifecycle)src/server/app-page-stream.ts—renderAppPageHtmlResponse()has the same patternProposed fix
Defer
clearRequestContext()until the HTML stream is fully consumed by piping through aTransformStreamwhoseflush()does the cleanup. Values that need to be read now (before the stream drains) —getDraftModeCookieHeader(),consumeDynamicUsage()— should still be called eagerly; only the nulling-out of the context needs to move.The same change is needed in
renderAppPageHtmlResponse()inapp-page-stream.tsand in the ISR cache write path infinalizeAppPageHtmlCacheResponse().Note that simply moving the
clearRequestContext()call later in the same synchronous function body is not enough — the stream is consumed asynchronously after theResponseis returned, so any synchronous call site still races it.Repro
Any App Router Server Component that calls
headers()orcookies():Works on first load, throws on every subsequent reload.
Workaround
Wrap the
headers()call inReact.cache()so it is resolved during the first synchronous component invocation (before anyawaityields control back to the stream machinery) and memoised for all other components in the same request. This sidesteps the race but does not fix the root cause.