Skip to content

fix(router)!: ctx.theme read in a handler refuses a PPR capture and throws in a cache() miss, like cookies() (#971) - #979

Merged
ivogt merged 1 commit into
mainfrom
fix/theme-capture-guard-971
Sep 29, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/theme-capture-guard-971

Conversation

@ivogt

@ivogt ivogt commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #971.

Problem. Reading the visitor's theme on a ppr or cache() route stored the first visitor's theme and served it to everyone. The reads involved are ctx.theme in a handler, ctx.theme in middleware, and getRequestContext().theme. A ppr shell, a route cache() entry or a "use cache" entry kept that theme until it expired. There was no refusal and no warning, while cookies() in the same place was already guarded. The shell also stored the capturing visitor's theme as its initialTheme. So on a HIT, a visitor with no stored theme kept another visitor's useTheme().theme for the life of the page. The <html> class was right, because the inline theme script reads document.cookie. Handler output built from the theme was wrong.

Before / after:

path("/settings", (ctx) => <p>{`theme-is-${ctx.theme}`}</p>, { ppr: true });
// Before: a dark visitor captures; a light visitor HITs and sees "theme-is-dark".
// After: the capture is refused (warned once per URL); every request is a MISS rendered with its own theme.

cache({ ttl: 300 }, () => [path("/settings", (ctx) => <p>{ctx.theme}</p>)]);
// Before: 200, the second visitor gets the first visitor's theme.
// After: 500, same error class as cookies() inside cache().

Consumer example:

// urls.tsx
path("/settings", () => <SettingsPage />, { ppr: true });

// settings-page.tsx
"use client";
import { useTheme } from "@rangojs/router/theme";
export function SettingsPage() {
  const { theme } = useTheme();
  return <p>{`theme: ${theme}`}</p>;
}

A server-side read moves into a live loader (no ssr: false) under loading(): createLoader(async () => cookies().get("theme")?.value ?? "system").

Semantics:

Where the theme is read Before After
Handler / layout ctx.theme, ppr capture baked first visitor's theme capture refused, warned once per key, MISS per request
Handler ctx.theme, route cache() miss stored, served to all throws (500)
ctx.theme / getRequestContext().theme in "use cache" stored throws
Middleware ctx.theme inside "use cache" stored throws
getRequestContext().theme in a handler under ppr / cache() baked refused / throws
No ppr/cache() above, or after ctx.dynamic() visitor's theme unchanged
Live loader reading the theme cookie fresh per request unchanged
ssr: false loader reading cookies() under ppr capture refused unchanged
Shell initialTheme on a HIT capturing visitor's theme defaultTheme
{ ...ctx }, Object.assign({}, ctx) copied theme theme absent (non-enumerable getter)
ctx.theme = x assigned TypeError; typed readonly

The exemptions are exactly the ones cookies() has. The SCOPES table in cookie-store.test.ts checks all three reads against cookies() in 8 scopes. Router-internal reads (initialTheme for a non-ppr document, the 404 path, the PE/no-JS action path) go through an unguarded internal accessor (payloadInitialTheme / _readTheme()).

Code.

  • server/cookie-store.ts: assertThemeReadAllowed uses the same predicates as cookies(), through a shared refusingCacheScope(). tripShellCaptureGuard records { surface, fix } for the refusal warning.
  • router/handler-context.ts, router/middleware.ts, server/request-context.ts: the theme getters 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-client pins that case.
  • rsc/full-payload.ts, rsc/shell-capture.ts, rsc/progressive-enhancement.ts, rsc/handler.ts, router/prerender-match.ts: internal reads use payloadInitialTheme / _readTheme(). The capture stores defaultTheme.
  • Types: readonly theme on HandlerContext, MiddlewareContext and RequestContext.

Test plan.

  • Userland (serveShellRequest): src/testing/__tests__/theme-read-guard.rsc-test.tsx. It covers:
    • the ppr refusal;
    • the cache() and "use cache" throws;
    • getRequestContext().theme, middleware in "use cache", and initialTheme = defaultTheme;
    • a cookies() control, pinning the real refusal text.
  • Unit: cookie-store.test.ts (the SCOPES parity table), shell-capture.test.ts, theme-readonly-types.test.ts, run-loader.test.ts.
  • Red on 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';
    • e2e production Expected: "MISS" Received: "HIT".
  • E2e, dev + production:
    • test-app theme-ppr.test.ts (3+3), theme (17+17), semantic-matrix (25+28);
    • cloudflare-basic ppr-theme.test.ts (3+3).
  • typecheck, test:unit:all, lint, format, check:docs-api, check:e2e-bucketing, check:e2e-parity --strict.

Notes.

@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@979

commit: 3cd7954

…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
ivogt force-pushed the fix/theme-capture-guard-971 branch from c5d8481 to 3cd7954 Compare September 29, 2026 19:52
@ivogt
ivogt merged commit 36e21af into main Sep 29, 2026
43 checks passed
@ivogt
ivogt deleted the fix/theme-capture-guard-971 branch September 29, 2026 20:02
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).
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.

theme: ctx.theme in a handler bakes the first visitor's theme into a shared PPR shell or cache() entry

1 participant