@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.
- package/dist/bin/rango.js +74 -3
- package/dist/vite/index.js +133 -18
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +35 -24
- package/skills/caching/SKILL.md +115 -7
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/hooks/SKILL.md +40 -22
- package/skills/links/SKILL.md +10 -10
- package/skills/loader/SKILL.md +3 -3
- package/skills/rango/SKILL.md +16 -10
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/location-state-shared.ts +93 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/per-module-writer.ts +4 -1
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/handle.ts +3 -5
- package/src/loader-store.ts +62 -25
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/reverse.ts +16 -13
- package/src/route-definition/dsl-helpers.ts +5 -2
- package/src/route-definition/helpers-types.ts +31 -10
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/router-options.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +17 -4
- package/src/router/segment-resolution/revalidation.ts +17 -4
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +2 -0
- package/src/segment-system.tsx +59 -10
- package/src/server/context.ts +26 -0
- package/src/server/cookie-store.ts +28 -4
- package/src/types/handler-context.ts +5 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/use-loader.tsx +89 -42
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/plugins/use-cache-transform.ts +12 -10
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
44
|
+
## Route Opt-In with Cache-Control
|
|
34
45
|
|
|
35
|
-
Routes opt-in to document caching
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
59
|
-
createRouter({
|
|
60
|
-
// ...
|
|
61
|
-
documentCache: (_env, ctx) => ({
|
|
62
|
-
// Cache store (required)
|
|
63
|
-
store: new CFCacheStore({ ctx: ctx! }),
|
|
68
|
+
`createDocumentCacheMiddleware(options?)` accepts:
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
```typescript
|
|
71
|
+
createDocumentCacheMiddleware({
|
|
72
|
+
// Skip specific paths (matched by pathname prefix)
|
|
73
|
+
skipPaths: ["/api", "/admin"],
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
// Custom cache key generator
|
|
76
|
+
keyGenerator: (url) => url.pathname,
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
// Conditional caching, evaluated per request
|
|
79
|
+
isEnabled: (ctx) => !ctx.request.headers.has("x-preview"),
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
144
|
-
|
|
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,
|
|
156
|
-
// Blog
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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 | `
|
|
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
|
|
package/skills/hooks/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
289
|
-
const
|
|
290
|
-
return
|
|
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
|
-
`
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
300
|
-
via its own read's `error`.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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("
|
|
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("
|
|
867
|
-
<Link to={reverse("
|
|
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
|
|
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 |
|
package/skills/links/SKILL.md
CHANGED
|
@@ -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("
|
|
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
|
-
>
|
|
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("
|
|
289
|
-
<Link to={reverse("
|
|
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
|
|
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
|
|
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
|
-
###
|
|
370
|
+
### The leading dot is optional
|
|
371
371
|
|
|
372
|
-
`
|
|
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("
|
|
384
|
-
<Link to={shop("
|
|
383
|
+
<Link to={blog("index")}>Blog</Link>
|
|
384
|
+
<Link to={shop("cart")}>Cart</Link>
|
|
385
385
|
</nav>
|
|
386
386
|
);
|
|
387
387
|
}
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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(
|
|
102
|
-
> See the hooks skill ("Scoping refetch with a `key`" and
|
|
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.
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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`.
|