@rangojs/router 0.0.0-experimental.56cb65a7 → 0.0.0-experimental.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/rango.js +128 -46
  2. package/dist/vite/index.js +211 -47
  3. package/package.json +2 -2
  4. package/skills/cache-guide/SKILL.md +32 -0
  5. package/skills/caching/SKILL.md +8 -0
  6. package/skills/links/SKILL.md +3 -1
  7. package/skills/loader/SKILL.md +53 -43
  8. package/skills/middleware/SKILL.md +2 -0
  9. package/skills/parallel/SKILL.md +67 -0
  10. package/skills/route/SKILL.md +31 -0
  11. package/skills/router-setup/SKILL.md +87 -2
  12. package/skills/typesafety/SKILL.md +10 -0
  13. package/src/browser/app-version.ts +14 -0
  14. package/src/browser/navigation-bridge.ts +16 -3
  15. package/src/browser/navigation-client.ts +64 -40
  16. package/src/browser/navigation-store.ts +43 -8
  17. package/src/browser/partial-update.ts +37 -4
  18. package/src/browser/prefetch/fetch.ts +8 -2
  19. package/src/browser/prefetch/queue.ts +61 -29
  20. package/src/browser/prefetch/resource-ready.ts +77 -0
  21. package/src/browser/react/Link.tsx +44 -8
  22. package/src/browser/react/NavigationProvider.tsx +13 -4
  23. package/src/browser/react/context.ts +7 -2
  24. package/src/browser/react/use-router.ts +21 -8
  25. package/src/browser/rsc-router.tsx +26 -3
  26. package/src/browser/server-action-bridge.ts +8 -6
  27. package/src/browser/types.ts +27 -5
  28. package/src/build/generate-manifest.ts +3 -0
  29. package/src/build/generate-route-types.ts +3 -0
  30. package/src/build/route-types/include-resolution.ts +8 -1
  31. package/src/build/route-types/router-processing.ts +211 -72
  32. package/src/cache/cache-runtime.ts +15 -11
  33. package/src/cache/cache-scope.ts +46 -5
  34. package/src/cache/taint.ts +55 -0
  35. package/src/context-var.ts +72 -2
  36. package/src/route-definition/helpers-types.ts +6 -5
  37. package/src/route-definition/redirect.ts +9 -1
  38. package/src/router/handler-context.ts +36 -17
  39. package/src/router/intercept-resolution.ts +9 -4
  40. package/src/router/loader-resolution.ts +9 -2
  41. package/src/router/match-middleware/background-revalidation.ts +12 -1
  42. package/src/router/match-middleware/cache-lookup.ts +50 -7
  43. package/src/router/match-middleware/cache-store.ts +21 -4
  44. package/src/router/match-result.ts +11 -5
  45. package/src/router/middleware-types.ts +6 -8
  46. package/src/router/middleware.ts +2 -5
  47. package/src/router/prerender-match.ts +2 -2
  48. package/src/router/router-context.ts +1 -0
  49. package/src/router/router-interfaces.ts +25 -4
  50. package/src/router/router-options.ts +37 -11
  51. package/src/router/segment-resolution/fresh.ts +47 -16
  52. package/src/router/segment-resolution/helpers.ts +29 -24
  53. package/src/router/segment-resolution/revalidation.ts +50 -21
  54. package/src/router/types.ts +1 -0
  55. package/src/router.ts +41 -4
  56. package/src/rsc/handler.ts +11 -2
  57. package/src/rsc/manifest-init.ts +5 -1
  58. package/src/rsc/progressive-enhancement.ts +4 -0
  59. package/src/rsc/rsc-rendering.ts +5 -0
  60. package/src/rsc/server-action.ts +2 -0
  61. package/src/rsc/ssr-setup.ts +1 -1
  62. package/src/rsc/types.ts +8 -1
  63. package/src/server/context.ts +36 -0
  64. package/src/server/loader-registry.ts +9 -8
  65. package/src/server/request-context.ts +50 -12
  66. package/src/ssr/index.tsx +3 -0
  67. package/src/types/cache-types.ts +4 -4
  68. package/src/types/handler-context.ts +125 -31
  69. package/src/types/loader-types.ts +4 -5
  70. package/src/urls/pattern-types.ts +12 -0
  71. package/src/vite/discovery/discover-routers.ts +5 -1
  72. package/src/vite/plugins/performance-tracks.ts +88 -0
  73. package/src/vite/rango.ts +17 -1
  74. package/src/vite/utils/shared-utils.ts +3 -2
@@ -162,6 +162,38 @@ middleware(async (ctx, next) => {
162
162
  });
163
163
  ```
164
164
 
165
+ ## Context Variable Cache Safety
166
+
167
+ Context variables created with `createVar()` are cacheable by default and can
168
+ be read freely inside `cache()` and `"use cache"` scopes. Non-cacheable vars
169
+ throw at read time to prevent request-specific data from being captured.
170
+
171
+ There are two ways to mark a value as non-cacheable:
172
+
173
+ ```typescript
174
+ // Var-level policy — inherently request-specific data
175
+ const Session = createVar<SessionData>({ cache: false });
176
+
177
+ // Write-level escalation — this specific write is non-cacheable
178
+ ctx.set(Theme, derivedTheme, { cache: false });
179
+ ```
180
+
181
+ "Least cacheable wins": if either the var definition or the `ctx.set()` call
182
+ specifies `cache: false`, the value is non-cacheable.
183
+
184
+ **Behavior inside cache scopes:**
185
+
186
+ | Operation | Inside `cache()` / `"use cache"` |
187
+ | ----------------------------------- | -------------------------------- |
188
+ | `ctx.get(cacheableVar)` | Allowed |
189
+ | `ctx.get(nonCacheableVar)` | Throws |
190
+ | `ctx.set(var, value)` (cacheable) | Allowed |
191
+ | `ctx.header()`, `ctx.cookie()`, etc | Throws (response side effects) |
192
+
193
+ Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
194
+ Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
195
+ scope and rejects non-cacheable reads.
196
+
165
197
  ## Loaders Are Always Fresh
166
198
 
167
199
  Loaders are **never cached** by route-level `cache()`. Even on a full cache hit
@@ -173,6 +173,14 @@ const router = createRouter<AppBindings>({
173
173
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
174
174
  are only cached in L1.
175
175
 
176
+ ## Context Variables Inside Cache Boundaries
177
+
178
+ Context variables (`createVar`) are cacheable by default and can be read and
179
+ written inside `cache()` scopes. Variables marked with `{ cache: false }` (at
180
+ the var level or write level) throw when read inside a cache scope. Response
181
+ side effects (`ctx.header()`, `ctx.cookie()`) always throw inside cache
182
+ boundaries. See `/cache-guide` for the full cache safety table.
183
+
176
184
  ## Nested Cache Boundaries
177
185
 
178
186
  Override cache settings for specific sections:
@@ -139,7 +139,9 @@ function GlobalNav() {
139
139
  }
140
140
  ```
141
141
 
142
- `href()` is an identity function at runtime but provides compile-time validation via `ValidPaths` type. Paths are validated against registered route patterns using `PatternToPath`.
142
+ `href()` provides compile-time validation via `ValidPaths` type. Paths are validated against registered route patterns using `PatternToPath`.
143
+
144
+ `href()` is a raw path helper — it is **not** basename-aware. It returns the path as-is (or with the include mount prefix via `useHref()`). For basename-aware navigation, use `Link`, `useRouter().push()`, or `reverse()`, which auto-prefix root-relative paths with the router's basename.
143
145
 
144
146
  ## Client: useHref()
145
147
 
@@ -65,24 +65,10 @@ export const urlpatterns = urls(({ path, loader }) => [
65
65
 
66
66
  ## Consuming Loader Data
67
67
 
68
- Loaders are the **live data layer** they resolve fresh on every request.
69
- The way you consume them depends on whether you're in a server component
70
- (route handler) or a client component.
71
-
72
- > **IMPORTANT: Prefer consuming loaders in client components.** Keeping data
73
- > fetching in loaders and consumption in client components creates a clean
74
- > separation: the server-side handler renders static markup that can be
75
- > freely cached with `cache()`, while loader data stays fresh on every
76
- > request. When you consume loaders in server handlers via `ctx.use()`, the
77
- > handler output depends on the loader data, which means caching the handler
78
- > also caches the data — defeating the purpose of the live data layer.
79
-
80
- ### In Client Components (Preferred)
81
-
82
- Client components use `useLoader()` from `@rangojs/router/client`.
83
- The loader **must** be registered with `loader()` in the route's DSL
84
- segments so the framework knows to resolve it during SSR and stream
85
- the data to the client:
68
+ Register loaders with `loader()` in the DSL and consume them in client
69
+ components with `useLoader()`. This is the recommended pattern it keeps
70
+ data fetching on the server and consumption on the client, with a clean
71
+ separation that works correctly with `cache()`.
86
72
 
87
73
  ```typescript
88
74
  "use client";
@@ -96,40 +82,60 @@ function ProductDetails() {
96
82
  ```
97
83
 
98
84
  ```typescript
99
- // Route definition — loader() registration required for client consumption
85
+ // Route definition — loader() registration required
100
86
  path("/product/:slug", ProductPage, { name: "product" }, () => [
101
- loader(ProductLoader), // Required for useLoader() in client components
87
+ loader(ProductLoader),
102
88
  ]);
103
89
  ```
104
90
 
105
- ### In Route Handlers (Server Components)
91
+ DSL loaders are the **live data layer** — they resolve fresh on every
92
+ request, even when the route is inside a `cache()` boundary. The router
93
+ excludes them from the segment cache at storage time and re-resolves them
94
+ on retrieval. This means `cache()` gives you cached UI + fresh data by
95
+ default.
106
96
 
107
- In server components, use `ctx.use(Loader)` directly in the route handler.
108
- This doesn't require `loader()` registration in the DSL — it works
109
- standalone. **However**, prefer client-side consumption when possible (see
110
- note above).
97
+ ### Cache safety
111
98
 
112
- ```typescript
113
- import { ProductLoader } from "./loaders/product";
99
+ DSL loaders can safely read `createVar({ cache: false })` variables
100
+ because they are always resolved fresh. The read guard is bypassed for
101
+ loader functions — they never produce stale data.
102
+
103
+ ### ctx.use(Loader) — escape hatch
104
+
105
+ For cases where you need loader data in the server handler itself (e.g.,
106
+ to set ctx variables or make routing decisions), use `ctx.use(Loader)`:
114
107
 
115
- // Route handler — server component
108
+ ```typescript
116
109
  path("/product/:slug", async (ctx) => {
117
110
  const { product } = await ctx.use(ProductLoader);
118
- return <h1>{product.name}</h1>;
119
- }, { name: "product" })
111
+ ctx.set(Product, product); // make available to children
112
+ return <ProductPage />;
113
+ }, { name: "product" }, () => [
114
+ loader(ProductLoader), // still register for client consumption
115
+ ])
120
116
  ```
121
117
 
122
- When you do register with `loader()` in the DSL, `ctx.use()` returns the
118
+ When you register with `loader()` in the DSL, `ctx.use()` returns the
123
119
  same memoized result — loaders never run twice per request.
124
120
 
121
+ **Limitations of ctx.use(Loader):**
122
+
123
+ - The handler output depends on the loader data. If the route is inside
124
+ `cache()`, the handler is cached with the loader result baked in —
125
+ defeating the live data guarantee.
126
+ - Non-cacheable variable reads (`createVar({ cache: false })`) inside the
127
+ handler still throw, even if the data came from a loader.
128
+ - Prefer DSL `loader()` + client `useLoader()` for data that depends on
129
+ non-cacheable context variables.
130
+
125
131
  **Never use `useLoader()` in server components** — it is a client-only API.
126
132
 
127
133
  ### Summary
128
134
 
129
- | Context | API | `loader()` DSL required? |
130
- | ---------------------------- | ------------------- | ------------------------ |
131
- | Client component (preferred) | `useLoader(Loader)` | **Yes** |
132
- | Route handler (server) | `ctx.use(Loader)` | No |
135
+ | Pattern | API | Cache-safe | Recommended |
136
+ | ---------------------- | ------------------- | ---------- | ----------- |
137
+ | DSL + client component | `useLoader(Loader)` | Yes | Yes |
138
+ | Handler escape hatch | `ctx.use(Loader)` | No | When needed |
133
139
 
134
140
  ## Loader Context
135
141
 
@@ -548,7 +554,7 @@ export const ProductLoader = createLoader(async (ctx) => {
548
554
  .first();
549
555
 
550
556
  if (!product) {
551
- throw new Response("Product not found", { status: 404 });
557
+ notFound("Product not found");
552
558
  }
553
559
 
554
560
  return { product };
@@ -564,10 +570,9 @@ export const CartLoader = createLoader(async (ctx) => {
564
570
  return { cart };
565
571
  });
566
572
 
567
- // urls.tsx
573
+ // urls.tsx — register loaders in the DSL
568
574
  export const urlpatterns = urls(({ path, layout, loader, loading, cache, revalidate }) => [
569
575
  layout(<ShopLayout />, () => [
570
- // Shared cart loader for all shop routes
571
576
  loader(CartLoader, () => [
572
577
  revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
573
578
  ]),
@@ -579,17 +584,22 @@ export const urlpatterns = urls(({ path, layout, loader, loading, cache, revalid
579
584
  ]),
580
585
  ]);
581
586
 
582
- // pages/product.tsx — server component (route handler)
587
+ // components/ProductDetails.tsx — consume in client component
588
+ "use client";
589
+ import { useLoader } from "@rangojs/router/client";
583
590
  import { ProductLoader, CartLoader } from "./loaders/shop";
584
591
 
585
- async function ProductPage(ctx) {
586
- const { product } = await ctx.use(ProductLoader);
587
- const { cart } = await ctx.use(CartLoader);
592
+ function ProductDetails() {
593
+ const { data: { product } } = useLoader(ProductLoader);
594
+ const { data: { cart } } = useLoader(CartLoader);
588
595
 
589
596
  return (
590
597
  <div>
591
598
  <h1>{product.name}</h1>
592
- <AddToCartButton productId={product.id} inCart={cart?.items.includes(product.id)} />
599
+ <AddToCartButton
600
+ productId={product.id}
601
+ inCart={cart?.items.includes(product.id)}
602
+ />
593
603
  </div>
594
604
  );
595
605
  }
@@ -26,6 +26,8 @@ const router = createRouter<AppEnv>({})
26
26
  .routes(urlpatterns);
27
27
  ```
28
28
 
29
+ When the router has a `basename`, pattern-scoped `.use()` patterns are automatically prefixed. For example, with `basename: "/app"`, `.use("/admin/*", mw)` matches `/app/admin/*`.
30
+
29
31
  ### Route middleware (`middleware()` in `urls()`)
30
32
 
31
33
  Registered inside `urls()` callback. Wraps **rendering only** -- it does NOT wrap server action execution. Actions run before route middleware, so when route middleware executes during post-action revalidation, it can observe state that the action set (cookies, context variables, headers).
@@ -92,6 +92,73 @@ path("/dashboard/:id", (ctx) => {
92
92
  ])
93
93
  ```
94
94
 
95
+ ## Setting Handles (Meta, Breadcrumbs)
96
+
97
+ Parallel slot handlers can call `ctx.use(Meta)` or `ctx.use(Breadcrumbs)` to
98
+ push handle data. The data is associated with the **parent** layout or route
99
+ segment, not the parallel segment itself. This is because parallels execute
100
+ after their parent handler and inherit its segment scope.
101
+
102
+ This works well for document-level metadata — the handle data follows the
103
+ parent's lifecycle (appears when the parent is mounted, removed when it
104
+ unmounts).
105
+
106
+ ```typescript
107
+ parallel({
108
+ "@meta": (ctx) => {
109
+ const meta = ctx.use(Meta);
110
+ meta({ title: "Product Detail" });
111
+ meta({ name: "description", content: "..." });
112
+ return null; // UI-less slot, only sets metadata
113
+ },
114
+ "@sidebar": (ctx) => <Sidebar />,
115
+ })
116
+ ```
117
+
118
+ Multiple parallels on the same parent can each push handle data — they all
119
+ accumulate under the parent segment ID.
120
+
121
+ ### Pattern: `@meta` slot for per-route metadata overrides
122
+
123
+ A dedicated `@meta` parallel slot lets routes define metadata separately from
124
+ their handler logic. The layout sets defaults via a title template, and each
125
+ route overrides via its own `@meta` slot. Since child segments push after
126
+ parents and `collectMeta` uses last-wins deduplication, overrides work
127
+ naturally.
128
+
129
+ ```typescript
130
+ // Layout sets defaults
131
+ layout((ctx) => {
132
+ ctx.use(Meta)({ title: { template: "%s | Store", default: "Store" } });
133
+ return <StoreLayout />;
134
+ }, () => [
135
+ // Route with @meta override — decoupled from handler rendering
136
+ path("/:slug", ProductPage, { name: "product" }, () => [
137
+ parallel({
138
+ "@meta": async (ctx) => {
139
+ const product = await ctx.use(ProductLoader);
140
+ const meta = ctx.use(Meta);
141
+ meta({ title: product.name });
142
+ meta({ name: "description", content: product.description });
143
+ meta({
144
+ "script:ld+json": {
145
+ "@context": "https://schema.org",
146
+ "@type": "Product",
147
+ name: product.name,
148
+ description: product.description,
149
+ },
150
+ });
151
+ return null; // UI-less slot
152
+ },
153
+ }),
154
+ ]),
155
+ ])
156
+ ```
157
+
158
+ This keeps the route handler focused on rendering UI while metadata
159
+ (title, description, Open Graph, JSON-LD) lives in a composable slot that
160
+ can be added, removed, or swapped per route without touching the handler.
161
+
95
162
  ## Parallel Routes with Loaders
96
163
 
97
164
  Add loaders and loading states to parallel routes:
@@ -181,6 +181,37 @@ String keys still work (`ctx.set("key", value)` / `ctx.get("key")`), but
181
181
  Only route handlers and middleware can call `ctx.set()`. Layouts, parallels,
182
182
  and intercepts can only read via `ctx.get()`.
183
183
 
184
+ #### Non-cacheable context variables
185
+
186
+ Mark a var as non-cacheable when it holds inherently request-specific data
187
+ (sessions, auth tokens, per-request IDs). There are two ways:
188
+
189
+ ```typescript
190
+ // Var-level: every value written to this var is non-cacheable
191
+ const Session = createVar<SessionData>({ cache: false });
192
+
193
+ // Write-level: escalate a normally-cacheable var for this specific write
194
+ const Theme = createVar<string>();
195
+ ctx.set(Theme, userTheme, { cache: false });
196
+ ```
197
+
198
+ "Least cacheable wins" — if either the var definition or the write site says
199
+ `cache: false`, the value is non-cacheable.
200
+
201
+ Reading a non-cacheable var inside `cache()` or `"use cache"` throws at
202
+ runtime. This prevents request-specific data from leaking into cached output:
203
+
204
+ ```typescript
205
+ // This throws — Session is non-cacheable
206
+ async function CachedWidget(ctx) {
207
+ "use cache";
208
+ const session = ctx.get(Session); // Error: non-cacheable var read inside cache scope
209
+ return <Widget />;
210
+ }
211
+ ```
212
+
213
+ Cacheable vars (the default) can be read freely inside cache scopes.
214
+
184
215
  ### Revalidation Contracts for Handler Data
185
216
 
186
217
  Handler-first guarantees apply within a single full render pass. For partial
@@ -78,16 +78,21 @@ interface RSCRouterOptions<TEnv> {
78
78
  // Document component wrapping entire app
79
79
  document?: ComponentType<{ children: ReactNode }>;
80
80
 
81
+ // URL prefix for sub-path deployments (e.g. "/admin")
82
+ // All routes, reverse(), href(), Link, redirect(), and router.use()
83
+ // patterns are automatically prefixed. Route names stay unprefixed.
84
+ basename?: string;
85
+
81
86
  // Enable per-request performance timeline (console waterfall + Server-Timing header)
82
87
  debugPerformance?: boolean;
83
88
 
84
89
  // Default error boundary
85
90
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
91
 
87
- // Default not-found boundary
92
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
93
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
94
 
90
- // Component for 404 routes
95
+ // Component for 404 (no route match, or notFound() without a boundary)
91
96
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
97
 
93
98
  // Error logging callback
@@ -124,6 +129,36 @@ interface RSCRouterOptions<TEnv> {
124
129
  }
125
130
  ```
126
131
 
132
+ ## Basename (Sub-Path Deployment)
133
+
134
+ When your app is served under a sub-path (e.g. `/admin` or `/v2`), set `basename`:
135
+
136
+ ```typescript
137
+ const router = createRouter({
138
+ basename: "/admin",
139
+ document: Document,
140
+ }).routes(({ path, include }) => [
141
+ path("/", Dashboard, { name: "home" }), // matches /admin
142
+ path("/users", Users, { name: "users" }), // matches /admin/users
143
+ include("/api", apiPatterns, { name: "api" }), // matches /admin/api/*
144
+ ]);
145
+
146
+ router.reverse("home"); // "/admin"
147
+ router.reverse("users"); // "/admin/users"
148
+ ```
149
+
150
+ Router-owned APIs are basename-aware:
151
+
152
+ - `reverse()` returns prefixed paths
153
+ - `<Link to="/users">` renders `<a href="/admin/users">`
154
+ - `redirect("/login")` redirects to `"/admin/login"`
155
+ - `router.use("/users/*", mw)` matches `/admin/users/*`
156
+ - `useRouter().push("/users")` navigates to `/admin/users`
157
+ - Route names stay unprefixed (`"home"`, not `"admin.home"`)
158
+
159
+ Note: `href()` is a raw path helper and does **not** auto-prefix with basename.
160
+ Use `reverse()` or `<Link>` for basename-aware URLs.
161
+
127
162
  ## Using the Request Handler
128
163
 
129
164
  The router provides a `fetch` method to handle RSC requests:
@@ -290,6 +325,56 @@ const router = createRouter({
290
325
  export default router;
291
326
  ```
292
327
 
328
+ ## Not Found Handling
329
+
330
+ Two distinct 404 scenarios:
331
+
332
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
333
+
334
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
335
+
336
+ ```typescript
337
+ import { notFound } from "@rangojs/router";
338
+
339
+ // In a handler or loader
340
+ path("/product/:slug", async (ctx) => {
341
+ const product = await db.getProduct(ctx.params.slug);
342
+ if (!product) notFound("Product not found");
343
+ return <ProductPage product={product} />;
344
+ });
345
+ ```
346
+
347
+ ### Fallback chain for `notFound()`
348
+
349
+ When `notFound()` is thrown, the router looks for a fallback in this order:
350
+
351
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
352
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
353
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
354
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
355
+
356
+ All cases set HTTP 404 status.
357
+
358
+ ### notFoundBoundary
359
+
360
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
361
+
362
+ ```typescript
363
+ urls(({ path, layout }) => [
364
+ layout(ShopLayout, () => [
365
+ notFoundBoundary(({ notFound: info }) => (
366
+ <div>
367
+ <h1>Not Found</h1>
368
+ <p>{info.message}</p>
369
+ </div>
370
+ )),
371
+ path("/product/:slug", ProductPage),
372
+ ]),
373
+ ]);
374
+ ```
375
+
376
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
377
+
293
378
  ## Including Sub-patterns
294
379
 
295
380
  ```typescript
@@ -369,8 +369,18 @@ interface PaginationData {
369
369
  perPage: number;
370
370
  }
371
371
  export const Pagination = createVar<PaginationData>();
372
+
373
+ // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
374
+ const Session = createVar<SessionData>({ cache: false });
372
375
  ```
373
376
 
377
+ `createVar` accepts an optional options object. The `cache` option (default
378
+ `true`) controls whether the var's values can be read inside cache scopes.
379
+ Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
380
+ marks a specific write as non-cacheable even if the var itself is cacheable.
381
+ "Least cacheable wins" — if either says `cache: false`, the value throws on
382
+ read inside `cache()` or `"use cache"`.
383
+
374
384
  ### Producer (handler or middleware)
375
385
 
376
386
  ```typescript
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mutable app version — updated after HMR revalidation.
3
+ * Read by prefetch, navigation, and context code.
4
+ */
5
+
6
+ let currentVersion: string | undefined;
7
+
8
+ export function getAppVersion(): string | undefined {
9
+ return currentVersion;
10
+ }
11
+
12
+ export function setAppVersion(version: string | undefined): void {
13
+ currentVersion = version;
14
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
7
8
  import * as React from "react";
8
9
  import { startTransition } from "react";
9
10
  import {
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
68
  export function createNavigationBridge(
68
69
  config: NavigationBridgeConfigWithController,
69
70
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
72
73
 
73
74
  // Create shared partial updater
74
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +77,7 @@ export function createNavigationBridge(
76
77
  client,
77
78
  onUpdate,
78
79
  renderSegments,
79
- version,
80
+ getVersion: () => version,
80
81
  });
81
82
 
82
83
  return {
@@ -447,6 +448,12 @@ export function createNavigationBridge(
447
448
  store.setCurrentUrl(url);
448
449
  store.setPath(new URL(url).pathname);
449
450
 
451
+ // Restore router identity from cache so subsequent navigations
452
+ // don't falsely detect an app switch.
453
+ if (cached?.routerId) {
454
+ store.setRouterId?.(cached.routerId);
455
+ }
456
+
450
457
  // Render from cache - force await to skip loading fallbacks
451
458
  try {
452
459
  const root = await renderSegments(cachedSegments, {
@@ -632,6 +639,12 @@ export function createNavigationBridge(
632
639
  window.removeEventListener("pageshow", handlePageShow);
633
640
  };
634
641
  },
642
+
643
+ updateVersion(newVersion: string): void {
644
+ version = newVersion;
645
+ setAppVersion(newVersion);
646
+ store.clearHistoryCache();
647
+ },
635
648
  };
636
649
  }
637
650