@rangojs/router 0.0.0-experimental.f3c21dba → 0.0.0-experimental.f681f24a

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 (49) hide show
  1. package/dist/vite/index.js +153 -5
  2. package/package.json +1 -1
  3. package/skills/cache-guide/SKILL.md +32 -0
  4. package/skills/caching/SKILL.md +8 -0
  5. package/skills/loader/SKILL.md +53 -43
  6. package/skills/parallel/SKILL.md +67 -0
  7. package/skills/route/SKILL.md +31 -0
  8. package/skills/router-setup/SKILL.md +52 -2
  9. package/skills/typesafety/SKILL.md +10 -0
  10. package/src/browser/debug-channel.ts +93 -0
  11. package/src/browser/navigation-client.ts +17 -1
  12. package/src/browser/partial-update.ts +11 -0
  13. package/src/browser/prefetch/queue.ts +61 -29
  14. package/src/browser/prefetch/resource-ready.ts +77 -0
  15. package/src/browser/react/NavigationProvider.tsx +5 -3
  16. package/src/browser/server-action-bridge.ts +12 -0
  17. package/src/browser/types.ts +8 -1
  18. package/src/cache/cache-runtime.ts +15 -11
  19. package/src/cache/cache-scope.ts +46 -5
  20. package/src/cache/taint.ts +55 -0
  21. package/src/context-var.ts +72 -2
  22. package/src/deps/browser.ts +1 -0
  23. package/src/route-definition/helpers-types.ts +6 -5
  24. package/src/router/handler-context.ts +31 -8
  25. package/src/router/loader-resolution.ts +7 -1
  26. package/src/router/match-middleware/background-revalidation.ts +12 -1
  27. package/src/router/match-middleware/cache-lookup.ts +12 -5
  28. package/src/router/match-middleware/cache-store.ts +21 -4
  29. package/src/router/match-result.ts +11 -5
  30. package/src/router/middleware-types.ts +6 -2
  31. package/src/router/middleware.ts +2 -2
  32. package/src/router/router-context.ts +1 -0
  33. package/src/router/segment-resolution/fresh.ts +12 -6
  34. package/src/router/segment-resolution/helpers.ts +29 -24
  35. package/src/router/segment-resolution/revalidation.ts +9 -2
  36. package/src/router/types.ts +1 -0
  37. package/src/router.ts +1 -0
  38. package/src/rsc/handler.ts +28 -2
  39. package/src/rsc/loader-fetch.ts +7 -2
  40. package/src/rsc/progressive-enhancement.ts +4 -1
  41. package/src/rsc/rsc-rendering.ts +4 -1
  42. package/src/rsc/server-action.ts +2 -0
  43. package/src/rsc/types.ts +7 -1
  44. package/src/server/context.ts +12 -0
  45. package/src/server/request-context.ts +49 -8
  46. package/src/types/handler-context.ts +20 -8
  47. package/src/types/loader-types.ts +4 -4
  48. package/src/vite/plugins/performance-tracks.ts +231 -0
  49. package/src/vite/rango.ts +4 -0
@@ -9,18 +9,18 @@ import fs from "node:fs";
9
9
 
10
10
  // src/vite/plugins/expose-id-utils.ts
11
11
  import path from "node:path";
12
- import crypto from "node:crypto";
12
+ import crypto2 from "node:crypto";
13
13
  function normalizePath(p) {
14
14
  return p.split(path.sep).join("/");
15
15
  }
16
16
  function hashId(filePath, exportName) {
17
17
  const input = `${filePath}#${exportName}`;
18
- const hash = crypto.createHash("sha256").update(input).digest("hex");
18
+ const hash = crypto2.createHash("sha256").update(input).digest("hex");
19
19
  return `${hash.slice(0, 8)}#${exportName}`;
20
20
  }
21
21
  function hashInlineId(filePath, lineNumber, index) {
22
22
  const input = index !== void 0 && index > 0 ? `${filePath}:${lineNumber}:${index}` : `${filePath}:${lineNumber}`;
23
- return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
23
+ return crypto2.createHash("sha256").update(input).digest("hex").slice(0, 8);
24
24
  }
25
25
  function buildExportMap(program) {
26
26
  const exportMap = /* @__PURE__ */ new Map();
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.f3c21dba",
1748
+ version: "0.0.0-experimental.f681f24a",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
@@ -3274,8 +3274,17 @@ function jsonParseExpression(value) {
3274
3274
  }
3275
3275
 
3276
3276
  // src/context-var.ts
3277
+ var NON_CACHEABLE_KEYS = /* @__PURE__ */ Symbol.for(
3278
+ "rango:non-cacheable-keys"
3279
+ );
3280
+ function getNonCacheableKeys(variables) {
3281
+ if (!variables[NON_CACHEABLE_KEYS]) {
3282
+ variables[NON_CACHEABLE_KEYS] = /* @__PURE__ */ new Set();
3283
+ }
3284
+ return variables[NON_CACHEABLE_KEYS];
3285
+ }
3277
3286
  var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3278
- function contextSet(variables, keyOrVar, value) {
3287
+ function contextSet(variables, keyOrVar, value, options) {
3279
3288
  if (typeof keyOrVar === "string") {
3280
3289
  if (FORBIDDEN_KEYS.has(keyOrVar)) {
3281
3290
  throw new Error(
@@ -3283,8 +3292,14 @@ function contextSet(variables, keyOrVar, value) {
3283
3292
  );
3284
3293
  }
3285
3294
  variables[keyOrVar] = value;
3295
+ if (options?.cache === false) {
3296
+ getNonCacheableKeys(variables).add(keyOrVar);
3297
+ }
3286
3298
  } else {
3287
3299
  variables[keyOrVar.key] = value;
3300
+ if (options?.cache === false) {
3301
+ getNonCacheableKeys(variables).add(keyOrVar.key);
3302
+ }
3288
3303
  }
3289
3304
  }
3290
3305
 
@@ -4846,6 +4861,138 @@ ${details}`
4846
4861
  };
4847
4862
  }
4848
4863
 
4864
+ // src/vite/plugins/performance-tracks.ts
4865
+ var DEBUG_ID_HEADER = "X-RSC-Debug-Id";
4866
+ var DEBUG_S2C_EVENT = "rango:perf-s2c";
4867
+ var DEBUG_C2S_EVENT = "rango:perf-c2s";
4868
+ var GLOBAL_KEY = "__RANGO_DEBUG_CHANNELS__";
4869
+ function getRegistry() {
4870
+ return globalThis[GLOBAL_KEY] ??= {
4871
+ channels: /* @__PURE__ */ new Map(),
4872
+ sessions: /* @__PURE__ */ new Map()
4873
+ };
4874
+ }
4875
+ var bytesToBase64 = (bytes) => Buffer.from(bytes).toString("base64");
4876
+ var base64ToBytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
4877
+ function performanceTracksPlugin() {
4878
+ return {
4879
+ name: "@rangojs/router:performance-tracks",
4880
+ apply: "serve",
4881
+ enforce: "pre",
4882
+ configureServer(server) {
4883
+ const hot = server.environments.client.hot;
4884
+ const registry = getRegistry();
4885
+ const sessions = registry.sessions;
4886
+ const sendChunk = (debugId, chunk) => {
4887
+ hot.send(DEBUG_S2C_EVENT, {
4888
+ i: debugId,
4889
+ b: bytesToBase64(chunk)
4890
+ });
4891
+ };
4892
+ const cleanupIfEnded = (debugId, session) => {
4893
+ if (session.pendingChunks || !session.ended) return;
4894
+ sessions.delete(debugId);
4895
+ hot.send(DEBUG_S2C_EVENT, {
4896
+ i: debugId,
4897
+ d: true
4898
+ });
4899
+ };
4900
+ const registerDebugChannel = (debugId) => {
4901
+ let session = sessions.get(debugId);
4902
+ if (!session) {
4903
+ session = { pendingChunks: [], ended: false };
4904
+ sessions.set(debugId, session);
4905
+ }
4906
+ const readable = new ReadableStream({
4907
+ start(controller) {
4908
+ session.cmdController = controller;
4909
+ },
4910
+ cancel() {
4911
+ delete session.cmdController;
4912
+ }
4913
+ });
4914
+ const writable = new WritableStream({
4915
+ write(chunk) {
4916
+ if (session.pendingChunks) {
4917
+ session.pendingChunks.push(chunk);
4918
+ } else {
4919
+ sendChunk(debugId, chunk);
4920
+ }
4921
+ },
4922
+ close() {
4923
+ session.ended = true;
4924
+ cleanupIfEnded(debugId, session);
4925
+ },
4926
+ abort() {
4927
+ session.ended = true;
4928
+ cleanupIfEnded(debugId, session);
4929
+ }
4930
+ });
4931
+ registry.channels.set(debugId, { readable, writable });
4932
+ };
4933
+ hot.on(DEBUG_C2S_EVENT, (raw) => {
4934
+ const payload = raw;
4935
+ const session = sessions.get(payload.i);
4936
+ if (payload.d) {
4937
+ if (session?.cmdController) {
4938
+ try {
4939
+ session.cmdController.close();
4940
+ } catch {
4941
+ }
4942
+ delete session.cmdController;
4943
+ }
4944
+ return;
4945
+ }
4946
+ if (payload.b) {
4947
+ if (session?.cmdController) {
4948
+ try {
4949
+ session.cmdController.enqueue(base64ToBytes(payload.b));
4950
+ } catch {
4951
+ delete session.cmdController;
4952
+ }
4953
+ }
4954
+ return;
4955
+ }
4956
+ if (session) {
4957
+ if (session.pendingChunks) {
4958
+ for (const chunk of session.pendingChunks) {
4959
+ sendChunk(payload.i, chunk);
4960
+ }
4961
+ delete session.pendingChunks;
4962
+ }
4963
+ cleanupIfEnded(payload.i, session);
4964
+ } else {
4965
+ sessions.set(payload.i, { ended: false });
4966
+ }
4967
+ });
4968
+ server.middlewares.use((req, _res, next) => {
4969
+ const existingId = req.headers[DEBUG_ID_HEADER.toLowerCase()];
4970
+ const isHtml = req.headers.accept?.includes("text/html");
4971
+ if (!existingId && !isHtml) {
4972
+ next();
4973
+ return;
4974
+ }
4975
+ const debugId = existingId || crypto.randomUUID();
4976
+ if (!existingId) {
4977
+ const lowerName = DEBUG_ID_HEADER.toLowerCase();
4978
+ req.headers[lowerName] = debugId;
4979
+ if (req.rawHeaders) {
4980
+ req.rawHeaders.push(DEBUG_ID_HEADER, debugId);
4981
+ }
4982
+ }
4983
+ registerDebugChannel(debugId);
4984
+ console.log(
4985
+ "[perf-tracks] middleware: created channel for",
4986
+ debugId,
4987
+ "from",
4988
+ existingId ? "client header" : "SSR inject"
4989
+ );
4990
+ next();
4991
+ });
4992
+ }
4993
+ };
4994
+ }
4995
+
4849
4996
  // src/vite/rango.ts
4850
4997
  async function rango(options) {
4851
4998
  const resolvedOptions = options ?? { preset: "node" };
@@ -5110,6 +5257,7 @@ ${list}`);
5110
5257
  staticRouteTypesGeneration: resolvedOptions.staticRouteTypesGeneration
5111
5258
  })
5112
5259
  );
5260
+ plugins.push(performanceTracksPlugin());
5113
5261
  return plugins;
5114
5262
  }
5115
5263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.f3c21dba",
3
+ "version": "0.0.0-experimental.f681f24a",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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:
@@ -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
  }
@@ -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
@@ -84,10 +84,10 @@ interface RSCRouterOptions<TEnv> {
84
84
  // Default error boundary
85
85
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
86
 
87
- // Default not-found boundary
87
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
88
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
89
 
90
- // Component for 404 routes
90
+ // Component for 404 (no route match, or notFound() without a boundary)
91
91
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
92
 
93
93
  // Error logging callback
@@ -290,6 +290,56 @@ const router = createRouter({
290
290
  export default router;
291
291
  ```
292
292
 
293
+ ## Not Found Handling
294
+
295
+ Two distinct 404 scenarios:
296
+
297
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
298
+
299
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
300
+
301
+ ```typescript
302
+ import { notFound } from "@rangojs/router";
303
+
304
+ // In a handler or loader
305
+ path("/product/:slug", async (ctx) => {
306
+ const product = await db.getProduct(ctx.params.slug);
307
+ if (!product) notFound("Product not found");
308
+ return <ProductPage product={product} />;
309
+ });
310
+ ```
311
+
312
+ ### Fallback chain for `notFound()`
313
+
314
+ When `notFound()` is thrown, the router looks for a fallback in this order:
315
+
316
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
317
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
318
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
319
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
320
+
321
+ All cases set HTTP 404 status.
322
+
323
+ ### notFoundBoundary
324
+
325
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
326
+
327
+ ```typescript
328
+ urls(({ path, layout }) => [
329
+ layout(ShopLayout, () => [
330
+ notFoundBoundary(({ notFound: info }) => (
331
+ <div>
332
+ <h1>Not Found</h1>
333
+ <p>{info.message}</p>
334
+ </div>
335
+ )),
336
+ path("/product/:slug", ProductPage),
337
+ ]),
338
+ ]);
339
+ ```
340
+
341
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
342
+
293
343
  ## Including Sub-patterns
294
344
 
295
345
  ```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