@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114

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 (48) hide show
  1. package/dist/bin/rango.js +74 -3
  2. package/dist/vite/index.js +133 -18
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +35 -24
  5. package/skills/caching/SKILL.md +115 -7
  6. package/skills/document-cache/SKILL.md +78 -55
  7. package/skills/hooks/SKILL.md +40 -22
  8. package/skills/links/SKILL.md +10 -10
  9. package/skills/loader/SKILL.md +3 -3
  10. package/skills/rango/SKILL.md +16 -10
  11. package/skills/react-compiler/SKILL.md +168 -0
  12. package/skills/use-cache/SKILL.md +34 -5
  13. package/skills/view-transitions/SKILL.md +85 -3
  14. package/src/browser/react/location-state-shared.ts +93 -3
  15. package/src/browser/react/use-reverse.ts +19 -12
  16. package/src/build/route-types/per-module-writer.ts +4 -1
  17. package/src/build/route-types/router-processing.ts +14 -1
  18. package/src/build/route-types/source-scan.ts +118 -0
  19. package/src/cache/cache-scope.ts +28 -42
  20. package/src/cache/cf/cf-cache-store.ts +49 -6
  21. package/src/handle.ts +3 -5
  22. package/src/loader-store.ts +62 -25
  23. package/src/loader.rsc.ts +2 -5
  24. package/src/loader.ts +3 -10
  25. package/src/missing-id-error.ts +68 -0
  26. package/src/reverse.ts +16 -13
  27. package/src/route-definition/dsl-helpers.ts +5 -2
  28. package/src/route-definition/helpers-types.ts +31 -10
  29. package/src/router/loader-resolution.ts +16 -2
  30. package/src/router/match-middleware/cache-lookup.ts +44 -91
  31. package/src/router/match-middleware/cache-store.ts +3 -2
  32. package/src/router/router-options.ts +24 -0
  33. package/src/router/segment-resolution/fresh.ts +17 -4
  34. package/src/router/segment-resolution/revalidation.ts +17 -4
  35. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  36. package/src/router/types.ts +8 -0
  37. package/src/router.ts +2 -0
  38. package/src/segment-system.tsx +59 -10
  39. package/src/server/context.ts +26 -0
  40. package/src/server/cookie-store.ts +28 -4
  41. package/src/types/handler-context.ts +5 -2
  42. package/src/types/segments.ts +18 -1
  43. package/src/urls/path-helper-types.ts +9 -1
  44. package/src/use-loader.tsx +89 -42
  45. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  46. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  47. package/src/vite/plugins/use-cache-transform.ts +12 -10
  48. package/src/vite/router-discovery.ts +14 -2
@@ -10,73 +10,82 @@ Caches complete HTTP responses (HTML/RSC) at the edge based on Cache-Control hea
10
10
 
11
11
  ## Setup
12
12
 
13
- Configure document cache in router:
13
+ Document caching is a middleware. Add `createDocumentCacheMiddleware()` to the
14
+ router with `.use()`. The cache store it reads from is the app-level store you
15
+ configure on `createRouter({ cache })` (available on the request context as
16
+ `requestCtx._cacheStore`), not a store passed to the middleware.
14
17
 
15
18
  ```typescript
16
19
  import { createRouter } from "@rangojs/router";
17
- import { CFCacheStore } from "@rangojs/router/cache";
20
+ import {
21
+ createDocumentCacheMiddleware,
22
+ CFCacheStore,
23
+ } from "@rangojs/router/cache";
18
24
  import { urlpatterns } from "./urls";
19
25
 
20
26
  const router = createRouter<AppBindings>({
21
27
  document: Document,
22
28
  urls: urlpatterns,
23
- documentCache: (_env, ctx) => ({
24
- store: new CFCacheStore({ ctx: ctx! }),
29
+ // App-level cache store. The document cache middleware uses this store's
30
+ // getResponse/putResponse methods.
31
+ cache: (_env, ctx) => new CFCacheStore({ ctx: ctx! }),
32
+ });
33
+
34
+ router.use(
35
+ createDocumentCacheMiddleware({
25
36
  skipPaths: ["/api", "/admin"],
26
37
  debug: process.env.NODE_ENV === "development",
27
38
  }),
28
- });
39
+ );
29
40
 
30
41
  export default router;
31
42
  ```
32
43
 
33
- ## Route Opt-In with cache()
44
+ ## Route Opt-In with Cache-Control
34
45
 
35
- Routes opt-in to document caching using the `cache()` DSL with `documentCache` option:
46
+ Routes opt-in to document caching by setting a `Cache-Control` response header
47
+ with `s-maxage`. The middleware caches responses whose `Cache-Control` includes
48
+ `s-maxage`; `stale-while-revalidate` enables background revalidation (SWR).
36
49
 
37
50
  ```typescript
38
- import { urls } from "@rangojs/router";
39
-
40
- export const urlpatterns = urls(({ path, cache }) => [
41
- // Cache full page for 5 min, serve stale for 1 hour
42
- cache({ documentCache: { sMaxAge: 300, swr: 3600 } }, () => [
43
- path("/blog", BlogIndex, { name: "blog" }),
44
- ]),
45
-
46
- // Long cache for individual posts
47
- cache({ documentCache: { sMaxAge: 3600, swr: 86400 } }, () => [
48
- path("/blog/:slug", BlogPost, { name: "blogPost" }),
49
- ]),
50
-
51
- // No cache for dashboard (no documentCache option)
52
- path("/dashboard", Dashboard, { name: "dashboard" }),
53
- ]);
51
+ // Cache full page for 5 min, serve stale for 1 hour
52
+ function BlogIndexHandler(ctx) {
53
+ ctx.headers.set("Cache-Control", "s-maxage=300, stale-while-revalidate=3600");
54
+ return <BlogIndex />;
55
+ }
56
+
57
+ // Long cache for individual posts
58
+ function BlogPostHandler(ctx) {
59
+ ctx.headers.set("Cache-Control", "s-maxage=3600, stale-while-revalidate=86400");
60
+ return <BlogPost />;
61
+ }
62
+
63
+ // Dashboard sets no Cache-Control header, so it is never document-cached.
54
64
  ```
55
65
 
56
66
  ## Document Cache Options
57
67
 
58
- ```typescript
59
- createRouter({
60
- // ...
61
- documentCache: (_env, ctx) => ({
62
- // Cache store (required)
63
- store: new CFCacheStore({ ctx: ctx! }),
68
+ `createDocumentCacheMiddleware(options?)` accepts:
64
69
 
65
- // Skip specific paths
66
- skipPaths: ["/api", "/admin"],
70
+ ```typescript
71
+ createDocumentCacheMiddleware({
72
+ // Skip specific paths (matched by pathname prefix)
73
+ skipPaths: ["/api", "/admin"],
67
74
 
68
- // Custom cache key
69
- keyGenerator: (url) => url.pathname,
75
+ // Custom cache key generator
76
+ keyGenerator: (url) => url.pathname,
70
77
 
71
- // Conditional caching
72
- isEnabled: (ctx) => !ctx.request.headers.has("x-preview"),
78
+ // Conditional caching, evaluated per request
79
+ isEnabled: (ctx) => !ctx.request.headers.has("x-preview"),
73
80
 
74
- // Debug logging
75
- debug: true,
76
- }),
81
+ // Debug logging (HIT, MISS, STALE, REVALIDATED)
82
+ debug: true,
77
83
  });
78
84
  ```
79
85
 
86
+ The cache store is not a middleware option — it comes from the app-level
87
+ `createRouter({ cache })` store.
88
+
80
89
  ## How It Works
81
90
 
82
91
  ```
@@ -89,7 +98,7 @@ Request → Check Cache
89
98
  ↓ ↓
90
99
  Fresh? Run handler
91
100
  │ │
92
- Yes → Return Has documentCache?
101
+ Yes → Return Has s-maxage?
93
102
  │ │
94
103
  No (stale) Yes → Cache + Return
95
104
  │ │
@@ -120,13 +129,14 @@ Segment hash ensures different cached responses for navigations from different s
120
129
 
121
130
  - Full HTML responses (document requests)
122
131
  - RSC payloads (client navigation)
123
- - Only 200 OK responses with documentCache enabled
132
+ - Only 200 OK responses whose `Cache-Control` includes `s-maxage`
124
133
 
125
134
  ## What's NOT Cached
126
135
 
127
136
  - Server actions (`_rsc_action`)
128
137
  - Loader requests (`_rsc_loader`)
129
- - Routes without `documentCache` option
138
+ - Non-GET requests
139
+ - Responses without an `s-maxage` `Cache-Control` directive
130
140
  - Non-200 responses
131
141
 
132
142
  ## Complete Example
@@ -134,40 +144,53 @@ Segment hash ensures different cached responses for navigations from different s
134
144
  ```typescript
135
145
  // router.tsx
136
146
  import { createRouter } from "@rangojs/router";
137
- import { CFCacheStore } from "@rangojs/router/cache";
147
+ import { createDocumentCacheMiddleware, CFCacheStore } from "@rangojs/router/cache";
138
148
  import { urlpatterns } from "./urls";
139
149
 
140
150
  const router = createRouter<AppBindings>({
141
151
  document: Document,
142
152
  urls: urlpatterns,
143
- documentCache: (_env, ctx) => ({
144
- store: new CFCacheStore({ ctx: ctx! }),
153
+ cache: (_env, ctx) => new CFCacheStore({ ctx: ctx! }),
154
+ });
155
+
156
+ router.use(
157
+ createDocumentCacheMiddleware({
145
158
  skipPaths: ["/api"],
146
159
  debug: process.env.NODE_ENV === "development",
147
160
  }),
148
- });
161
+ );
149
162
 
150
163
  export default router;
151
164
 
152
165
  // urls.tsx
153
166
  import { urls } from "@rangojs/router";
154
167
 
155
- export const urlpatterns = urls(({ path, layout, cache, loader }) => [
156
- // Blog with document caching
157
- cache({ documentCache: { sMaxAge: 300, swr: 3600 } }, () => [
158
- layout(<BlogLayout />, () => [
159
- path("/blog", BlogIndex, { name: "blog" }),
160
- path("/blog/:slug", BlogPost, { name: "blogPost" }, () => [
161
- loader(BlogPostLoader),
162
- ]),
168
+ export const urlpatterns = urls(({ path, layout, loader }) => [
169
+ // Blog pages opt into document caching via Cache-Control headers set in
170
+ // their handlers (see BlogIndex / BlogPost below).
171
+ layout(<BlogLayout />, () => [
172
+ path("/blog", BlogIndex, { name: "blog" }),
173
+ path("/blog/:slug", BlogPost, { name: "blogPost" }, () => [
174
+ loader(BlogPostLoader),
163
175
  ]),
164
176
  ]),
165
177
 
166
- // Dashboard - no document cache (dynamic content)
178
+ // Dashboard sets no Cache-Control header, so it is never document-cached.
167
179
  layout(<DashboardLayout />, () => [
168
180
  path("/dashboard", Dashboard, { name: "dashboard" }),
169
181
  ]),
170
182
  ]);
183
+
184
+ // Blog handlers set s-maxage to opt into the document cache.
185
+ function BlogIndex(ctx) {
186
+ ctx.headers.set("Cache-Control", "s-maxage=300, stale-while-revalidate=3600");
187
+ return <BlogIndexPage />;
188
+ }
189
+
190
+ function BlogPost(ctx) {
191
+ ctx.headers.set("Cache-Control", "s-maxage=300, stale-while-revalidate=3600");
192
+ return <BlogPostPage />;
193
+ }
171
194
  ```
172
195
 
173
196
  ## Document Cache vs Segment Cache
@@ -175,7 +198,7 @@ export const urlpatterns = urls(({ path, layout, cache, loader }) => [
175
198
  | Feature | Document Cache | Segment Cache |
176
199
  | ------------ | -------------------------- | --------------------- |
177
200
  | Granularity | Full response | Individual segments |
178
- | Opt-in | `documentCache` in cache() | `cache({ ttl, swr })` |
201
+ | Opt-in | `Cache-Control` `s-maxage` | `cache({ ttl, swr })` |
179
202
  | Use case | Static pages | Dynamic compositions |
180
203
  | Key includes | URL + segment hash | Route params |
181
204
 
@@ -268,7 +268,11 @@ reads (keyed or not) reset on navigation from fresh route data, as before.
268
268
  **Refreshing multiple loaders together (`refreshGroup` + `useRefreshLoaders`)**:
269
269
 
270
270
  `key` groups readers of one loader. To refresh **different** loaders together,
271
- tag them with the same `refreshGroup` and trigger them with `useRefreshLoaders`:
271
+ tag them with a shared `refreshGroup` name and trigger them with
272
+ `useRefreshLoaders()`. The hook takes no argument; you pass the group(s) to the
273
+ function it returns, so one `useRefreshLoaders()` can refresh different groups
274
+ depending on context. A read may carry **several** tags — pass an array — and is
275
+ refreshed when **any** of its groups is refreshed:
272
276
 
273
277
  ```tsx
274
278
  function Profile() {
@@ -279,33 +283,47 @@ function Profile() {
279
283
  return <span>{data.name}</span>;
280
284
  }
281
285
  function Orders() {
286
+ // Tagged into two groups: refreshed by "account" (the whole set) or the
287
+ // finer "orders" tag.
282
288
  const { data } = useLoader(OrdersLoader, {
283
289
  key: userId,
284
- refreshGroup: "account",
290
+ refreshGroup: ["account", "orders"],
285
291
  });
286
292
  return <span>{data.count} orders</span>;
287
293
  }
288
- function RefreshButton() {
289
- const refreshAccount = useRefreshLoaders("account");
290
- return <button onClick={() => refreshAccount()}>Refresh</button>;
294
+ function RefreshButtons() {
295
+ const refresh = useRefreshLoaders();
296
+ return (
297
+ <>
298
+ <button onClick={() => refresh("account")}>Refresh account</button>
299
+ <button onClick={() => refresh("orders")}>Refresh orders only</button>
300
+ <button onClick={() => refresh(["account", "orders"])}>
301
+ Refresh both
302
+ </button>
303
+ </>
304
+ );
291
305
  }
292
306
  ```
293
307
 
294
- `refreshAccount()` re-runs every currently-mounted member with a **plain GET**
295
- against the current route URL no params, no body, no mutation methods, because
296
- a group spans loaders with different shapes. It returns a promise that resolves
308
+ `refresh(groups)` accepts one name or an array and re-runs every currently-mounted
309
+ member tagged with **any** of them, with a **plain GET** against the current route
310
+ URL no params, no body, no mutation methods, because a group spans loaders with
311
+ different shapes. A member that sits in two of the requested groups is fetched
312
+ once (members are unioned and deduped by read). It returns a promise that resolves
297
313
  when all members settle and **rejects with an `AggregateError`** if any fail;
298
314
  group refresh never render-throws, so handle failures at the await site
299
- (`await refreshAccount().catch(...)`). Each failing member also exposes its error
300
- via its own read's `error`.
301
-
302
- Sharing within a group is opt-in via `key`: members that share a `key` share one
303
- value (and one fetch); a grouped reader **without** a `key` gets its own private
304
- bucket, so a group refresh updates only that read and never leaks into unrelated
305
- unkeyed reads of the same loader. A bucket may belong to several groups at once
306
- (different reads can tag the same keyed bucket with different group names).
307
- Keep parameterized loaders on the single-loader `key` a plain-GET group refresh
308
- sends no params.
315
+ (`await refresh("account").catch(...)`). Each failing member also exposes its
316
+ error via its own read's `error`.
317
+
318
+ Multiple tags give you granular vs. whole-set refresh from one place: a coarse
319
+ tag (`"account"`) covers everything, while a finer tag (`"orders"`) targets a
320
+ subset. Sharing within a group is opt-in via `key`: members that share a `key`
321
+ share one value (and one fetch); a grouped reader **without** a `key` gets its own
322
+ private bucket, so a group refresh updates only that read and never leaks into
323
+ unrelated unkeyed reads of the same loader. A bucket may belong to several groups
324
+ at once (one read tagged with multiple names, or different reads tagging the same
325
+ keyed bucket with different names). Keep parameterized loaders on the single-loader
326
+ `key` — a plain-GET group refresh sends no params.
309
327
 
310
328
  **Load options**:
311
329
 
@@ -850,7 +868,7 @@ function MountInfo() {
850
868
 
851
869
  ### useReverse(routes)
852
870
 
853
- Mount-aware local reverse for client components. Import the generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse(".name", params?)`. Auto-fills params from `useParams()`; explicit params override.
871
+ Mount-aware local reverse for client components. Import the generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse("name", params?)` — the leading dot is optional. Auto-fills params from `useParams()`; explicit params override.
854
872
 
855
873
  > Per-module `*.gen.ts` files are **CLI opt-in and not Vite-watched** — run `rango generate <urls-file>` (or wire it into `predev`) and re-run it whenever the module's routes change. See `/links` for the full generated-file setup and exposure-boundary rules.
856
874
 
@@ -863,8 +881,8 @@ function BlogNav() {
863
881
  const reverse = useReverse(blogRoutes);
864
882
  return (
865
883
  <nav>
866
- <Link to={reverse(".index")}>Blog</Link>
867
- <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
884
+ <Link to={reverse("index")}>Blog</Link>
885
+ <Link to={reverse("post", { postId: "hello" })}>Post</Link>
868
886
  </nav>
869
887
  );
870
888
  }
@@ -888,7 +906,7 @@ See `/links` for the full URL generation guide. `ctx.reverse()` is server-only;
888
906
  | `useLinkStatus()` | Link pending state | { pending } |
889
907
  | `useLoader()` | Loader data (strict) | data, isLoading, error |
890
908
  | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
891
- | `useRefreshLoaders()` | Refresh a cross-loader group | `(group) => () => Promise<void>` |
909
+ | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
892
910
  | `useHandle()` | Accumulated handle data | T (handle type) |
893
911
  | `useAction()` | Server action state | state, error, result |
894
912
  | `useLocationState()` | History state (persists or flash) | T \| undefined |
@@ -13,7 +13,7 @@ argument-hint: [ctx.reverse|href|useHref|useMount|useReverse|scopedReverse]
13
13
  **On the client, two patterns:**
14
14
 
15
15
  1. **Receive URLs as props / loader data / action return.** The default. The server has the full route manifest and handler context — generate URLs there and hand strings to client components.
16
- 2. **`useReverse(routes)`.** Import a generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse(".name", params?)`. Mount-aware via `useMount()`, auto-fills params from `useParams()`, fully typed from the imported map. Use this when a client component needs to generate URLs into a known module without round-tripping through the server.
16
+ 2. **`useReverse(routes)`.** Import a generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse("name", params?)` (the leading dot is optional). Mount-aware via `useMount()`, auto-fills params from `useParams()`, fully typed from the imported map. Use this when a client component needs to generate URLs into a known module without round-tripping through the server.
17
17
 
18
18
  `ctx.reverse()` itself is **server-only** — it depends on the full route manifest and handler context. Client components never import or call it.
19
19
 
@@ -273,7 +273,7 @@ function MountInfo() {
273
273
 
274
274
  Hook that returns a typed local reverse function for a `routes` map imported from a generated `.gen.ts` next to a `urls()` module. The route map is the **exposure boundary** — `useReverse` only knows about names in that map, never the full app manifest.
275
275
 
276
- > Import the per-module `routes` (e.g. `urls/blog.gen.ts`), **not** `router.named-routes.gen.ts`. The named-routes file is the whole app manifest and is server-only data importing it into a client component pulls every route name into the client bundle.
276
+ > **Which map?** `useReverse` accepts any routes map. Prefer the per-module `routes` (e.g. `urls/blog.gen.ts`): it gives **mount-aware** local `.name` reverse (auto-prefixes the `include()` mount) and only that module's names enter the client bundle. You _can_ instead pass `router.named-routes.gen.ts` (`NamedRoutes`) for **global** names (`blog.post`; the leading dot is optional) — it is a plain importable map and works on the client (it is **not** server-only)but its paths are **absolute** while `useReverse` mount-prefixes, so it is correct only at the root mount (under a non-root mount it double-prefixes), and importing it pulls every route name and pattern in the app into the client bundle (a small names-to-paths map — not components or loaders), versus the per-module map which exposes only one module's names. So the per-module map is preferred for in-module links; the named-routes map is the escape hatch for global names.
277
277
 
278
278
  ```tsx
279
279
  "use client";
@@ -285,8 +285,8 @@ export function BlogNav() {
285
285
 
286
286
  return (
287
287
  <nav>
288
- <Link to={reverse(".index")}>Blog</Link>
289
- <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
288
+ <Link to={reverse("index")}>Blog</Link>
289
+ <Link to={reverse("post", { postId: "hello" })}>Post</Link>
290
290
  </nav>
291
291
  );
292
292
  }
@@ -294,7 +294,7 @@ export function BlogNav() {
294
294
 
295
295
  ### How it resolves
296
296
 
297
- 1. Strips the leading `.` and looks up the name in the imported `routes` map.
297
+ 1. Strips an optional leading `.` and looks up the name in the imported `routes` map.
298
298
  2. Joins the local pattern with the surrounding `useMount()` value — the include's URL pattern.
299
299
  3. Substitutes params: explicit params from the call, then auto-filled from `useParams()` for anything still unresolved (mount params like `:tenantId` flow in this way).
300
300
  4. Appends a query string if a search object is passed and the route has a `search` schema.
@@ -362,14 +362,14 @@ reverse(".search", {}, { q: "hello world", page: 2 });
362
362
 
363
363
  ### Errors
364
364
 
365
- - Unknown name: throws `Unknown local route: ".not-a-route"`.
365
+ - Unknown name: throws `Unknown route: ".not-a-route"`.
366
366
  - Missing required param: throws `Missing param "postId" for route ".detail"`.
367
367
 
368
368
  Both happen synchronously during `reverse()` — wrap calls in try/catch (or an ErrorBoundary if the throw happens during render) when you need to surface them as UI.
369
369
 
370
- ### Names are dot-only on the client
370
+ ### The leading dot is optional
371
371
 
372
- `useReverse` accepts only `.name` (and dotted variants like `.nested.index`). There is no global namespace on the client the import IS the scope. To link into a different module, import that module's `routes`:
372
+ `reverse("post")` and `reverse(".post")` resolve **identically** the leading dot is cosmetic. The map you import IS the scope, so there is no separate global namespace to disambiguate and the dot carries no meaning; it exists only as a readability convention and for parity with `ctx.reverse(".name")` on the server. To link into a different module, import that module's `routes`:
373
373
 
374
374
  ```tsx
375
375
  import { routes as blogRoutes } from "../urls/blog.gen.js";
@@ -380,8 +380,8 @@ function CrossNav() {
380
380
  const shop = useReverse(shopRoutes);
381
381
  return (
382
382
  <nav>
383
- <Link to={blog(".index")}>Blog</Link>
384
- <Link to={shop(".cart")}>Cart</Link>
383
+ <Link to={blog("index")}>Blog</Link>
384
+ <Link to={shop("cart")}>Cart</Link>
385
385
  </nav>
386
386
  );
387
387
  }
@@ -98,9 +98,9 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
98
98
  > **client** refresh identity. It groups which mounted reads of one loader
99
99
  > refresh together when one calls `load()`. It never touches the server
100
100
  > request. For refreshing **different** loaders together, tag them with
101
- > `{ refreshGroup }` and call `useRefreshLoaders(name)()` (plain GET only).
102
- > See the hooks skill ("Scoping refetch with a `key`" and "Refreshing multiple
103
- > loaders together").
101
+ > `{ refreshGroup }` (one name or several) and call `useRefreshLoaders()(name)`
102
+ > (plain GET only). See the hooks skill ("Scoping refetch with a `key`" and
103
+ > "Refreshing multiple loaders together").
104
104
  > - `cache({ key })` — a **server** cache identity (storage hit/miss/ttl/swr).
105
105
  > - `revalidate()` — which **server** segments/loaders recompute during
106
106
  > navigation and action refreshes.
@@ -28,7 +28,10 @@ with the shape, then pick a primitive.
28
28
  cache hit streams UI instantly while loaders resolve fresh alongside). Opt into
29
29
  caching explicitly. See `/loader` → "Parallel and streaming".
30
30
  - **One identity, one store** — loaders, handles, cached fns, and actions are all
31
- `path#export`; all caches share one store; `revalidateTag` cuts across them.
31
+ `path#export`; all caches share one store. Entries expire by TTL/SWR; cache
32
+ entries accept an optional `tags` field, but built-in stores do not yet index
33
+ or invalidate by tag, so tag-based invalidation (`revalidateTag`) is a
34
+ forward-looking API requiring a custom store.
32
35
  - **Type-safe end to end** — route names, params, search schemas, loader return
33
36
  types, context vars, and `href` / `reverse` are checked at compile time
34
37
  (`/typesafety`).
@@ -82,8 +85,10 @@ To decide where something can live: **does it define a URL? structure, stays in
82
85
  (`set`/`header`/`setTheme`/`onResponse`/`setLocationState`) throw; `ctx.use(Handle)`
83
86
  is captured on miss and replayed on hit. (The non-cacheable read guard is a
84
87
  separate `cache()`-boundary check — see the correctness bullet below.)
85
- - One identity `path#export` (`functionId`/`$$id`/`actionId`); one store;
86
- `revalidateTag` cuts across all cache mechanisms.
88
+ - One identity `path#export` (`functionId`/`$$id`/`actionId`); one store. The
89
+ cross-cutting freshness mechanism today is TTL/SWR expiry; cache entries accept
90
+ an optional `tags` field, but built-in stores do not yet index or invalidate by
91
+ tag, so `revalidateTag` is forward-looking (requires a custom store).
87
92
  - `useLoader` / `useHandle` / `useFetchLoader` are client-only.
88
93
  - Caches are correctness-first: persistent store keys are version-segmented (no
89
94
  cross-deploy drift), the forward/back cache is mutation-aware, and
@@ -106,13 +111,13 @@ To decide where something can live: **does it define a URL? structure, stays in
106
111
  Same words, different jobs — this is the most common source of the
107
112
  `revalidate()`-is-caching misread.
108
113
 
109
- | You may know | Maps to Rango axis | Watch out |
110
- | ------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
- | Next.js `export const revalidate = N` | **Axis 1** (cache) | Same word, opposite meaning. Next's `revalidate` is time-based cache expiry; Rango's `revalidate()` is **axis 2**. Use `cache({ ttl })` for the Next behavior. |
112
- | Next.js `revalidatePath` / `revalidateTag` | **Axis 1** (cache) | Cache busting. Rango's tag bust is `revalidateTag`; there is no `revalidatePath`. |
113
- | React Router / Remix `shouldRevalidate` | **Axis 2** | This is the correct mental model for Rango's `revalidate()`. |
114
- | HTTP `Cache-Control` / ISR | **Axis 1** | Edge/document layer — see `/document-cache`. Separate from both `cache()` and `revalidate()`. |
115
- | Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
114
+ | You may know | Maps to Rango axis | Watch out |
115
+ | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | Next.js `export const revalidate = N` | **Axis 1** (cache) | Same word, opposite meaning. Next's `revalidate` is time-based cache expiry; Rango's `revalidate()` is **axis 2**. Use `cache({ ttl })` for the Next behavior. |
117
+ | Next.js `revalidatePath` / `revalidateTag` | **Axis 1** (cache) | Cache busting. No shipped equivalent: entries accept `tags`, but built-in stores don't yet index/invalidate by tag, so `revalidateTag` is forward-looking (custom store); today entries expire by TTL/SWR. No `revalidatePath`. |
118
+ | React Router / Remix `shouldRevalidate` | **Axis 2** | This is the correct mental model for Rango's `revalidate()`. |
119
+ | HTTP `Cache-Control` / ISR | **Axis 1** | Edge/document layer — see `/document-cache`. Separate from both `cache()` and `revalidate()`. |
120
+ | Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
116
121
 
117
122
  See `/cache-guide` for the axis-1 decision guide, `/loader` and `/route` for
118
123
  `revalidate()` (axis 2), and `/document-cache` for the edge layer.
@@ -215,6 +220,7 @@ Grouped by concern — read when you need to…
215
220
  | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
216
221
  | `/view-transitions` | React View Transitions on layouts, routes, and parallel slots |
217
222
  | `/breadcrumbs` | Built-in Breadcrumbs handle for breadcrumb navigation |
223
+ | `/react-compiler` | Enable React Compiler (opt-in) the vite-rsc way; client-only scope |
218
224
 
219
225
  **Observability & production health**:
220
226
 
@@ -0,0 +1,168 @@
1
+ ---
2
+ name: react-compiler
3
+ description: Enable the React Compiler in a Rango app the @vitejs/plugin-rsc way — a separate @rolldown/plugin-babel running reactCompilerPreset(), ordered after react() and before the plugin that supplies @vitejs/plugin-rsc. Use when a consumer wants to turn React Compiler on, hits the dead plugin-react v6 `react({ babel })` path, or is unsure why server components aren't being compiled.
4
+ argument-hint:
5
+ ---
6
+
7
+ # React Compiler
8
+
9
+ React Compiler is **opt-in** in Rango. The plugin pipeline is fully compatible —
10
+ you just add one more plugin. The catch on a current Rango stack (Vite 8 +
11
+ `@vitejs/plugin-react` v6) is that **v6 dropped its internal Babel for oxc**, so
12
+ the way the React docs and most blog posts show it — `react({ babel: { plugins:
13
+ [...] } })` — silently does nothing. The compiler has to be its own top-level
14
+ plugin.
15
+
16
+ ## The shape (read first)
17
+
18
+ - The compiler is a **Babel** plugin, run via
19
+ [`@rolldown/plugin-babel`](https://www.npmjs.com/package/@rolldown/plugin-babel)
20
+ with `reactCompilerPreset()` from `@vitejs/plugin-react`.
21
+ - **Ordering is load-bearing:** put `babel(...)` **after `react()`** and
22
+ **before the plugin that supplies `@vitejs/plugin-rsc`**. In a default Rango
23
+ app that plugin is `rango()` itself; in a Cloudflare app it is
24
+ `@cloudflare/vite-plugin`.
25
+ - **It is client-only.** `reactCompilerPreset()` gates itself to the client
26
+ environment. Server/RSC components are not compiled, and that is the upstream
27
+ example's behavior — not a Rango limitation. See
28
+ [What gets compiled](#what-gets-compiled-client-only).
29
+ - **Rango's build-time prerender is unaffected.** You do not need to do anything
30
+ special. See [Prerender](#interaction-with-build-time-prerender).
31
+
32
+ ## Step 1: Install
33
+
34
+ ```bash
35
+ pnpm add -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler
36
+ # TypeScript users also want the Babel core types:
37
+ pnpm add -D @types/babel__core
38
+ ```
39
+
40
+ React 19 ships `react/compiler-runtime` in-tree, so there is **no** extra runtime
41
+ to install and **no** `target` option to set. Only pass `target: '17' | '18'` to
42
+ `reactCompilerPreset()` if you are on an older React.
43
+
44
+ ## Step 2: Wire it in
45
+
46
+ ### Default (non-Cloudflare) app
47
+
48
+ ```ts
49
+ // vite.config.ts
50
+ import { defineConfig } from "vite";
51
+ import react, { reactCompilerPreset } from "@vitejs/plugin-react";
52
+ import babel from "@rolldown/plugin-babel";
53
+ import { rango } from "@rangojs/router/vite";
54
+
55
+ export default defineConfig({
56
+ plugins: [
57
+ react(),
58
+ babel({ presets: [reactCompilerPreset()] }),
59
+ rango(), // supplies @vitejs/plugin-rsc
60
+ ],
61
+ });
62
+ ```
63
+
64
+ ### Cloudflare app
65
+
66
+ ```ts
67
+ // vite.config.ts
68
+ import { cloudflare } from "@cloudflare/vite-plugin";
69
+ import react, { reactCompilerPreset } from "@vitejs/plugin-react";
70
+ import babel from "@rolldown/plugin-babel";
71
+ import { defineConfig } from "vite";
72
+ import { rango } from "@rangojs/router/vite";
73
+
74
+ export default defineConfig({
75
+ plugins: [
76
+ react(),
77
+ babel({ presets: [reactCompilerPreset()] }),
78
+ rango({ preset: "cloudflare" }),
79
+ cloudflare({
80
+ /* ... */
81
+ }), // supplies @vitejs/plugin-rsc
82
+ ],
83
+ });
84
+ ```
85
+
86
+ ## What gets compiled (client-only)
87
+
88
+ `reactCompilerPreset()` carries
89
+ `rolldown.applyToEnvironmentHook: (env) => env.config.consumer === "client"`, so
90
+ even though the babel plugin is top-level, the transform runs **only in the
91
+ `client` environment**:
92
+
93
+ | Environment | `consumer` | Compiled? |
94
+ | ----------- | ---------- | --------- |
95
+ | client | `client` | Yes |
96
+ | ssr | `server` | No |
97
+ | rsc | `server` | No |
98
+
99
+ This matches the upstream `@vitejs/plugin-rsc` example. If you genuinely need to
100
+ compile **server** components, you would have to invoke
101
+ `babel-plugin-react-compiler` yourself without the preset's
102
+ `applyToEnvironmentHook` — that is outside what the example does and is not
103
+ covered here.
104
+
105
+ ## Options
106
+
107
+ `reactCompilerPreset()` forwards to `babel-plugin-react-compiler`:
108
+
109
+ | Option | Effect |
110
+ | ------------------------------- | -------------------------------------------------------------------------------------- |
111
+ | `compilationMode: 'annotation'` | Compile only components marked with the `"use memo"` directive, not every eligible one |
112
+ | `target: '17' \| '18'` | Emit `react-compiler-runtime` calls for React < 19. Omit on React 19+. |
113
+
114
+ ## Interaction with build-time prerender
115
+
116
+ Nothing to configure. Rango's discovery/prerender step runs a throwaway temp Vite
117
+ server (`createTempRscServer`) that forwards only your **resolution** plugins
118
+ (`resolveId` / `load`). A pure transform plugin like `@rolldown/plugin-babel` is
119
+ intentionally **not** forwarded — and that is correct: the temp runner only
120
+ produces **data** (serialized Flight payloads + the route manifest), not shipped
121
+ code, and React Compiler is a memoization-only transform that does not change
122
+ rendered output. Your shipped client bundle still gets compiled, because the
123
+ babel plugin lives in your app's top-level plugin array alongside `react()`.
124
+
125
+ ## Step 3: Verify the compiler actually ran
126
+
127
+ A compiled module imports the cache allocator from `react/compiler-runtime` and
128
+ calls `_c(n)`. Those two appear in **every** compiled module, so they are the
129
+ reliable per-module signal in dev:
130
+
131
+ ```bash
132
+ pnpm dev
133
+ # fetch any client component module straight from Vite and look for the markers:
134
+ curl -s "http://localhost:5173/src/components/SomeClientComponent.tsx" \
135
+ | grep -E "compiler-runtime|_c\("
136
+ ```
137
+
138
+ For a production build, grep the built client bundle for the compiler's
139
+ input-independent cache check, which has a **zero baseline** without the compiler:
140
+
141
+ ```bash
142
+ pnpm build
143
+ grep -r "Symbol.for(\"react.memo_cache_sentinel\")" dist/client/assets/ | head
144
+ ```
145
+
146
+ Note the **comparison** form `$[i] === Symbol.for("react.memo_cache_sentinel")`
147
+ is only emitted for components with input-independent JSX, so it is reliable over
148
+ the **whole** client bundle, not necessarily in one chosen module. (React core
149
+ also defines that symbol once with a single `=` assignment, so count comparisons,
150
+ not the bare string.) Run the same grep over `dist/rsc` / `dist/ssr` and you
151
+ should find **none** — that is the client-only contract.
152
+
153
+ ## Troubleshooting
154
+
155
+ | Symptom | Cause / fix |
156
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | Nothing is compiled; no `compiler-runtime` import anywhere | You used `react({ babel: { plugins: [...] } })`. plugin-react v6 has no internal Babel — add `@rolldown/plugin-babel` as its own plugin. |
158
+ | Client compiled, but server/RSC components are not | Expected. `reactCompilerPreset()` is client-only (see the table). Not a bug. |
159
+ | `Cannot find module 'babel-plugin-react-compiler'` (or `@babel/core`) | Install the peer deps from Step 1; they are not bundled by `reactCompilerPreset()`. |
160
+ | Build pulls in `react-compiler-runtime` | You set `target: '17'`/`'18'` on React 19. Drop `target` — React 19 ships `react/compiler-runtime` in-tree. |
161
+ | Output looks compiled but a component misbehaves | The component likely breaks the Rules of React. Fix the component, or scope the compiler with `compilationMode: 'annotation'` while you do. |
162
+
163
+ ## Reference
164
+
165
+ A worked, tested wiring (dev + production e2e markers, incl. the client-only
166
+ contract) lives in the `@rangojs/router` repo: `docs/react-compiler.md` and the
167
+ `react-compiler.test.ts` files under `e2e/e2e-basic`, `tests/cloudflare-basic`,
168
+ and `tests/vite-rsc-demo`.