createH3SSRHandler's rewriteMountedResponse applies DEFAULT_SSR_CACHE_HEADERS
(public, max-age=600, stale-while-revalidate=604800, plus the two CDN variants) to
every React Router .data response — isSsrHtmlOrDataResponse matches any *.data
path with content-type: text/x-script (packages/core/src/server/ssr-handler.ts,
applyDefaultSsrCacheHeader).
React Router's useRevalidator() / redirect-after-action re-fetches loader data with a
plain GET, so the browser (and any shared CDN) serves the cached copy: after any
successful mutation the UI renders loader data up to 10 minutes stale. We verified
byte-identical .data payloads bracketing a 200 OK mutation, in curl and in
authenticated in-browser Playwright sessions.
Repro (any agent-native app whose loader returns mutable state):
curl -s http://localhost:3231/forge/features.data > a.txt # 1. prime cache
curl -s -X POST http://localhost:3231/api/.../comments -d '...' # 2. mutate via an API route (200 OK)
curl -s http://localhost:3231/forge/features.data > b.txt # 3. re-fetch within 600 s
cmp a.txt b.txt && echo "STALE: byte-identical after mutation"
The anonymous-SSR contract documented in applyDefaultSsrCacheHeader covers
personalization — and we're not asking to reintroduce per-user variation. Our loaders
render identically for everyone. The uncovered axis is mutability: apps whose
impersonal loader data changes through user actions (CRUD-heavy tools) have no correct
pattern today. Route-level headers() exports are overwritten (the rewrite runs after
route code), createH3SSRHandler takes no options, and /api/* is the only exempt
path.
Asks — any one of:
- Honor an explicit route-level
cache-control on .data responses: apply
DEFAULT_SSR_CACHE_HEADERS only when route code didn't set one (the public-shell
default stays intact for every route that says nothing).
- An option on
createH3SSRHandler (e.g. dataCacheControl: "no-store").
- Documented guidance that mutation-fresh reads must go through
/api/* + client
fetch, never loader .data — so app authors can conform deliberately.
Secondary note: public on authenticated apps' .data means a shared CDN can serve one
visitor's loader payload to another whenever loaders do bake tenant-scoped data — the
anonymous-SSR guardrail assumes apps never do, but nothing enforces it.
Companion PR sketch (ask 1) — smallest diff that preserves the guardrail
(cookie/vary stripping untouched, defaults unchanged for routes that say nothing):
// packages/core/src/server/ssr-handler.ts — applyDefaultSsrCacheHeader, final loop
// (upstream main l.282 as of 2026-07-23)
- for (const [name, value] of Object.entries(DEFAULT_SSR_CACHE_HEADERS)) {
- headers.set(name, value);
- }
+ // Respect an explicit route-level policy; default only when the route said nothing.
+ if (!headers.has("cache-control")) {
+ for (const [name, value] of Object.entries(DEFAULT_SSR_CACHE_HEADERS)) {
+ headers.set(name, value);
+ }
+ }
Plus one test: a route with headers: () => ({ "cache-control": "no-store" }) keeps
no-store on its .data response; a route without stays public, max-age=600.
On the open question of whether React Router itself ever emits a default
cache-control on .data responses (which would defeat the has() check): we greped
react-router 8.2.0's dist (dist/development/*.js) and found zero cache-control
emissions, so the has() check is viable as of that version — worth re-confirming in
the PR discussion.
Verified against upstream main on 2026-07-23: applyDefaultSsrCacheHeader still sets
all three headers unconditionally, and createH3SSRHandler still takes no options.
Happy to open the PR for ask 1 if the approach looks right.
createH3SSRHandler'srewriteMountedResponseappliesDEFAULT_SSR_CACHE_HEADERS(
public, max-age=600, stale-while-revalidate=604800, plus the two CDN variants) toevery React Router
.dataresponse —isSsrHtmlOrDataResponsematches any*.datapath with
content-type: text/x-script(packages/core/src/server/ssr-handler.ts,applyDefaultSsrCacheHeader).React Router's
useRevalidator()/ redirect-after-action re-fetches loader data with aplain GET, so the browser (and any shared CDN) serves the cached copy: after any
successful mutation the UI renders loader data up to 10 minutes stale. We verified
byte-identical
.datapayloads bracketing a 200 OK mutation, in curl and inauthenticated in-browser Playwright sessions.
Repro (any agent-native app whose loader returns mutable state):
The anonymous-SSR contract documented in
applyDefaultSsrCacheHeadercoverspersonalization — and we're not asking to reintroduce per-user variation. Our loaders
render identically for everyone. The uncovered axis is mutability: apps whose
impersonal loader data changes through user actions (CRUD-heavy tools) have no correct
pattern today. Route-level
headers()exports are overwritten (the rewrite runs afterroute code),
createH3SSRHandlertakes no options, and/api/*is the only exemptpath.
Asks — any one of:
cache-controlon.dataresponses: applyDEFAULT_SSR_CACHE_HEADERSonly when route code didn't set one (the public-shelldefault stays intact for every route that says nothing).
createH3SSRHandler(e.g.dataCacheControl: "no-store")./api/*+ clientfetch, never loader
.data— so app authors can conform deliberately.Secondary note:
publicon authenticated apps'.datameans a shared CDN can serve onevisitor's loader payload to another whenever loaders do bake tenant-scoped data — the
anonymous-SSR guardrail assumes apps never do, but nothing enforces it.
Companion PR sketch (ask 1) — smallest diff that preserves the guardrail
(cookie/vary stripping untouched, defaults unchanged for routes that say nothing):
Plus one test: a route with
headers: () => ({ "cache-control": "no-store" })keepsno-storeon its.dataresponse; a route without stayspublic, max-age=600.On the open question of whether React Router itself ever emits a default
cache-controlon.dataresponses (which would defeat thehas()check): we grepedreact-router 8.2.0's dist (
dist/development/*.js) and found zerocache-controlemissions, so the
has()check is viable as of that version — worth re-confirming inthe PR discussion.
Verified against upstream
mainon 2026-07-23:applyDefaultSsrCacheHeaderstill setsall three headers unconditionally, and
createH3SSRHandlerstill takes no options.Happy to open the PR for ask 1 if the approach looks right.