@rangojs/router 0.0.0-experimental.39 → 0.0.0-experimental.3b1deca8
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 +8 -3
- package/dist/vite/index.js +292 -204
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- package/skills/loader/SKILL.md +53 -43
- package/skills/parallel/SKILL.md +126 -0
- package/skills/route/SKILL.md +31 -0
- package/skills/router-setup/SKILL.md +52 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/browser/debug-channel.ts +93 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +1 -5
- package/src/browser/navigation-client.ts +84 -27
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +50 -9
- package/src/browser/prefetch/cache.ts +57 -5
- package/src/browser/prefetch/fetch.ts +30 -21
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +9 -1
- package/src/browser/react/NavigationProvider.tsx +32 -3
- package/src/browser/rsc-router.tsx +109 -57
- package/src/browser/scroll-restoration.ts +31 -34
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/server-action-bridge.ts +12 -0
- package/src/browser/types.ts +17 -1
- package/src/build/route-types/router-processing.ts +12 -2
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/deps/browser.ts +1 -0
- package/src/route-definition/dsl-helpers.ts +32 -7
- package/src/route-definition/helpers-types.ts +6 -5
- package/src/route-definition/redirect.ts +2 -2
- package/src/route-map-builder.ts +7 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +31 -8
- package/src/router/intercept-resolution.ts +2 -0
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +7 -1
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +66 -9
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +8 -5
- package/src/router/match-result.ts +22 -6
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +6 -2
- package/src/router/middleware.ts +4 -3
- package/src/router/router-context.ts +6 -1
- package/src/router/segment-resolution/fresh.ts +130 -17
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +352 -290
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +6 -1
- package/src/rsc/handler.ts +28 -2
- package/src/rsc/loader-fetch.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +4 -1
- package/src/rsc/rsc-rendering.ts +4 -1
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +7 -1
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +102 -13
- package/src/server/request-context.ts +59 -12
- package/src/ssr/index.tsx +1 -0
- package/src/types/handler-context.ts +120 -22
- package/src/types/loader-types.ts +4 -4
- package/src/types/route-entry.ts +7 -0
- package/src/types/segments.ts +2 -0
- package/src/urls/path-helper.ts +1 -1
- package/src/vite/discovery/state.ts +0 -2
- package/src/vite/plugin-types.ts +0 -83
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/performance-tracks.ts +235 -0
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +148 -209
- package/src/vite/router-discovery.ts +0 -8
- package/src/vite/utils/banner.ts +3 -3
package/package.json
CHANGED
|
@@ -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
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -120,9 +120,9 @@ const store = new MemorySegmentCacheStore({
|
|
|
120
120
|
});
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
### Cloudflare
|
|
123
|
+
### Cloudflare Edge Cache Store
|
|
124
124
|
|
|
125
|
-
For distributed caching on Cloudflare Workers:
|
|
125
|
+
For distributed caching on Cloudflare Workers using the Cache API:
|
|
126
126
|
|
|
127
127
|
```typescript
|
|
128
128
|
import { CFCacheStore } from "@rangojs/router/cache";
|
|
@@ -132,14 +132,55 @@ const router = createRouter<AppBindings>({
|
|
|
132
132
|
urls: urlpatterns,
|
|
133
133
|
cache: (env, ctx) => ({
|
|
134
134
|
store: new CFCacheStore({
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
ctx,
|
|
136
|
+
defaults: { ttl: 60, swr: 300 },
|
|
137
137
|
}),
|
|
138
138
|
enabled: true,
|
|
139
139
|
}),
|
|
140
140
|
});
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### With KV L2 Persistence
|
|
144
|
+
|
|
145
|
+
Add a KV namespace for global cross-colo persistence. On Cache API miss, KV is
|
|
146
|
+
checked and hits are promoted back to L1. Writes go to both layers.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { CFCacheStore } from "@rangojs/router/cache";
|
|
150
|
+
|
|
151
|
+
const router = createRouter<AppBindings>({
|
|
152
|
+
document: Document,
|
|
153
|
+
urls: urlpatterns,
|
|
154
|
+
cache: (env, ctx) => ({
|
|
155
|
+
store: new CFCacheStore({
|
|
156
|
+
ctx,
|
|
157
|
+
kv: env.CACHE_KV, // optional KV namespace binding
|
|
158
|
+
defaults: { ttl: 60, swr: 300 },
|
|
159
|
+
}),
|
|
160
|
+
enabled: true,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**How the two layers work:**
|
|
166
|
+
|
|
167
|
+
| Scenario | L1 (Cache API) | L2 (KV) | Result |
|
|
168
|
+
| ------------ | -------------- | ------- | ----------------------------- |
|
|
169
|
+
| Hot request | HIT | — | Serve from L1 (fast) |
|
|
170
|
+
| Cold colo | MISS | HIT | Serve from KV, promote to L1 |
|
|
171
|
+
| First render | MISS | MISS | Render, write to both L1 + KV |
|
|
172
|
+
|
|
173
|
+
KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
|
|
174
|
+
are only cached in L1.
|
|
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
|
+
|
|
143
184
|
## Nested Cache Boundaries
|
|
144
185
|
|
|
145
186
|
Override cache settings for specific sections:
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -65,24 +65,10 @@ export const urlpatterns = urls(({ path, loader }) => [
|
|
|
65
65
|
|
|
66
66
|
## Consuming Loader Data
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
85
|
+
// Route definition — loader() registration required
|
|
100
86
|
path("/product/:slug", ProductPage, { name: "product" }, () => [
|
|
101
|
-
loader(ProductLoader),
|
|
87
|
+
loader(ProductLoader),
|
|
102
88
|
]);
|
|
103
89
|
```
|
|
104
90
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
108
|
+
```typescript
|
|
116
109
|
path("/product/:slug", async (ctx) => {
|
|
117
110
|
const { product } = await ctx.use(ProductLoader);
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
586
|
-
const { product } =
|
|
587
|
-
const { cart } =
|
|
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
|
|
599
|
+
<AddToCartButton
|
|
600
|
+
productId={product.id}
|
|
601
|
+
inCart={cart?.items.includes(product.id)}
|
|
602
|
+
/>
|
|
593
603
|
</div>
|
|
594
604
|
);
|
|
595
605
|
}
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -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:
|
|
@@ -109,6 +176,65 @@ parallel(
|
|
|
109
176
|
)
|
|
110
177
|
```
|
|
111
178
|
|
|
179
|
+
### Streaming Behavior
|
|
180
|
+
|
|
181
|
+
Parallels with `loading()` are **independent streaming units**. They don't
|
|
182
|
+
block the parent layout or sibling routes during SSR:
|
|
183
|
+
|
|
184
|
+
- **With `loading()`**: The skeleton renders immediately. The loader runs
|
|
185
|
+
in the background and streams data to the client when ready. The rest
|
|
186
|
+
of the page (layout, route content, other parallels) renders without
|
|
187
|
+
waiting.
|
|
188
|
+
- **Without `loading()`**: The parallel's loaders block the parent layout's
|
|
189
|
+
rendering. Use this when the data must be available before the page
|
|
190
|
+
paints (e.g., critical above-the-fold content).
|
|
191
|
+
- **SPA navigation**: Parallel loaders resolve in the background. The
|
|
192
|
+
existing parallel UI stays visible — no skeleton flash on route changes
|
|
193
|
+
within the same layout.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// Sidebar streams independently — page renders immediately
|
|
197
|
+
parallel(
|
|
198
|
+
{ "@sidebar": () => <Sidebar /> },
|
|
199
|
+
() => [loader(SlowSidebarLoader), loading(<SidebarSkeleton />)]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Cart data blocks layout — must be ready before paint
|
|
203
|
+
parallel(
|
|
204
|
+
{ "@cartBadge": () => <CartBadge /> },
|
|
205
|
+
() => [loader(CartCountLoader)] // No loading() = awaited
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Slot Override Semantics
|
|
210
|
+
|
|
211
|
+
When multiple `parallel()` calls define the same slot name, **the last
|
|
212
|
+
definition wins**. Earlier definitions of that slot are removed. Other
|
|
213
|
+
slots from the earlier call are preserved.
|
|
214
|
+
|
|
215
|
+
This enables composition patterns where included routes override
|
|
216
|
+
parent-defined slots:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
layout(DashboardLayout, () => [
|
|
220
|
+
// Base slots
|
|
221
|
+
parallel({
|
|
222
|
+
"@sidebar": () => <DefaultSidebar />,
|
|
223
|
+
"@footer": () => <Footer />,
|
|
224
|
+
}),
|
|
225
|
+
|
|
226
|
+
// Override just @sidebar — @footer is preserved
|
|
227
|
+
parallel({ "@sidebar": () => <CustomSidebar /> }),
|
|
228
|
+
|
|
229
|
+
path("/", DashboardIndex, { name: "index" }),
|
|
230
|
+
])
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
After resolution, the layout has two parallel entries:
|
|
234
|
+
|
|
235
|
+
- `{ "@footer": () => <Footer /> }` (first call, `@sidebar` removed)
|
|
236
|
+
- `{ "@sidebar": () => <CustomSidebar /> }` (second call, wins)
|
|
237
|
+
|
|
112
238
|
## Multiple Parallel Slots
|
|
113
239
|
|
|
114
240
|
```typescript
|
package/skills/route/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side debug channel for React Performance Tracks.
|
|
3
|
+
*
|
|
4
|
+
* Creates a bidirectional channel that communicates with the server-side
|
|
5
|
+
* debug channel via Vite's HMR WebSocket. Used with createFromFetch()
|
|
6
|
+
* so Chrome DevTools can display Server Components in the Performance tab.
|
|
7
|
+
*
|
|
8
|
+
* Dev-only — gated behind import.meta.hot.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
|
|
12
|
+
const DEBUG_S2C_EVENT = "rango:perf-s2c";
|
|
13
|
+
const DEBUG_C2S_EVENT = "rango:perf-c2s";
|
|
14
|
+
|
|
15
|
+
type DebugPayload =
|
|
16
|
+
| { i: string; b: string } // chunk (base64)
|
|
17
|
+
| { i: string; d: true }; // done
|
|
18
|
+
|
|
19
|
+
const bytesToBase64 = (bytes: Uint8Array) => {
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
22
|
+
binary += String.fromCharCode(bytes[i]!);
|
|
23
|
+
}
|
|
24
|
+
return btoa(binary);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const base64ToBytes = (base64: string) =>
|
|
28
|
+
Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a client-side debug channel for the given debugId.
|
|
32
|
+
* The channel communicates with the server via Vite's HMR WebSocket.
|
|
33
|
+
*/
|
|
34
|
+
export function createClientDebugChannel(debugId: string): {
|
|
35
|
+
readable: ReadableStream<Uint8Array>;
|
|
36
|
+
writable: WritableStream<Uint8Array>;
|
|
37
|
+
} | null {
|
|
38
|
+
const hot = (import.meta as any).hot;
|
|
39
|
+
if (!hot) return null;
|
|
40
|
+
|
|
41
|
+
let closed = false;
|
|
42
|
+
let onServerData: ((payload: DebugPayload) => void) | undefined;
|
|
43
|
+
|
|
44
|
+
const cleanup = (notify?: boolean) => {
|
|
45
|
+
if (closed) return;
|
|
46
|
+
closed = true;
|
|
47
|
+
if (onServerData) {
|
|
48
|
+
hot.off(DEBUG_S2C_EVENT, onServerData);
|
|
49
|
+
}
|
|
50
|
+
if (notify) {
|
|
51
|
+
hot.send(DEBUG_C2S_EVENT, { i: debugId, d: true } satisfies DebugPayload);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Readable: receives server-to-client debug data via HMR WS
|
|
56
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
57
|
+
start(controller) {
|
|
58
|
+
onServerData = (payload: DebugPayload) => {
|
|
59
|
+
if (closed || payload.i !== debugId) return;
|
|
60
|
+
if ("b" in payload) {
|
|
61
|
+
controller.enqueue(base64ToBytes(payload.b));
|
|
62
|
+
}
|
|
63
|
+
if ("d" in payload) {
|
|
64
|
+
cleanup();
|
|
65
|
+
controller.close();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
hot.on(DEBUG_S2C_EVENT, onServerData);
|
|
69
|
+
},
|
|
70
|
+
cancel() {
|
|
71
|
+
cleanup(true);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Writable: sends client-to-server commands via HMR WS
|
|
76
|
+
const writable = new WritableStream<Uint8Array>({
|
|
77
|
+
write(chunk) {
|
|
78
|
+
if (closed) throw new TypeError("Channel is closed");
|
|
79
|
+
hot.send(DEBUG_C2S_EVENT, {
|
|
80
|
+
i: debugId,
|
|
81
|
+
b: bytesToBase64(chunk),
|
|
82
|
+
} satisfies DebugPayload);
|
|
83
|
+
},
|
|
84
|
+
close() {
|
|
85
|
+
cleanup(true);
|
|
86
|
+
},
|
|
87
|
+
abort() {
|
|
88
|
+
cleanup(true);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return { readable, writable };
|
|
93
|
+
}
|
|
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
|
|
|
79
79
|
state: "idle" | "loading";
|
|
80
80
|
/** Whether any operation is streaming */
|
|
81
81
|
isStreaming: boolean;
|
|
82
|
+
/** Whether a navigation is active (fetching or streaming, before commit) */
|
|
83
|
+
isNavigating: boolean;
|
|
82
84
|
/** Current committed location */
|
|
83
85
|
location: NavigationLocation;
|
|
84
86
|
/** URL being navigated to (null if idle) */
|
|
@@ -389,6 +391,9 @@ export function createEventController(
|
|
|
389
391
|
return {
|
|
390
392
|
state,
|
|
391
393
|
isStreaming,
|
|
394
|
+
// True when a navigation is active (fetching or streaming, before
|
|
395
|
+
// commit). Broader than pendingUrl which clears during streaming.
|
|
396
|
+
isNavigating: currentNavigation !== null,
|
|
392
397
|
location,
|
|
393
398
|
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
399
|
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
@@ -472,6 +472,7 @@ export function createNavigationBridge(
|
|
|
472
472
|
cachedHandleData,
|
|
473
473
|
params: cachedParams,
|
|
474
474
|
},
|
|
475
|
+
scroll: { restore: true, isStreaming },
|
|
475
476
|
};
|
|
476
477
|
const hasTransition = cachedSegments.some((s) => s.transition);
|
|
477
478
|
if (hasTransition) {
|
|
@@ -480,14 +481,9 @@ export function createNavigationBridge(
|
|
|
480
481
|
addTransitionType("navigation-back");
|
|
481
482
|
}
|
|
482
483
|
onUpdate(popstateUpdate);
|
|
483
|
-
// Restore inside the transition so React commits the new
|
|
484
|
-
// content before scroll restoration measures scrollHeight.
|
|
485
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
486
484
|
});
|
|
487
485
|
} else {
|
|
488
486
|
onUpdate(popstateUpdate);
|
|
489
|
-
// Restore scroll position for back/forward navigation
|
|
490
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
491
487
|
}
|
|
492
488
|
|
|
493
489
|
// SWR: If stale, trigger background revalidation
|