Skip to content

fix(router): a document-cache entry stores the default theme, not the first visitor's (#978) - #987

Merged
ivogt merged 1 commit into
mainfrom
fix/doc-cache-theme-978
Sep 30, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/doc-cache-theme-978

Conversation

@ivogt

@ivogt ivogt commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #978.

Problem. The document cache (opt-in via s-maxage) stored the full HTML, including metadata.initialTheme read from the theme cookie of the visitor who rendered it. Every visitor served that cached document got the first visitor's initialTheme. A visitor with no stored theme then kept that theme's useTheme().theme / resolvedTheme for the life of the page, because ThemeProvider only re-syncs from an explicit cookie or localStorage value. The <html> class was still right, since the inline theme script reads document.cookie, so the bug showed up as a wrong toggle label or icon. #979 fixed the same thing for PPR shells.

Before / after:

// middleware or handler opts the page into the document cache
ctx.header("Cache-Control", "public, s-maxage=60");
// Before: a dark visitor MISSes and stores initialTheme "dark"; a no-cookie visitor HITs and useTheme() says "dark".
// After: the stored document carries the default theme; a visitor with a stored theme re-syncs after hydration.

Consumer example: set Cache-Control in the handler, or in middleware before await next().

export const cachePublic: Middleware = async (ctx, next) => {
  ctx.header("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
  await next();
};

Semantics:

Render initialTheme Stored
Opted in before next() (handler, or middleware before next()) default yes
Stale refresh of an opted-in entry, by any visitor default yes
Opted in after next() / in onResponse, dark visitor visitor's (dark) no (refused)
Opted in after next(), default-theme visitor default yes
Not opted in visitor's no
404 (unmatched), even with the middleware opt-in visitor's no
No theme config unchanged unchanged

Code.

  • cache/document-cache.ts:
    • The middleware sets _documentCacheRender after its store lookup.
    • documentCacheStoresRender(reqCtx) runs the cache's own shouldCacheResponse against the pending response headers.
    • shouldCacheResponse refuses a response that rendered a non-default theme (_payloadVisitorTheme), which catches a late opt-in.
  • rsc/full-payload.ts: payloadInitialTheme returns defaultTheme when both hold, and otherwise the visitor's theme (marking it).
  • rsc/handler.ts: the unmatched 404 reads the visitor's theme through the unguarded accessor, because a 404 is never stored.
  • Neither field is public: both are in the PublicRequestContext omit list.

Test plan.

  • Userland, src/testing/__tests__/document-cache-theme.rsc-test.tsx (through serveShellRequest):
    • a dark visitor warms, then a no-cookie HIT gets the default;
    • an uncached page keeps the visitor's theme;
    • a late header stores only a default-theme render;
    • a stale refresh by a dark visitor stores the default;
    • a 404 keeps the visitor's theme.
  • Unit: document-cache.test.ts (5 new), cookie-store.test.ts (1 new).
  • Red on origin/main, e.g. expected '…"initialTheme":"dark"…' to contain '"initialTheme":"system"'.
  • E2e, dev + production:
    • test-app theme-doc-cache (a scoped createDocumentCacheMiddleware on /theme/doc-cache/*), plus theme-ppr and theme: 66 passed with dependencies;
    • cloudflare-basic document-cache-theme, ppr-theme, blog-cache, theme: 32 passed, 1 skipped (the existing fixme).
  • typecheck, test:unit:all (router 6618 unit + 300 rsc; all consumer apps), lint, format, docs-api, bucketing, parity --strict.

Notes.

  • s-maxage also lets a platform CDN store the response. This fix covers the in-app document cache, because it needs the middleware's flag.
  • A handler reading ctx.theme on a document-cached route is still unguarded. The theme docs list this in their safe-read table.

… first visitor's (#978)

The document cache stored metadata.initialTheme from the theme cookie of
the visitor who rendered the entry, and served it to every later visitor.
A visitor with no stored theme kept that visitor's useTheme().theme for
the life of the page, because ThemeProvider re-syncs only from an
explicitly stored theme. The <html> class was right, since the theme
script reads document.cookie.

The document-cache middleware now marks the render it may store
(_documentCacheRender, set after the store lookup, so it covers the MISS
and the stale refresh). payloadInitialTheme renders the no-cookie default
when that mark is set and the response stub already passes the cache's
own shouldCacheResponse (documentCacheStoresRender), as a PPR shell
capture does. So a render whose response opted in before next() starts
at the default; any other render keeps the visitor's theme. The
unmatched-route 404 reads _readTheme() directly: middleware can opt its
URL in while the stub is still a 200, and a 404 is never stored.

A Cache-Control written after await next() or in onResponse is not on the
stub when the payload is built. For that case payloadInitialTheme marks
_payloadVisitorTheme when it emits a theme other than the default, and
shouldCacheResponse refuses to store the response, so a late opt-in only
stores a default-theme render.
@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 97adcb7

@ivogt
ivogt merged commit 1c29b91 into main Sep 30, 2026
43 checks passed
@ivogt
ivogt deleted the fix/doc-cache-theme-978 branch September 30, 2026 07:23
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: a document-cache entry replays the first visitor's initialTheme to every visitor

1 participant