Repository navigation
fix(router)!: ctx.theme read in a handler refuses a PPR capture and throws in a cache() miss, like cookies() (#971) - #979
Merged
Conversation
commit: |
5 tasks done
…rows in a cache() miss, like cookies() (#971) The visitor's theme was a plain read in three places: the handler ctx (copied at creation), the middleware ctx and getRequestContext().theme. A ppr shell, a route cache() entry or a "use cache" entry stored the first visitor's theme and served it to every later visitor. Each read is now a read-only, non-enumerable getter that runs the cookies() guards (same scopes, order and exemptions): a ppr capture refuses with a once-per-key warning and the route keeps MISSing, and a cache() or "use cache" read throws. The guard records the read and its fix, so the refusal warning prints both. The router's own payload read goes through payloadInitialTheme, unguarded, and a capture stores the no-cookie default as the shell's initialTheme instead of the capturing visitor's theme, so a HIT no longer hands one visitor's useTheme() value to a visitor with no stored theme.
ivogt
force-pushed
the
fix/theme-capture-guard-971
branch
from
September 29, 2026 19:52
c5d8481 to
3cd7954
Compare
This was referenced Sep 29, 2026
Merged
ivogt
added a commit
that referenced
this pull request
Sep 30, 2026
…okies() refuse where cookies() does (#976) (#999) The identity guards covered cookies(), headers(), the theme reads and a { cache: false } variable, but three raw reads went around them: ctx.request.headers (the same Request in handlers, middleware, loaders and getRequestContext().request) and getRequestContext().cookie()/cookies(). A "use cache" function keys a ctx or Request argument by route and URL only, so a header read in its body stored the first caller's value for every caller; a handler under cache() or on a ppr route stored it in the shared record or shell, and a loader's own cache() without key() stored it in its entry. - The request every ctx exposes gets an own, non-enumerable headers getter (server/request-headers.ts shadowRequestHeaders, guarded by cookie-store.ts guardRequestHeaders) that runs guardIdentityRead: the same scopes, order and error style as headers(), and recorded at the read for a loader cache() fill. createRequestContext installs it, and createHandlerContext for a request the router builds and matches (the progressive-enhancement re-render, a navigation-only capture). No Proxy: fetch(ctx.request) and new Request(ctx.request) copy the headers natively and never call the getter (they are not guarded; the docs say not to make them in a cached body). clone() is shadowed to return a guarded clone. A lazy srvx NodeRequest's _request (Node dev and preview servers, vercel output) and Node's util.inspect hook run with the guard off, so srvx's own clone/body reads and console.log(ctx.request) are not reads; the facade stays lazy, so a buffered rawBody is still read. - Router code reads a request's headers only through requestHeaders(), which bypasses the getter (state cookie rotation, request classification, HMR and prefetch markers, origin guard, cf-ray, ...). - getRequestContext().cookie()/cookies() run guardRawCookieRead; the cookies() store reads the unguarded _readCookie()/_readCookies(). - key(), a store keyGenerator, condition(), tags() and onError run under runIdentityExempt (cache/cache-exec-scope.ts): identity reads there are allowed and record nothing. A ppr capture resolves its doc record key and the record's tags again under the capture context, so a keyGenerator or tags() reading the request (or cookies()) refused every capture of its route. The exemption is async-local, so a "use cache" body, a loader body and a segment funnel end it on entry: a key() that awaited a cached function reading cookies() stored the first visitor's cookie. Breaking: a handler under cache() or on a ppr route, or a "use cache" body, that reads ctx.request.headers (or a clone's headers) now throws or refuses the capture. Copy the value in middleware (ctx.set) and include it in key(), read it in a live loader, or pass it into the "use cache" function as an argument. The test fixtures and the test-app, cloudflare-basic and vite-rsc-demo pages that rendered a partition header from a cached handler now render the middleware copy; vite-rsc-demo's unreferenced PostRoute is removed. getRequestContext().theme has been guarded since #979. Tests: request-headers.test.ts (the getter; fetch, new Request, clone and util.inspect in every refusing scope on undici; the srvx facade, lazy rawBody; onError), identity-exempt-scope.test.ts (a cached body, loader body and funnel started inside an exempt callback are guarded), the SCOPES table in cookie-store.test.ts (three new surfaces, exempt rows, clone recording), request-raw-reads.rsc-test.tsx through serveShellRequest (keyGenerator, condition() and tags() at capture), and dev + production e2e identity-raw-reads in the router test-app and cloudflare-basic (on workerd, fetch, new Request and clone inside a cache() boundary).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #971.
Problem. Reading the visitor's theme on a
pprorcache()route stored the first visitor's theme and served it to everyone. The reads involved arectx.themein a handler,ctx.themein middleware, andgetRequestContext().theme. Apprshell, a routecache()entry or a"use cache"entry kept that theme until it expired. There was no refusal and no warning, whilecookies()in the same place was already guarded. The shell also stored the capturing visitor's theme as itsinitialTheme. So on a HIT, a visitor with no stored theme kept another visitor'suseTheme().themefor the life of the page. The<html>class was right, because the inline theme script readsdocument.cookie. Handler output built from the theme was wrong.Before / after:
Consumer example:
A server-side read moves into a live loader (no
ssr: false) underloading():createLoader(async () => cookies().get("theme")?.value ?? "system").Semantics:
ctx.theme,pprcapturectx.theme, routecache()missctx.theme/getRequestContext().themein"use cache"ctx.themeinside"use cache"getRequestContext().themein a handler underppr/cache()ppr/cache()above, or afterctx.dynamic()ssr: falseloader readingcookies()underpprinitialThemeon a HITdefaultTheme{ ...ctx },Object.assign({}, ctx)themethemeabsent (non-enumerable getter)ctx.theme = xTypeError; typedreadonlyThe exemptions are exactly the ones
cookies()has. TheSCOPEStable incookie-store.test.tschecks all three reads againstcookies()in 8 scopes. Router-internal reads (initialThemefor a non-pprdocument, the 404 path, the PE/no-JS action path) go through an unguarded internal accessor (payloadInitialTheme/_readTheme()).Code.
server/cookie-store.ts:assertThemeReadAlloweduses the same predicates ascookies(), through a sharedrefusingCacheScope().tripShellCaptureGuardrecords{ surface, fix }for the refusal warning.router/handler-context.ts,router/middleware.ts,server/request-context.ts: thethemegetters are shared and non-enumerable. An enumerable getter tripped the guard in dev, because React's dev Flight debug info serializes server component props;/theme/ppr-clientpins that case.rsc/full-payload.ts,rsc/shell-capture.ts,rsc/progressive-enhancement.ts,rsc/handler.ts,router/prerender-match.ts: internal reads usepayloadInitialTheme/_readTheme(). The capture storesdefaultTheme.readonly themeonHandlerContext,MiddlewareContextandRequestContext.Test plan.
serveShellRequest):src/testing/__tests__/theme-read-guard.rsc-test.tsx. It covers:pprrefusal;cache()and"use cache"throws;getRequestContext().theme, middleware in"use cache", andinitialTheme = defaultTheme;cookies()control, pinning the real refusal text.cookie-store.test.ts(the SCOPES parity table),shell-capture.test.ts,theme-readonly-types.test.ts,run-loader.test.ts.f24d4597, for example:expected '1:"…' not to contain 'theme-is-dark';expected [ 200, 200, 500 ] to deeply equal [ 500, 500, 500 ];expected 'dark' to be 'system';Expected: "MISS" Received: "HIT".theme-ppr.test.ts(3+3),theme(17+17),semantic-matrix(25+28);ppr-theme.test.ts(3+3).test:unit:all, lint, format,check:docs-api,check:e2e-bucketing,check:e2e-parity --strict.Notes.
ctx.themeat runtime, because its ctx is a spread of the request context andLoaderContextnever typedtheme.ctx.request.headersandgetRequestContext().cookie()/cookies()still bypass the guards.getRequestContext().themeis covered here.initialTheme.