Skip to content

.data single-fetch responses are hard-cached public,max-age=600 — post-mutation revalidation serves stale loader data, with no opt-out #2366

Description

@RupanRC

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:

  1. 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).
  2. An option on createH3SSRHandler (e.g. dataCacheControl: "no-store").
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions