Skip to content

feat(router): rango state session cookie + invalidateClientCache/keepClientCache - #560

Merged
ivogt merged 15 commits into
mainfrom
feat/rango-state-cookie
Jun 12, 2026
Merged

ivogt merged 15 commits into
mainfrom
feat/rango-state-cookie

Conversation

@ivogt

@ivogt ivogt commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator

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 with Vary):

  1. The cache key outlived the session. Rango state lived in 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 to prefetchCacheTTL after the session was gone.
  2. Non-action mutations had no invalidation entry point. A 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 was useClientCache().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() and keepClientCache(). useClientCache() is removed.

Before → after

Before — a non-component mutation could not invalidate (the hook is component-only):

const { clear } = useClientCache();              // React hook
socket.on("catalog-updated", () => clear());     // can't: not inside a component

After — one import, works from any seat:

import { invalidateClientCache } from "@rangojs/router";

// Client: a mutation the router can't see
socket.on("catalog-updated", () => invalidateClientCache());
// Server: a non-action handler that mutates
import { invalidateClientCache } from "@rangojs/router";

export const handler = async (ctx) => {
  await db.orders.create(/* ... */);
  invalidateClientCache();   // writes a rotated Set-Cookie for the responding client
  return { ok: true };
};

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":

import { keepClientCache } from "@rangojs/router";

export async function saveDraft(formData: FormData) {
  "use server";
  const changed = await drafts.persist(formData);
  if (!changed) keepClientCache();   // no rotation, no prefetch wipe, no broadcast, no revalidation refetch
  return { saved: true };
}

Semantics

Call site invalidateClientCache() keepClientCache()
Client component / browser module marks history cache stale (SWR) + flushes prefetch + rotates state + broadcasts to sibling tabs no-op (dev warning) — it is a server directive
Handler / middleware / server component writes one rotated Set-Cookie; client rotates on receipt, history marked stale by the divergence observer at its next read suppresses this action's automatic invalidation (internal x-rango-keep-cache response header the bridge reads)
SSR render pass no-op (dev warning) no-op (dev warning)
Outside a request (server) inert no-op (dev warning) inert no-op (dev warning)
Called twice in one request (server) one Set-Cookie one directive
Action calls both invalidation wins — the explicit Set-Cookie lands regardless —

Pull-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:

  • Storage — browser/rango-state.ts rewritten to a session cookie {prefix}_{routerId} (default prefix rango-state; new stateCookiePrefix router option, injective _-separated name). The per-request document.cookie read is the cross-tab value sync (the storage event 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 shared getRawCookieValue (their agreement is the monotonic guard's contract); an empty value counts as absent.
  • Observer — new browser/navigation-store-handle.ts wires a jar-divergence observer that marks the history cache stale (history-only, no re-rotation) when a sibling tab / server Set-Cookie / cookie-clear rotates the value. It fires at a tab's next jar read — a navigation/prefetch fetch or a popstate restore.
  • API — invalidateClientCache() selected by export conditions (client marks-stale; server writes a rotated Set-Cookie via a new ctx._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.
  • Bridge — the eager clear-at-action-start became a refcounted fence whose invalidation is deferred to the response via a single latched finalization that runs for every terminal — normal / redirect / version-mismatch reload / error / abort / intercept / concurrent — and releases the fence exactly once. The three never-settling navigate-away terminals (simple-redirect, router-id mismatch, version-mismatch reload) call it explicitly before their return, since their finally never runs; the reload terminal releases without invalidating (nothing executed server-side). During flight: prefetch consumption suspended, genuine navigations + prefetches use cache:"no-store", popstate is SWR.
  • Finding Loading states 11 28 #3 (per-client signals never cross clients) — every shared response store (document-cache chokepoint, CF L1/KV envelope, Memory putResponse) refuses/strips any Set-Cookie and the directive header on write; the CF serve paths (toClientResponse, kvGetResponse) also strip on read, so a pre-fix build or a consumer-pinned version namespace can't replay a stored Set-Cookie to the next client. All strip sites share one PER_CLIENT_SIGNAL_HEADERS source in cookie-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):

  • A deploy mid-action could brick a tab. The X-RSC-Reload (version-mismatch) terminal navigates away via a never-settling promise, so its finally never ran — the action fence leaked permanently (prefetch suspended, every fetch no-store) if the user cancelled the reload. It now releases the fence in onReload.
  • A build version containing : or % silently defeated invalidation. The server monotonic guard read the prior value through a decoding parser, double-decoding the wire value → NaN timestamp → prevTs 0 → a same-millisecond server mint could equal the client's, leaving the divergence observer silent. Now a raw read.
  • A pinned version could leak one user's Set-Cookie to another (the read-side half of Finding Loading states 11 28 #3 above).
  • Hardening: finalizeAction wraps invalidation in try/finally; removed the dead NavigationStore.broadcastCacheInvalidation.

Test plan

  • pnpm run typecheck
  • pnpm 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 format
  • Semantic matrix (action-execution parity gate) — 39 green, dev + production, before and after the bridge restructure and the stabilization fixes.
  • e2e dev + production: rango-state lifecycle (cutover, deploy-bust survives refresh), keepClientCache() 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.
  • Broad action e2e sweep (pending / parallel-reval / streaming / params / ctx-set — 22) + action-redirect-revalidation: confirms the fence restructure across intercept / concurrent / redirect / navigated-away.

Notes / call-outs

  • Hard cutover, no clientStateStorage knob — 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).
  • Credentials invariant — server Set-Cookie rotation works because the router's navigation/action/prefetch fetches keep a non-omit credentials mode (default same-origin). Now pinned by unit tests; treat that default as part of the contract.
  • Stabilization landed via stacked PR fix(router): stabilize rango-state cookie — review findings + test gaps #566 (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).
  • Deferred follow-ups (pure cleanup, no behavior change) — extract a buildPayloadMetadata() helper across the five RSC render sites; hoist the e2e readRangoState parser into the shared helper.

ivogt added 11 commits June 12, 2026 10:27
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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d6cb3e0

ivogt added 3 commits June 12, 2026 17:14
…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).
fix(router): stabilize rango-state cookie — review findings + test gaps
@ivogt
ivogt merged commit cd5a13f into main Jun 12, 2026
25 checks passed
@ivogt
ivogt deleted the feat/rango-state-cookie branch June 12, 2026 20:42
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.
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