Repository navigation
feat(router): rango state session cookie + invalidateClientCache/keepClientCache - #560
Merged
Merged
Conversation
Design + review pass for moving the rango state from localStorage to a session cookie and collapsing cache invalidation to invalidateClientCache() / keepClientCache(). Open mechanism decisions are tagged [resolve before implementing] inline.
…lumbing
Additive, no behavior change. Adds the stateCookiePrefix router option, resolves
the rango state cookie name once at router init ({sanitizedPrefix}_{sanitizedRouterId};
the prefix charset excludes _ so the first _ is always the prefix/routerId
boundary, keeping the name injective), exposes resolvedStateCookieName on the
router, and ships it in payload metadata following the prefetchCacheTTL path.
New cookie-name.ts (value codec + attribute serialization, shared client/server,
no name composition) and state-cookie-name.ts (server-only name composition).
Nothing consumes the cookie yet; the storage cutover is Phase 2.
Unit: cookie-name (encode/decode round-trip, malformed -> null, attributes),
state-cookie-name (default + sanitization + empty-fallback + injective separator).
Rewrites rango-state.ts to store the X-Rango-State value in a session cookie named by the server-resolved name (initRangoState now takes the resolved name, not routerId; bare default prefix when metadata lacks it). The per-request document.cookie read IS the cross-tab value sync, so the storage event is gone. An in-memory mirror is a write-through fallback authoritative only when the cookie is unreadable; cookieBacked tracks jar presence so an external clear is detected once. Adds the max(now, prev+1) monotonic guard, one-time legacy localStorage cleanup, and an unregistered external-rotation observer hook (wired to the store handle in Phase 3). Couples cache-key lifecycle to the session: clearing cookies takes the state with it. Ships stateCookieName on RscMetadata (client read). Unit: boot keep-vs-mint, per-request read, mirror-on-unreadable, monotonic guard, legacy cleanup, observer fires-once/self-rotation-silent/external-clear/ multi-app-isolation. E2e rango-state-invalidation reads the cookie now (dev green; full dev+prod at the pre-bridge checkpoint).
Adds a dependency-light navigation-store-handle module that holds the live store reference (the getNavigationStore singleton is never populated by the real boot path) and wires the jar-divergence observer: when a per-request cookie read detects an EXTERNAL rotation (sibling tab, server Set-Cookie, cookie clear), this tab marks its history cache stale. The history cache is not state-keyed, so the value rotation alone never reaches it. Splits the store's stale-marking: markHistoryCacheStale() marks history entries only, WITHOUT clearing the prefetch caches or rotating the state (the existing markCacheAsStale path still does both). This is load-bearing: routing the observer through the prefetch-clearing path would re-rotate on every observed rotation and ping-pong across tabs. Boot registers the store on the handle before any getRangoState() read. Unit: handle register/get, observer fires once on external rotation with no re-rotation (no ping-pong), silent on self-rotation; markHistoryCacheStale marks entries stale.
…ache Adds invalidateClientCache(), exported from the root entry and selected by export conditions like cookies(): - default (index.ts -> browser/invalidate-client-cache.ts): client seat. Guards document (SSR no-op + dev warning behind a foldable NODE_ENV check), then marks the history cache stale + broadcasts (SWR, same path as the action bridge), with a pre-boot clearPrefetchCache fallback. - react-server (index.rsc.ts -> server/cookie-store.ts): server seat. Writes ONE rotated Set-Cookie via the resolved cookie name, idempotent within a request, inert (dev warning) outside a request context (non-throwing _getRequestContext), and guarded inside a cache boundary (assertNotInsideCacheContext, allowed in loaders). The timestamp is max(serverNow, inboundClientTs+1) so a same-ms server rotation still differs from the client value. Mechanics: createRequestContext gains stateCookieName + version (threaded from the router at handler.ts), captured by a new closure method ctx._rotateStateCookie() that appends the raw Set-Cookie with a literal ':' (not serializeCookieValue's URL-encoded form). prerender synthetic contexts get a no-op. Removes useClientCache + ClientCacheControls (hard delete, pre-release hygiene): the hook, its barrel re-export (browser/react/index.ts), client.tsx exports, the client.rsc.tsx comment, two navigation-store.ts comments, its test; migrates the vite-rsc-demo dogfood to invalidateClientCache() (root import); documentation-imports gains a positive root entry for invalidateClientCache and drops useClientCache. Docs-sync: feature-map.md (export enumeration + cookie-based cross-tab note, fixed the stale 'page visibility events' wording), feature-file-map.md (new rango-state row, dropped use-client-cache), SKILL.md, and registers the design doc in the package docs hub. Unit: server seat (one Set-Cookie, literal colon, idempotent, monotonic-vs-inbound, no-name no-op, cache-guard throw) + client seat (SSR no-op, store mark-stale, pre-boot fallback). E2e client/server seat deferred to the pre-bridge checkpoint.
Replaces the action bridge's eager clear-at-start with a refcounted fence whose invalidation decision is deferred to the response. New action-fence.ts: a refcounted flag raised while any action is in flight. While up: prefetch consumption is suspended (navigation-client canUsePrefetch), a genuine mid-action navigation fetches with cache:no-store so the browser HTTP cache can't serve stale prefetched bytes (navigation-client doFreshFetch), and popstate is treated as SWR (navigation-bridge isStale || fenceActive). Nothing is rotated/wiped/broadcast while up, so sibling tabs see no pre-commit signal. server-action-bridge: :164 markCacheAsStaleAndBroadcast becomes enterActionFence; exitActionFence in finally (refcount lifts at zero). The response is read for the x-rango-keep-cache directive, which gates every terminal invalidation site (navigated-away, normal, hmr-missing, consolidation) on !keepCache. A directive-free action reproduces today's post-commit end state; the decision is per-response, so a concurrent directive-free action still invalidates. keepClientCache(): root entry, export-condition selected. react-server (cookie-store) sets the x-rango-keep-cache response header via a new ctx._setKeepCacheDirective() (idempotent .set), inert outside a request, guarded inside a cache boundary. default (browser/invalidate-client-cache) is a warn no-op. Gate: semantic matrix stays green (39, dev+prod) — directive-free actions are observationally equivalent; rango-state-invalidation still rotates end to end. Unit: action-fence refcount (incl. not-dropped-while-pending), keepClientCache server seat (header, idempotent, inert, cache-guard) + client no-op. The keep / invalidate SEAT e2e (new test-app routes) lands with Phase 6.
…ores (Finding #3) A Set-Cookie (e.g. a rango state rotation from invalidateClientCache(), or any cookie a loader set on a cached document) stored in a SHARED response store is replayed to every client on a hit — pinning them to one value and even rolling a rotated client back to a prior one. The x-rango-keep-cache directive header is the mirror: a replayed 'keep' would suppress invalidation for replayed clients. Primary chokepoint: shouldCacheResponse (document-cache.ts) now returns null when the response carries Set-Cookie or x-rango-keep-cache, so the document-cache middleware never stores them. Defense-in-depth: MemorySegmentCacheStore and the CF KV envelope strip both headers in putResponse (public, reachable directly by tag-revalidation re-puts that bypass the chokepoint). CF L1 cache.put already throws on Set-Cookie responses (platform behavior). Unit: document-cache refuses an s-maxage response carrying Set-Cookie or x-rango-keep-cache; MemorySegmentCacheStore strips both while keeping other headers.
…ction) Adds test-app routes exercising the new directives on the existing /loader-cookie/action-sets-cookie page: actionKeepCache (keepClientCache only) and actionKeepThenInvalidate (both verbs), with buttons. E2e (dev + production): - keepClientCache() leaves the rango-state value unchanged (the fence-and-defer directive suppresses the automatic rotation end to end); - keepClientCache() + invalidateClientCache() still rotates (invalidation wins: the explicit Set-Cookie from the server seat lands regardless), which also exercises the invalidateClientCache server seat end to end. No routes added/removed, so no generated-route-file change.
…tion) A browser-code invalidateClientCache() (the client seat) rotates the rango state value via markCacheAsStaleAndBroadcast -> clearPrefetchCache -> invalidateRangoState. Adds an InvalidateClientCacheButton to the action-sets-cookie page and asserts the value rotates (version preserved, timestamp advanced) dev + production. All three seats are now e2e-covered: client invalidate, server invalidate, and keepClientCache.
…ation gaps) The fence-and-defer restructure scattered invalidation across terminal scenarios that not every action path reaches, breaking main's invariant that EVERY action invalidates. Three defects, one family: - Mutate-then-redirect actions (Flight metadata.redirect and X-RSC-Redirect paths) returned before the scenario switch, so no rotation/wipe/stale-mark/ broadcast — Back served the pre-mutation page as fresh. - The simple-redirect path returns a never-settling promise, so createFromFetch never settled and the finally (the only exitActionFence) never ran: the fence refcount leaked permanently, disabling prefetch + forcing no-store/SWR for the whole session. - Error, abort, intercept navigated-away, and concurrent-skip/consolidation terminals all skipped invalidation, breaking the 'same end state as today' parity row (incl. a directive-free action overlapping a keep action never invalidating). Fix: one latched finalizeAction() — invalidate once unless keepCache, then exitActionFence — called from the finally AND before the never-settling SPA-redirect returns. Removes the five scattered per-scenario invalidation gates; the scenarios keep only their revalidation refetches (suppressed on keep). Gate: semantic matrix 39 (dev+prod) still green; rango-state + keep/invalidate e2e green. Redirect-invalidation / Back e2e to follow.
… cookie, observer/fence gaps Addresses the review findings beyond the bridge family: - #6 decodeStateValue wraps decodeURIComponent in try/catch: a malformed percent-escape ('%:1') returned null instead of throwing (was a 500 on the server seat and a boot failure on the client). - #4 CF L1 putResponse strips Set-Cookie + the directive header (only KV/L2 was covered); the in-repo guarantee no longer depends on the unverified platform Set-Cookie rejection, and nothing platform-side rejects the custom directive. - #5 the three full-document payloads that omitted stateCookieName (PE re-render, PE error boundary, notFound) now ship it; a PE-booted or 404 session no longer binds the bare default cookie name while the server writes {prefix}_{routerId}. - #8 _rotateStateCookie's monotonic guard falls back to the request Cookie when X-Rango-State is absent (action POSTs / plain fetch carry no router header but do send the cookie), so a same-ms server mint can't silently equal the client. - #7 popstate restore calls getRangoState() before reading the stale bit so the jar-divergence observer can mark history stale — a server Set-Cookie rotation is no longer invisible to a popstate-first interaction. - #9 prefetch fetches get the fence's cache:no-store too (was navigation-only). - #10 the directive header is now KEEP_CACHE_HEADER + an isPerClientSignalHeader() helper shared by all strip sites (was a bare literal in 5 files). Strip-all Set-Cookie is the documented rule (a shared store can't know the cookie name); the design doc's match-rule line and fence-terminal enumeration are amended. Unit: malformed-escape decode, server mint from the Cookie header. Gates: full unit suite (3614), semantic matrix 39, rango-state e2e 6 — all green.
commit: |
…ate cookie
The localStorage -> session cookie cutover broke e2e suites in other apps that
still read/wrote rango-state via localStorage (the CI e2e failures):
- vite-rsc-demo prefetch: readRangoState now reads the cookie; the 'persist
across refresh' test no longer asserts localStorage.
- rangojs-router cross-tab-invalidation: pageA now rotates the shared cookie
(the jar is shared across tabs) instead of writing localStorage; pageB picks
it up via the per-request cookie read.
- cloudflare-multi-router isolation: reads/writes the namespaced cookie
(rango-state_{routerId}); the key-format assertion is /^rango-state_/ (was the
colon-separated localStorage key). Multi-app isolation still holds — the
divergence observer compares cookie names.
Validated locally: cross-tab (dev), vite-rsc-demo prefetch (dev), multi-router
isolation (dev + production).
Quality cleanups from a 4-angle review (no behavior change): - Hoist the monotonic mint rule into cookie-name.ts mintStateValue(version, prevRaw); the client mintValue and the server _rotateStateCookie now both call it (was the max(now, prev+1) guard hand-copied into both seats). - document-cache reuses a new hasPerClientSignal(headers) instead of open-coding the Set-Cookie / x-rango-keep-cache presence check, keeping the signal list in one module alongside isPerClientSignalHeader. - Extract the duplicated makeJar cookie-jar mock into src/__tests__/cookie-jar-mock.ts; rango-state and navigation-store-handle suites import it. - Drop a redundant second assertion in the keepClientCache idempotency test.
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).
6 tasks done
fix(router): stabilize rango-state cookie — review findings + test gaps
8 tasks done
ivogt
added a commit
that referenced
this pull request
Jun 13, 2026
Brings in #560 (rango state cookie + invalidateClientCache/keepClientCache), #534 (cache tag invalidation cacheTag/updateTag/revalidateTag), and the CFCacheStore latency-budget/SWR hardening (#558). Non-trivial conflict resolutions: - docs/internal/feature-map.md & feature-file-map.md: unioned both sides' tables. Took main's updated `.`/`./cache` rows (new cache-tag + client-cache exports) plus its `Client cache invalidation` feature row, and kept this branch's `./testing/*` export rows and `Consumer testing primitives` row. - tests/cloudflare-basic/src/urls.tsx: kept main's updateTag/revalidateTag imports but dropped `cookies` - this branch extracted the inline cookies().set("mw-overlay") into middleware/cookie-overlay.ts. - src/testing/render-route.tsx: removed the test NavigationBridge's updateAppShell member; main removed updateAppShell from the NavigationBridge type (app-shell updates now flow only through appShellRef). Verified green: typecheck (12/12), test:unit:all (3915 passed / 6 todo), lint (oxlint 0/0), format (oxfmt), check:e2e-bucketing.
ivogt
added a commit
that referenced
this pull request
Jun 13, 2026
…ags in unit primitives Closes the #560 follow-up flagged on PR #533: the consumer testing primitives now make the rango cache-invalidation directives assertable at the unit layer instead of silently no-opping. What a consumer writes / sees differently: - invalidateClientCache() now ROTATES the rango state cookie under renderHandler / runInRequestContext / runLoader / runMiddleware (the name is seeded by default, rango-state_router_0), so the rotation Set-Cookie is observable on result.response.headers.getSetCookie(). Previously it silently no-opped because the test stub omitted the cookie name (production always resolves one). - New opts.stateCookie ({ prefix?, routerId?, version? }) customizes the seeded name/version to match createRouter({ stateCookiePrefix, id }). - renderHandler / runInRequestContext now return the resolved `stateCookieName`, so a test asserts the rotation without recomputing it. - keepClientCache() already set the x-rango-keep-cache directive header; now it is documented and tested (idempotent). - StateCookieSeed exported from @rangojs/router/testing and /testing/flight. Dogfood (cloudflare-basic, through the public testing entry): - test/client-cache.test.ts: real logoutAction (invalidateClientCache + session clear) and dismissBannerAction (keepClientCache + cookie persist). - test/cache-tags.test.ts: real publishProductAction asserts updateTag('products') evicts a tagged entry in a MemorySegmentCacheStore (read-your-own-writes). This corrects an earlier wrong claim: server-side tag invalidation IS unit-assertable in-process; CFCacheStore+KV is only needed for the distributed cross-colo purge. Docs/skills: testing.md (trap-table clarification), render-handler.md, server-actions.md, loader.md, middleware.md, feature-map.md updated for the new option, the stateCookieName return, and the assertion pattern. Tests: +8 render-handler (rotation idempotent, custom seed, keep idempotent, Response-while-invalidating), +5 runInRequestContext (action rotation, keep, keep+invalidate, https Secure, redirect-merge), +3 cloudflare-basic dogfood. Verified: typecheck 12/12, test:unit:all (router 3801 unit + 45 RSC, cloudflare-basic 42), lint 0/0, format, check:e2e-bucketing.
ivogt
added a commit
that referenced
this pull request
Jun 13, 2026
…ags in unit primitives Closes the #560 follow-up flagged on PR #533: the consumer testing primitives now make the rango cache-invalidation directives assertable at the unit layer instead of silently no-opping. What a consumer writes / sees differently: - invalidateClientCache() now ROTATES the rango state cookie under renderHandler / runInRequestContext / runLoader / runMiddleware (the name is seeded by default, rango-state_router_0), so the rotation Set-Cookie is observable on result.response.headers.getSetCookie(). Previously it silently no-opped because the test stub omitted the cookie name (production always resolves one). - New opts.stateCookie ({ prefix?, routerId?, version? }) customizes the seeded name/version to match createRouter({ stateCookiePrefix, id }). - renderHandler / runInRequestContext now return the resolved `stateCookieName`, so a test asserts the rotation without recomputing it. - keepClientCache() already set the x-rango-keep-cache directive header; now it is documented and tested (idempotent). - StateCookieSeed exported from @rangojs/router/testing and /testing/flight. Dogfood (cloudflare-basic, through the public testing entry): - test/client-cache.test.ts: real logoutAction (invalidateClientCache + session clear) and dismissBannerAction (keepClientCache + cookie persist). - test/cache-tags.test.ts: real publishProductAction asserts updateTag('products') evicts a tagged entry in a MemorySegmentCacheStore (read-your-own-writes). This corrects an earlier wrong claim: server-side tag invalidation IS unit-assertable in-process; CFCacheStore+KV is only needed for the distributed cross-colo purge. Docs/skills: testing.md (trap-table clarification), render-handler.md, server-actions.md, loader.md, middleware.md, feature-map.md updated for the new option, the stateCookieName return, and the assertion pattern. Tests: +8 render-handler (rotation idempotent, custom seed, keep idempotent, Response-while-invalidating), +5 runInRequestContext (action rotation, keep, keep+invalidate, https Secure, redirect-merge), +3 cloudflare-basic dogfood. Verified: typecheck 12/12, test:unit:all (router 3801 unit + 45 RSC, cloudflare-basic 42), lint 0/0, format, check:e2e-bucketing.
ivogt
added a commit
that referenced
this pull request
Jun 13, 2026
…o-JS jar comparison Merging #560 (rango state session cookie) made two consumer-testing-harness parity tests fail in CI. expectParity compares the whole document.cookie jar between the JS and no-JS transports, but the rango state cookie (rango-state_<id>) is written/rotated ONLY by the client runtime (it keys the client's prefetch/HTTP caches and drives the divergence observer), so it exists in the JS jar and never in the no-JS one. The full-jar equality therefore always diverged once the cookie existed: Expected: "parity-count=1; rango-state_5a0ebfec=19ec0492cf8:1781342582085" Received: "parity-count=1" expectParity now excludes the rango state cookie (default prefix rango-state) from the comparison automatically, and gains an `ignoreCookies?: ReadonlyArray<string | RegExp>` option for a custom stateCookiePrefix or other JS-only/volatile cookies (analytics, CSRF). The jar is also sorted before comparison so cookie ORDER differences no longer false-mismatch. The import is the env-agnostic DEFAULT_STATE_COOKIE_PREFIX constant, so the e2e entry stays loadable in a plain Playwright runner. Verified: the two previously-failing tests (e2e/consumer-testing-harness.test.ts "... matches across JS and no-JS") now pass in BOTH dev and production locally; typecheck 12/12, test:unit:all, lint, format.
ivogt
added a commit
that referenced
this pull request
Jun 13, 2026
…533) Ships @rangojs/router/testing — a consumer-facing testing surface across six entries (./testing, ./testing/vitest, ./testing/dom, ./testing/e2e, ./testing/flight, ./testing/flight-matchers) plus @rangojs/router/host/testing. Layers: unit (runLoader/runLoaderResult, runMiddleware, runInRequestContext, createTestRequestContext), integration (dispatch), component (renderRoute), RSC (renderToFlightString/renderServerTree/findClientBoundaries/renderHandler), cross-cut (assertCacheStatus/createCacheSink, diffGeneratedRoutes/collectHandle, telemetry types), e2e (createRangoE2E/parityDescribe/expectParity), host (testPattern/matchesHost). One-line setup via rangoTestConfig. Validated by dogfooding into cloudflare-basic / e2e/mini / vite-rsc-demo and an external app. Includes the #560 follow-up (assertable invalidateClientCache/ keepClientCache via the stateCookie seed + stateCookieName), the cache-tag dogfood, and the full multi-agent review pass (96 findings raised / 44 confirmed / all addressed) — see packages/rangojs-router/docs/internal/reviews/ for the record and the API overview. Full per-wave detail in the PR description.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two consumer-observable problems with the rango state — the value that keys the client's prefetch / HTTP caches (sent as
X-Rango-State, answered withVary):localStorage. Clear your cookies (sign-out, "clear cookies on close", a manual clear) and the session died but the state survived — so the browser's HTTP cache kept serving signed-in payloads (keyed by the old state) for up toprefetchCacheTTLafter the session was gone.fetch()to an API route, a WebSocket "data changed" push, a login handler — none are server actions, so nothing rotated the state or busted the caches. The only client API wasuseClientCache().clear(): a React hook, unusable from a module-level callback, with no server-side counterpart.This PR moves the state from localStorage to a session cookie (coupling the cache-key lifecycle to the session) and collapses the invalidation surface to two functions callable from any seat:
invalidateClientCache()andkeepClientCache().useClientCache()is removed.Before → after
Before — a non-component mutation could not invalidate (the hook is component-only):
After — one import, works from any seat:
End-to-end: opt a hot action out of automatic invalidation
Server actions invalidate automatically — the right default. But some actions change nothing route-visible (a debounced autosave, a no-op save).
keepClientCache()says "this changed nothing a route renders; leave the caches alone":Semantics
invalidateClientCache()keepClientCache()Set-Cookie; client rotates on receipt, history marked stale by the divergence observer at its next readx-rango-keep-cacheresponse header the bridge reads)Set-CookieSet-Cookielands regardlessPull-based and per-client: other clients with warm caches are unaffected (there is no shared server epoch — see Deferred in the design doc).
What changed under the hood
Full design + review in
docs/design/rango-state-cookie.md. Phased:browser/rango-state.tsrewritten to a session cookie{prefix}_{routerId}(default prefixrango-state; newstateCookiePrefixrouter option, injective_-separated name). The per-requestdocument.cookieread is the cross-tab value sync (thestorageevent is gone); an in-memory mirror is a write-through fallback authoritative only when the cookie is unreadable;max(now, prev+1)mint guard; one-time legacy localStorage cleanup. Both seats read the jar through one sharedgetRawCookieValue(their agreement is the monotonic guard's contract); an empty value counts as absent.browser/navigation-store-handle.tswires a jar-divergence observer that marks the history cache stale (history-only, no re-rotation) when a sibling tab / serverSet-Cookie/ cookie-clear rotates the value. It fires at a tab's next jar read — a navigation/prefetch fetch or a popstate restore.invalidateClientCache()selected by export conditions (client marks-stale; server writes a rotatedSet-Cookievia a newctx._rotateStateCookie(), idempotent, monotonic vs the inbound header/cookie read raw, guarded inside a cache scope).keepClientCache()(server sets the directive header; client no-op).useClientCache()hard-removed + full migration.finallynever runs; the reload terminal releases without invalidating (nothing executed server-side). During flight: prefetch consumption suspended, genuine navigations + prefetches usecache:"no-store", popstate is SWR.putResponse) refuses/strips anySet-Cookieand the directive header on write; the CF serve paths (toClientResponse,kvGetResponse) also strip on read, so a pre-fix build or a consumer-pinnedversionnamespace can't replay a storedSet-Cookieto the next client. All strip sites share onePER_CLIENT_SIGNAL_HEADERSsource incookie-name.ts.Stabilization (review rounds)
Three adversarial review passes over the branch caught defects none of the original tests covered; all fixed here (consumer-observable terms):
X-RSC-Reload(version-mismatch) terminal navigates away via a never-settling promise, so itsfinallynever ran — the action fence leaked permanently (prefetch suspended, every fetchno-store) if the user cancelled the reload. It now releases the fence inonReload.:or%silently defeated invalidation. The server monotonic guard read the prior value through a decoding parser, double-decoding the wire value →NaNtimestamp →prevTs 0→ a same-millisecond server mint could equal the client's, leaving the divergence observer silent. Now a raw read.versioncould leak one user'sSet-Cookieto another (the read-side half of Finding Loading states 11 28 #3 above).finalizeActionwraps invalidation intry/finally; removed the deadNavigationStore.broadcastCacheInvalidation.Test plan
pnpm run typecheckpnpm run test:unit— 3630 pass: cookie codec (malformed percent-escape → null), name resolution (injective separator, empty-fallback), storage (keep-vs-mint, mirror-when-unreadable, monotonic guard, legacy cleanup), divergence observer (fires-once / self-rotation-silent / equals-mirror no-op / multi-app isolation / no ping-pong), both API seats (idempotent one-Set-Cookie, inert-outside-request, cache-guarded, monotonic-from-Cookie-header, percent-encoded + empty header/cookie mint), fence refcount + latch exactly-once per terminal incl. simple-redirect and reload (times(0)), credentials-not-omit + no-store-under-fence on nav + prefetch, Finding Loading states 11 28 #3 strips (write + CF read-side: KV replay, malformed-hd evict, L1 serve).pnpm run lint,pnpm run formatkeepClientCache()leaves state unchanged, keep+invalidate still rotates, client-seat invalidate rotates, cookie-cleared-mid-session mints fresh via popstate, Back-after-redirect revalidates the pre-mutation page.Notes / call-outs
clientStateStorageknob — losing the old localStorage state is always safe (a fresh cookie mint just misses cleanly), so a client on the old scheme mints fresh on first load. A cookie-free-by-policy adopter is the named trigger to add a localStorage fallback later (Deferred).useClientCache()removed, not deprecated (pre-release API hygiene). Migrated the one dogfood usage; docs synced (feature-map, feature-file-map, SKILL, design hub).Set-Cookierotation works because the router's navigation/action/prefetch fetches keep a non-omitcredentials mode (defaultsame-origin). Now pinned by unit tests; treat that default as part of the contract.fix/rango-state-cookie-stabilization→ this branch). Of the three previously-deferred "Back must not serve the pre-mutation page" e2e assertions, two are now written (redirect-action Back; cookie-cleared / popstate divergence); the during-flight fetch-fresh assertion remains deferred (covered by unit tests + the 22-test action sweep).buildPayloadMetadata()helper across the five RSC render sites; hoist the e2ereadRangoStateparser into the shared helper.