Skip to content

fix(router): stabilize rango-state cookie — review findings + test gaps - #566

Merged
ivogt merged 1 commit into
feat/rango-state-cookiefrom
fix/rango-state-cookie-stabilization
Jun 12, 2026
Merged

ivogt merged 1 commit into
feat/rango-state-cookiefrom
fix/rango-state-cookie-stabilization

Conversation

@ivogt

@ivogt ivogt commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #560 (base: feat/rango-state-cookie). This is the stabilization delta for the rango-state cookie work — three adversarial review rounds over #560 surfaced one latent data bug, three fence/guard correctness defects, and a set of missing contracts. Review it as the diff on top of #560.

Problem statement (consumer-observable)

Three issues a consumer could actually hit, none caught by #560's own tests:

  1. A deploy mid-action could brick a tab. If a server action hits a freshly-deployed (version-mismatched) worker and the user cancels the resulting reload (a beforeunload "Stay" on a dirty form), the action fence leaked permanently: prefetch consumption stayed suspended and every fetch went no-store/SWR for the rest of the session.
  2. A build version containing : or % silently defeated cache invalidation. The server's monotonic guard double-decoded the prior state value, so a same-millisecond server rotation could mint a value equal to the client's — the divergence observer never fired and the client kept serving stale, Vary-keyed bytes.
  3. A pinned version could leak one user's Set-Cookie to another. The CF response cache stripped per-client signals only on write. A KV envelope written by a pre-fix build, or persisted under a consumer-pinned options.version, replayed its stored Set-Cookie (a session/consent cookie a loader set) to the next client for the whole ttl+swr window.

Before / after

(2) Server monotonic guard — request-context.ts:

// before: getParsedCookies() decodeURIComponent's the value, so a wire value
// "a%3Ab:1700000000" parses to "a:b:1700000000" -> split on the wrong ':'
// -> NaN timestamp -> prevTs 0 -> a same-ms server mint can equal the client.
const prevRaw =
  request.headers.get("x-rango-state") ??
  getParsedCookies()[stateCookieName] ?? null;

// after: raw read (decoded exactly once by decodeStateValue, matching the
// client), shared parser, and '' header/cookie counts as absent.
const prevRaw =
  (request.headers.get("x-rango-state") || null) ??
  getRawCookieValue(cookieHeader, stateCookieName);

(3) CF read-side strip — cf-cache-store.ts:

// before: kvGetResponse rebuilt the served Response and re-seeded L1 from
// envelope.hd verbatim; toClientResponse stripped only internal CACHE_* headers.
const headers = new Headers(envelope.hd);

// after: strip per-client signals on every serve path. Inside the corrupt-
// envelope try, so a malformed hd evicts instead of throwing through.
envelope.hd = envelope.hd.filter(([name]) => !isPerClientSignalHeader(name));
const headers = new Headers(envelope.hd);
// ...and toClientResponse() now also calls stripPerClientSignals(headers).

Terminal semantics (the fence-leak class)

Every action terminal must release the fence exactly once; the never-settling navigate-away terminals can't rely on the finally.

Terminal Settles? Releases fence Invalidates
normal / error / abort / intercept / concurrent yes finally → finalizeAction() once (unless keepClientCache)
simple redirect (X-RSC-Redirect) never pre-return finalizeAction() once (mutation committed)
router-id mismatch never pre-return finalizeAction() once
version mismatch (X-RSC-Reload) never onReload → finalizeAction(true) no (short-circuited server-side; nothing ran)

The reload row is the one this PR adds — it was the only never-settling terminal without an explicit release. It skips invalidation deliberately: nothing executed server-side, and a broadcast would only risk hard-reloading a sibling tab mid-task.

Code-change summary

  • browser/server-action-bridge.ts — finalizeAction(skipInvalidation?) + try/finally; onReload releases the fence without invalidating; ungated repair-refetch comments point at the spec keep row.
  • server/request-context.ts — raw monotonic-guard read via shared getRawCookieValue; empty header/cookie treated as absent; local parser removed.
  • cache/cf/cf-cache-store.ts — read-side strip in toClientResponse + kvGetResponse (filter inside the corrupt-envelope try).
  • browser/cookie-name.ts — single PER_CLIENT_SIGNAL_HEADERS source; stripPerClientSignals; shared getRawCookieValue (also used by the client readCookie).
  • browser/{navigation-store,types}.ts — removed dead broadcastCacheInvalidation.
  • Docs — design-doc terminal enumeration (+reload), keep-row exemption + end-state-delta, read-side strip rule; feature-file-map (+action-fence).

Test plan

  • pnpm run typecheck
  • pnpm run test:unit — 3630 pass (+ new: latch per terminal incl. reload times(0), equals-mirror observer, credentials/no-store on nav + prefetch, percent-encoded + empty header/cookie mint, CF KV-replay strip, malformed-hd evict, L1-serve strip)
  • pnpm run lint / pnpm run format
  • semantic matrix — green (dev + production)
  • e2e dev + production — cookie-cleared-mid-session mints via popstate; Back-after-redirect revalidates the pre-mutation page
  • pnpm check:e2e-bucketing (pre-commit) — every build-fixture describe correctly bucketed

Notes

  • Deliberately not changed (verified, left alone): the getRangoState() double-read (load-bearing — the await between the prefetch-key read and the header read lets an external rotation correctly miss the stale entry); abort-before-response rotating despite keep intent (fail-toward-freshness).
  • Deferred to follow-up (pure cleanup, no behavior): extracting a buildPayloadMetadata() helper across the five RSC render sites; hoisting the e2e readRangoState parser into the shared helper.

Three review rounds over the rango-state cookie branch (#560).

Correctness:
- Action fence leaked on the X-RSC-Reload (version-mismatch) terminal: like the
  simple-redirect and router-id-mismatch terminals it navigates away via a
  never-settling promise, so the finally never runs. finalizeAction() now runs
  in onReload, skipping invalidation (the mismatch short-circuits server-side, so
  nothing mutated and a broadcast would only risk hard-reloading a sibling).
- _rotateStateCookie's monotonic guard read the prior value through the decoding
  getParsedCookies, double-decoding any version containing ':' or '%' (NaN
  timestamp -> prevTs 0 -> same-ms collision the divergence observer can't see).
  Reads raw via the shared getRawCookieValue now; an empty header/cookie counts
  as absent (no '' short-circuit of the ?? chain).
- CF response cache replayed per-client signals on READ: a pre-fix or
  pinned-`version` KV envelope (or L1 entry) could replay one client's Set-Cookie
  to another. toClientResponse and kvGetResponse strip now; the KV hd filter is
  inside the corrupt-envelope try so a malformed hd evicts rather than throwing.
- finalizeAction wraps invalidation in try/finally so a throw can't leak the
  fence; removed the dead NavigationStore.broadcastCacheInvalidation.

Consolidation:
- One PER_CLIENT_SIGNAL_HEADERS source for the strip/predicate/presence helpers.
- One getRawCookieValue shared by both seats — their agreement IS the guard's
  contract, so the client mirror and server read the same jar entry.

Tests (dev + production where e2e):
- Latch exactly-once per terminal incl. simple-redirect (success + keepCache) and
  reload (no invalidation); equals-mirror observer no-op.
- credentials-not-omit + no-store-under-fence on navigation and prefetch fetches.
- percent-encoded + empty header/cookie server mint.
- CF read-side strip: KV replay, malformed-hd evict, L1 serve.
- cookie-cleared-mid-session mints fresh via popstate; Back-after-redirect
  revalidates the pre-mutation page.

Docs: design-doc terminal enumeration (+reload), keep-row exemption + end-state
delta, read-side strip rule; feature-file-map (+action-fence).
@ivogt
ivogt merged commit d6cb3e0 into feat/rango-state-cookie Jun 12, 2026
23 checks passed
@ivogt
ivogt deleted the fix/rango-state-cookie-stabilization branch June 12, 2026 20: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.

1 participant