@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19
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/README.md +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
|
@@ -11,6 +11,9 @@ deserialization path, same segment system. The worker handles every request --
|
|
|
11
11
|
there are NO static .html or .rsc files served from assets. The worker reads
|
|
12
12
|
pre-computed Flight payloads instead of executing handler code.
|
|
13
13
|
|
|
14
|
+
Canonical semantics reference:
|
|
15
|
+
[docs/execution-model.md](../../docs/internal/execution-model.md)
|
|
16
|
+
|
|
14
17
|
## API: Prerender
|
|
15
18
|
|
|
16
19
|
### Static Route (no params)
|
|
@@ -69,13 +72,14 @@ export const ProductPage = Prerender(
|
|
|
69
72
|
|
|
70
73
|
Controls whether the handler stays in the RSC server bundle after build:
|
|
71
74
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
| Known params
|
|
75
|
-
| Unknown params
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
| `
|
|
75
|
+
| | `passthrough: false` (default) | `passthrough: true` |
|
|
76
|
+
| ------------------- | --------------------------------------- | --------------------------------------- |
|
|
77
|
+
| Known params | Served from pre-rendered Flight payload | Served from pre-rendered Flight payload |
|
|
78
|
+
| Unknown params | Handler evicted, no live fallback | Handler runs live at request time |
|
|
79
|
+
| `ctx.passthrough()` | Throws (not allowed) | Skips artifact, defers to live fallback |
|
|
80
|
+
| Bundle size | Handler code + imports removed | Handler code kept in RSC bundle |
|
|
81
|
+
| `revalidate()` | Not allowed (handler gone) | Allowed (handler can re-render) |
|
|
82
|
+
| `loading()` | Ignored (segments fully resolved) | Works for live fallback renders |
|
|
79
83
|
|
|
80
84
|
### When to use passthrough
|
|
81
85
|
|
|
@@ -98,6 +102,7 @@ Handlers receive `BuildContext` at build time, a subset of the runtime `HandlerC
|
|
|
98
102
|
```typescript
|
|
99
103
|
interface BuildContext<TParams> {
|
|
100
104
|
params: TParams; // From getParams
|
|
105
|
+
build: true; // Always true at build time
|
|
101
106
|
use: <T>(handle: Handle<T>) => (data: T) => void; // Push handle data
|
|
102
107
|
url: URL; // Synthetic URL from pattern + params
|
|
103
108
|
pathname: string; // Pathname from synthetic URL
|
|
@@ -105,6 +110,12 @@ interface BuildContext<TParams> {
|
|
|
105
110
|
set<T>(contextVar: ContextVar<T>, value: T): void; // Set typed context variable
|
|
106
111
|
get(key: string): any; // Read context variable (string key)
|
|
107
112
|
get<T>(contextVar: ContextVar<T>): T | undefined; // Read typed context variable
|
|
113
|
+
reverse(
|
|
114
|
+
name: string,
|
|
115
|
+
params?: Record<string, string>,
|
|
116
|
+
search?: Record<string, unknown>,
|
|
117
|
+
): string; // URL generation
|
|
118
|
+
passthrough(): PrerenderPassthroughResult; // Skip local artifact (passthrough routes only)
|
|
108
119
|
// NOT available: req, headers, cookies, env (throws descriptive errors)
|
|
109
120
|
}
|
|
110
121
|
```
|
|
@@ -130,6 +141,16 @@ All items inside the path's use() callback (child layouts, parallels) also recei
|
|
|
130
141
|
`BuildContext` during pre-rendering. Loaders are the exception -- they run at
|
|
131
142
|
request time with full server context.
|
|
132
143
|
|
|
144
|
+
This is one reason prerender is a good fit for handler-first composition:
|
|
145
|
+
the handler and its child layouts/parallels participate in the same full
|
|
146
|
+
render pass, so data set with `ctx.set()` is available downstream via
|
|
147
|
+
`ctx.get()`.
|
|
148
|
+
|
|
149
|
+
At runtime, partial action revalidation follows a narrower rule: only
|
|
150
|
+
revalidated segments are recomputed. If a child segment depends on data
|
|
151
|
+
established by an outer handler/layout, that outer segment must also be
|
|
152
|
+
revalidated, or the child must load/guard the data independently.
|
|
153
|
+
|
|
133
154
|
## Supported Export Patterns
|
|
134
155
|
|
|
135
156
|
All of the following are equivalent and fully supported by the Vite transform:
|
|
@@ -218,11 +239,25 @@ path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
|
|
|
218
239
|
| `loading()` | Ignored without passthrough. Works for live fallback with passthrough. |
|
|
219
240
|
| `intercept()` | Pre-rendered at build time. Intercept variant stored under `/i` key alongside main segments. At runtime, the correct variant is served based on `ctx.isIntercept`. `when()` conditions are skipped at build time (all intercepts are pre-rendered unconditionally). |
|
|
220
241
|
|
|
242
|
+
When passthrough revalidation is enabled, remember that revalidation is
|
|
243
|
+
still partial: opting a child segment into revalidation does not
|
|
244
|
+
implicitly re-run outer prerender-derived handlers/layouts.
|
|
245
|
+
|
|
221
246
|
## Dev Mode
|
|
222
247
|
|
|
223
|
-
In dev mode
|
|
224
|
-
|
|
225
|
-
|
|
248
|
+
In dev mode there is no production-style prerender build pass and no handler
|
|
249
|
+
stubbing.
|
|
250
|
+
|
|
251
|
+
**Node.js dev server** — `Prerender` acts as a normal handler. Routes render
|
|
252
|
+
live on every request with full runtime context (`ctx.build === false`).
|
|
253
|
+
|
|
254
|
+
**Non-Node runtimes (Cloudflare workerd, Deno workers)** — Handlers that
|
|
255
|
+
depend on Node APIs (e.g. `node:fs`) cannot run in-process. The Vite plugin
|
|
256
|
+
can intercept these requests and resolve them via the `/__rsc_prerender`
|
|
257
|
+
endpoint, which runs `matchForPrerender` in a Node.js temp server. In this
|
|
258
|
+
path the handler receives `BuildContext` (`ctx.build === true`) and segments
|
|
259
|
+
are resolved identically to production prerendering, then served on-demand.
|
|
260
|
+
This only applies when `__PRERENDER_DEV_URL` is set by the plugin.
|
|
226
261
|
|
|
227
262
|
## Storage Layout
|
|
228
263
|
|
|
@@ -288,8 +323,10 @@ export const TocSidebar = Static(() => {
|
|
|
288
323
|
|
|
289
324
|
### Error behavior at build time
|
|
290
325
|
|
|
291
|
-
|
|
|
326
|
+
| Handler outcome | Effect |
|
|
292
327
|
| --------------------------- | ----------------------------------------------------- |
|
|
328
|
+
| JSX / `null` | Normal prerender entry, log OK |
|
|
329
|
+
| `return ctx.passthrough()` | Skip entry, log PASS, continue (passthrough routes) |
|
|
293
330
|
| `throw new Skip("reason")` | Skip entry, log SKIP, continue with remaining entries |
|
|
294
331
|
| `throw new Error("reason")` | Log FAIL, stop ALL pre-rendering, fail the build |
|
|
295
332
|
|
|
@@ -303,21 +340,108 @@ The build produces per-URL timing logs:
|
|
|
303
340
|
```
|
|
304
341
|
[rsc-router] Pre-rendering 12 URL(s) (concurrency: 4)...
|
|
305
342
|
[rsc-router] OK /articles/hello (42ms)
|
|
343
|
+
[rsc-router] PASS /articles/remote-only (5ms) - live fallback
|
|
306
344
|
[rsc-router] SKIP /articles/draft-post (3ms) - Article is a draft
|
|
307
|
-
[rsc-router]
|
|
308
|
-
[rsc-router] Pre-render complete: 10 ok, 1 skipped, 1 failed (1204ms total)
|
|
345
|
+
[rsc-router] Pre-render complete: 11 done, 1 skipped (1204ms total)
|
|
309
346
|
|
|
310
347
|
[rsc-router] Rendering 3 static handler(s)...
|
|
311
348
|
[rsc-router] OK DocsLayout (28ms)
|
|
312
349
|
[rsc-router] SKIP TocSidebar (1ms) - Not ready
|
|
313
|
-
[rsc-router] Static render complete: 2
|
|
350
|
+
[rsc-router] Static render complete: 2 done, 1 skipped (120ms total)
|
|
314
351
|
```
|
|
315
352
|
|
|
353
|
+
A `FAIL` line is logged per-URL when a handler throws a non-Skip error. The
|
|
354
|
+
error is re-thrown immediately, so no summary line is printed — the build
|
|
355
|
+
stops at the first failure.
|
|
356
|
+
|
|
316
357
|
### Dev mode behavior
|
|
317
358
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
359
|
+
**Node.js dev server** — `Skip` behaves like a regular runtime error because
|
|
360
|
+
the handler runs live with `ctx.build === false`.
|
|
361
|
+
|
|
362
|
+
**Non-Node runtimes using `/__rsc_prerender`** — `Skip` participates in the
|
|
363
|
+
on-demand prerender path, so build-style skip logic does run for that request.
|
|
364
|
+
The dev prerender endpoint treats it like a prerender miss and the request
|
|
365
|
+
falls back according to normal dev/runtime behavior.
|
|
366
|
+
|
|
367
|
+
## Per-Param Passthrough with ctx.passthrough()
|
|
368
|
+
|
|
369
|
+
On `{ passthrough: true }` routes, a handler can return `ctx.passthrough()`
|
|
370
|
+
to skip writing a local prerender artifact for a specific param set. At
|
|
371
|
+
runtime, the missing entry falls through to the live handler.
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
export const BlogPost = Prerender(
|
|
375
|
+
async () => [{ slug: "a" }, { slug: "b" }, { slug: "c" }],
|
|
376
|
+
async (ctx) => {
|
|
377
|
+
const post = await getPost(ctx.params.slug);
|
|
378
|
+
if (!post) return ctx.passthrough();
|
|
379
|
+
return <article>{post.content}</article>;
|
|
380
|
+
},
|
|
381
|
+
{ passthrough: true },
|
|
382
|
+
);
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Semantics
|
|
386
|
+
|
|
387
|
+
- JSX or `null` from the handler produces a normal prerender entry.
|
|
388
|
+
- `ctx.passthrough()` returns a sentinel that signals "no local artifact".
|
|
389
|
+
The build skips the manifest entry for that param set.
|
|
390
|
+
- `ctx.passthrough()` on a non-passthrough route throws an invariant error.
|
|
391
|
+
- `ctx.passthrough()` at runtime (`ctx.build === false`) also throws.
|
|
392
|
+
It is a build-time-only control flow.
|
|
393
|
+
- `getParams()` still enumerates the param set; the handler decides per-param
|
|
394
|
+
whether to produce an artifact or defer to runtime.
|
|
395
|
+
|
|
396
|
+
### Difference from Skip
|
|
397
|
+
|
|
398
|
+
| Mechanism | Effect on build | Runtime behavior |
|
|
399
|
+
| ------------------- | ---------------------- | ---------------------------------------------------- |
|
|
400
|
+
| `throw new Skip()` | Skips entry, logs SKIP | No artifact, no live fallback unless passthrough |
|
|
401
|
+
| `ctx.passthrough()` | Skips entry, logs PASS | Always defers to live handler (requires passthrough) |
|
|
402
|
+
|
|
403
|
+
Use `ctx.passthrough()` when you want the handler to run live at request time
|
|
404
|
+
for specific params. Use `Skip` when you want to exclude params entirely.
|
|
405
|
+
|
|
406
|
+
### Use case: Remote storage
|
|
407
|
+
|
|
408
|
+
`ctx.passthrough()` enables a pattern where build-time data is stored in a
|
|
409
|
+
remote KV store instead of the local prerender manifest. The handler
|
|
410
|
+
pre-computes data during `getParams`, pushes it to KV, then calls
|
|
411
|
+
`ctx.passthrough()` so the local build skips the artifact. At runtime,
|
|
412
|
+
the live handler reads from KV:
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
export const Product = Prerender(
|
|
416
|
+
async () => {
|
|
417
|
+
const products = await db.getFeaturedProducts();
|
|
418
|
+
// Pre-compute and store in remote KV during build
|
|
419
|
+
for (const p of products) {
|
|
420
|
+
await kv.put(`product:${p.id}`, await renderProduct(p));
|
|
421
|
+
}
|
|
422
|
+
return products.map(p => ({ id: p.id }));
|
|
423
|
+
},
|
|
424
|
+
async (ctx) => {
|
|
425
|
+
// At build time: skip local artifact, data is in KV
|
|
426
|
+
if (ctx.build) return ctx.passthrough();
|
|
427
|
+
// At runtime: read from KV
|
|
428
|
+
const cached = await kv.get(`product:${ctx.params.id}`);
|
|
429
|
+
if (cached) return cached;
|
|
430
|
+
return <Product data={await db.getProduct(ctx.params.id)} />;
|
|
431
|
+
},
|
|
432
|
+
{ passthrough: true },
|
|
433
|
+
);
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Build logs
|
|
437
|
+
|
|
438
|
+
Passthrough entries are logged distinctly:
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
[rsc-router] OK /blog/a (42ms)
|
|
442
|
+
[rsc-router] PASS /blog/b (3ms) - live fallback
|
|
443
|
+
[rsc-router] OK /blog/c (38ms)
|
|
444
|
+
```
|
|
321
445
|
|
|
322
446
|
## Edge Cases and Constraints
|
|
323
447
|
|
|
@@ -471,3 +595,49 @@ At runtime, the cache-lookup middleware uses these flags:
|
|
|
471
595
|
- `pr + hit` -- serve pre-rendered Flight payload
|
|
472
596
|
- `pr + pt + miss` -- fall through to live handler (handler kept in bundle)
|
|
473
597
|
- `pr + miss` (no pt) -- fall through (handler stubbed, no live render)
|
|
598
|
+
|
|
599
|
+
## Contributor Checklist
|
|
600
|
+
|
|
601
|
+
Before changing prerender behavior, read these docs and run these tests.
|
|
602
|
+
|
|
603
|
+
### Docs to re-read
|
|
604
|
+
|
|
605
|
+
- [Prerender API design](../../docs/prerender-api-design.md) -- canonical
|
|
606
|
+
architecture: build-time flow, runtime flow, storage, passthrough, intercept
|
|
607
|
+
- [Execution model](../../docs/internal/execution-model.md) -- handler-first
|
|
608
|
+
ordering, middleware scope, context visibility rules
|
|
609
|
+
- [Semantic change checklist](../../docs/internal/semantic-change-checklist.md)
|
|
610
|
+
-- gate for any change to execution semantics
|
|
611
|
+
|
|
612
|
+
### Tests to run
|
|
613
|
+
|
|
614
|
+
```bash
|
|
615
|
+
# Core prerender e2e (passthrough, eviction, loaders, sub-use, intercept)
|
|
616
|
+
pnpm --filter @rangojs/router exec playwright test prerender
|
|
617
|
+
|
|
618
|
+
# Prerender-specific unit test
|
|
619
|
+
pnpm --filter @rangojs/router run test:unit -- prerender-passthrough
|
|
620
|
+
|
|
621
|
+
# Semantic matrix (prerender rows cover intercept + ctx propagation)
|
|
622
|
+
pnpm --filter @rangojs/router exec playwright test semantic-matrix
|
|
623
|
+
|
|
624
|
+
# Handler-first (ctx.set/get visibility with prerender handlers)
|
|
625
|
+
pnpm --filter @rangojs/router exec playwright test handler-first
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Dev-only vs build-parity
|
|
629
|
+
|
|
630
|
+
- Prerender e2e tests run against a real production build by default (the
|
|
631
|
+
fixture builds the test app). Dev-mode prerender behavior is tested via
|
|
632
|
+
`/__rsc_prerender` endpoint tests and node.js dev-server fallback.
|
|
633
|
+
- Log-based assertions (build output lines, debug cache logs) are inherently
|
|
634
|
+
dev/build-only and do not need a production counterpart.
|
|
635
|
+
- Behavioral assertions (rendered content, loader freshness, passthrough
|
|
636
|
+
fallback, intercept variant selection) must work in the production build.
|
|
637
|
+
|
|
638
|
+
## Maintenance References
|
|
639
|
+
|
|
640
|
+
- [Stability next steps plan](../../docs/internal/stability-next-steps-plan.md)
|
|
641
|
+
-- completed parity and cleanup pass (reference for decisions made)
|
|
642
|
+
- [Test quality baseline](../../docs/internal/test-quality-baseline.md) --
|
|
643
|
+
measured test inventory, sleep debt, production coverage gaps
|
package/skills/route/SKILL.md
CHANGED
|
@@ -181,6 +181,47 @@ 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
|
+
### Revalidation Contracts for Handler Data
|
|
185
|
+
|
|
186
|
+
Handler-first guarantees apply within a single full render pass. For partial
|
|
187
|
+
action revalidation, define named revalidation contracts and reuse them on both
|
|
188
|
+
the producer route and the consumer child segments.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// revalidation-contracts.ts
|
|
192
|
+
export const revalidateCheckoutData = ({ actionId }) =>
|
|
193
|
+
actionId?.includes("src/actions/checkout.ts#") ?? false;
|
|
194
|
+
|
|
195
|
+
path("/checkout", CheckoutPage, { name: "checkout" }, () => [
|
|
196
|
+
revalidate(revalidateCheckoutData), // producer (route handler) reruns
|
|
197
|
+
layout(CheckoutLayout, () => [
|
|
198
|
+
revalidate(revalidateCheckoutData), // consumer reruns
|
|
199
|
+
parallel({ "@summary": CheckoutSummary }, () => [
|
|
200
|
+
revalidate(revalidateCheckoutData),
|
|
201
|
+
]),
|
|
202
|
+
]),
|
|
203
|
+
]);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
If children depend on multiple upstream domains, compose multiple contracts on
|
|
207
|
+
the same segment (`revalidateAuthData`, `revalidateCheckoutData`, and so on).
|
|
208
|
+
|
|
209
|
+
For cleaner route trees, expose contract helpers and spread them:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { revalidate } from "@rangojs/router";
|
|
213
|
+
|
|
214
|
+
export const revalidateCheckout = () => [revalidate(revalidateCheckoutData)];
|
|
215
|
+
|
|
216
|
+
path("/checkout", CheckoutPage, { name: "checkout" }, () => [
|
|
217
|
+
revalidateCheckout(),
|
|
218
|
+
layout(CheckoutLayout, () => [revalidateCheckout()]),
|
|
219
|
+
]);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
For scope/revalidation guarantees and non-guarantees, see:
|
|
223
|
+
[docs/execution-model.md](../../docs/internal/execution-model.md)
|
|
224
|
+
|
|
184
225
|
## Redirects
|
|
185
226
|
|
|
186
227
|
### Basic redirect
|
|
@@ -242,7 +283,7 @@ Attach location state to any server response (not just redirects):
|
|
|
242
283
|
|
|
243
284
|
```typescript
|
|
244
285
|
path("/dashboard", (ctx) => {
|
|
245
|
-
ctx.setLocationState(
|
|
286
|
+
ctx.setLocationState(ServerInfo({ data: "welcome" }));
|
|
246
287
|
return <Dashboard />;
|
|
247
288
|
}, { name: "dashboard" })
|
|
248
289
|
```
|
|
@@ -99,6 +99,12 @@ interface RSCRouterOptions<TEnv> {
|
|
|
99
99
|
// Theme configuration
|
|
100
100
|
theme?: ThemeConfig | true;
|
|
101
101
|
|
|
102
|
+
// SSR options (streaming policy)
|
|
103
|
+
ssr?: SSROptions<TEnv>;
|
|
104
|
+
|
|
105
|
+
// Telemetry sink for structured lifecycle events
|
|
106
|
+
telemetry?: TelemetrySink;
|
|
107
|
+
|
|
102
108
|
// Connection warmup (default: true)
|
|
103
109
|
warmup?: boolean;
|
|
104
110
|
|
|
@@ -355,3 +361,74 @@ const router = createRouter({
|
|
|
355
361
|
|
|
356
362
|
The warmup request is relative to the current page path, so it works correctly
|
|
357
363
|
with subpath deployments (reverse proxy, base path).
|
|
364
|
+
|
|
365
|
+
## Telemetry
|
|
366
|
+
|
|
367
|
+
The router emits structured lifecycle events through a pluggable telemetry sink.
|
|
368
|
+
Zero overhead when not configured.
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// Console sink for development
|
|
372
|
+
import { createRouter, createConsoleSink } from "@rangojs/router";
|
|
373
|
+
|
|
374
|
+
const router = createRouter({
|
|
375
|
+
document: Document,
|
|
376
|
+
urls: urlpatterns,
|
|
377
|
+
telemetry: createConsoleSink(),
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// OpenTelemetry for production
|
|
383
|
+
import { createRouter, createOTelSink } from "@rangojs/router";
|
|
384
|
+
import { trace } from "@opentelemetry/api";
|
|
385
|
+
|
|
386
|
+
const router = createRouter({
|
|
387
|
+
document: Document,
|
|
388
|
+
urls: urlpatterns,
|
|
389
|
+
telemetry: createOTelSink(trace.getTracer("my-app")),
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
// Custom sink
|
|
395
|
+
const router = createRouter({
|
|
396
|
+
telemetry: {
|
|
397
|
+
emit(event) {
|
|
398
|
+
// Send to any observability backend
|
|
399
|
+
myTracer.record(event);
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Events emitted: `request.start/end/error`, `loader.start/end/error`,
|
|
406
|
+
`handler.error`, `cache.decision`, `revalidation.decision`.
|
|
407
|
+
|
|
408
|
+
## SSR Streaming Policy
|
|
409
|
+
|
|
410
|
+
Control whether HTML SSR responses stream progressively or wait for all content:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import { createRouter, type SSRStreamMode } from "@rangojs/router";
|
|
414
|
+
|
|
415
|
+
const router = createRouter({
|
|
416
|
+
ssr: {
|
|
417
|
+
resolveStreaming: ({ request }) => {
|
|
418
|
+
const ua = request.headers.get("user-agent") ?? "";
|
|
419
|
+
// Bots that can't process streamed HTML get a fully resolved page
|
|
420
|
+
if (/Googlebot|bingbot/i.test(ua)) return "allReady";
|
|
421
|
+
return "stream";
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`SSRStreamMode` is `"stream" | "allReady"`:
|
|
428
|
+
|
|
429
|
+
- `"stream"` (default) — flush HTML as React renders. Suspense fallbacks appear first, then resolved content streams in. Best for real users (fastest TTFB).
|
|
430
|
+
- `"allReady"` — await `stream.allReady` before flushing. The full page arrives in one shot. Use for bots that cannot execute JavaScript or process chunked HTML.
|
|
431
|
+
|
|
432
|
+
The resolver receives `{ request, env, url }` and may be sync or async. It only runs on HTML SSR paths — RSC partials, `__rsc` requests, and response routes are unaffected.
|
|
433
|
+
|
|
434
|
+
When `resolveStreaming` is not configured, the default is `"stream"`.
|
package/src/__internal.ts
CHANGED
|
@@ -160,7 +160,7 @@ export type {
|
|
|
160
160
|
/**
|
|
161
161
|
* @internal
|
|
162
162
|
* Internal handler context with additional props for router internals.
|
|
163
|
-
* Includes `
|
|
163
|
+
* Includes `_currentSegmentId` and `_responseType`.
|
|
164
164
|
*/
|
|
165
165
|
export type { InternalHandlerContext } from "./types.js";
|
|
166
166
|
|
package/src/bin/rango.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
writePerModuleRouteTypesForFile,
|
|
6
6
|
writeCombinedRouteTypes,
|
|
7
7
|
detectUnresolvableIncludes,
|
|
8
|
+
detectUnresolvableIncludesForUrlsFile,
|
|
8
9
|
type UnresolvableInclude,
|
|
9
10
|
} from "../build/generate-route-types.ts";
|
|
10
11
|
|
|
@@ -131,22 +132,18 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
|
|
|
131
132
|
process.exit(0);
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
// Phase 1: Classify files
|
|
134
136
|
const routerFiles: string[] = [];
|
|
137
|
+
const urlsFiles: string[] = [];
|
|
135
138
|
|
|
136
139
|
for (const filePath of files) {
|
|
137
140
|
try {
|
|
138
141
|
const source = readFileSync(filePath, "utf-8");
|
|
139
|
-
|
|
140
|
-
// Detect file type and generate accordingly
|
|
141
|
-
const isRouter = /\bcreateRouter\s*[<(]/.test(source);
|
|
142
|
-
const isUrls = source.includes("urls(");
|
|
143
|
-
|
|
144
|
-
if (isRouter) {
|
|
142
|
+
if (/\bcreateRouter\s*[<(]/.test(source)) {
|
|
145
143
|
routerFiles.push(filePath);
|
|
146
144
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
writePerModuleRouteTypesForFile(filePath);
|
|
145
|
+
if (source.includes("urls(")) {
|
|
146
|
+
urlsFiles.push(filePath);
|
|
150
147
|
}
|
|
151
148
|
} catch (err) {
|
|
152
149
|
console.warn(
|
|
@@ -155,9 +152,10 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
|
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
//
|
|
155
|
+
// Phase 2: Collect diagnostics from all files BEFORE writing anything
|
|
159
156
|
const allDiagnostics: Array<UnresolvableInclude & { routerFile: string }> =
|
|
160
157
|
[];
|
|
158
|
+
|
|
161
159
|
for (const routerFile of routerFiles) {
|
|
162
160
|
const diagnostics = detectUnresolvableIncludes(routerFile);
|
|
163
161
|
for (const d of diagnostics) {
|
|
@@ -165,10 +163,29 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
|
|
|
165
163
|
}
|
|
166
164
|
}
|
|
167
165
|
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
// Also check standalone urls files not covered by router-level detection
|
|
167
|
+
const routerFileSet = new Set(routerFiles);
|
|
168
|
+
for (const urlsFile of urlsFiles) {
|
|
169
|
+
if (routerFileSet.has(urlsFile)) continue;
|
|
170
|
+
const diagnostics = detectUnresolvableIncludesForUrlsFile(urlsFile);
|
|
171
|
+
for (const d of diagnostics) {
|
|
172
|
+
allDiagnostics.push({ ...d, routerFile: urlsFile });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Deduplicate diagnostics (router and urls detection may find the same issue)
|
|
177
|
+
const seen = new Set<string>();
|
|
178
|
+
const uniqueDiagnostics = allDiagnostics.filter((d) => {
|
|
179
|
+
const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}`;
|
|
180
|
+
if (seen.has(key)) return false;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (uniqueDiagnostics.length > 0 && mode === "default") {
|
|
186
|
+
// Hard error: no files written
|
|
170
187
|
console.error("\n[rango] Unresolvable includes detected:\n");
|
|
171
|
-
formatDiagnostics(
|
|
188
|
+
formatDiagnostics(uniqueDiagnostics);
|
|
172
189
|
console.error(
|
|
173
190
|
"\nThe static parser cannot resolve these includes because they use " +
|
|
174
191
|
"factory functions or dynamic expressions.\n\n" +
|
|
@@ -179,16 +196,20 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
|
|
|
179
196
|
process.exit(1);
|
|
180
197
|
}
|
|
181
198
|
|
|
182
|
-
if (
|
|
199
|
+
if (uniqueDiagnostics.length > 0 && mode === "static") {
|
|
183
200
|
// Warning: partial output accepted
|
|
184
201
|
console.warn(
|
|
185
202
|
"\n[rango] Warning: partial output (unresolvable includes):\n",
|
|
186
203
|
);
|
|
187
|
-
formatDiagnostics(
|
|
204
|
+
formatDiagnostics(uniqueDiagnostics);
|
|
188
205
|
console.warn("");
|
|
189
206
|
}
|
|
190
207
|
|
|
191
|
-
//
|
|
208
|
+
// Phase 3: Write all outputs (only reached if diagnostics pass or --static)
|
|
209
|
+
for (const urlsFile of urlsFiles) {
|
|
210
|
+
writePerModuleRouteTypesForFile(urlsFile);
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
for (const routerFile of routerFiles) {
|
|
193
214
|
const projectRoot = findProjectRoot(routerFile);
|
|
194
215
|
writeCombinedRouteTypes(projectRoot, [routerFile]);
|
|
@@ -257,10 +278,8 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
|
|
|
257
278
|
process.exit(1);
|
|
258
279
|
}
|
|
259
280
|
|
|
260
|
-
// Use a single project root for all routers (find from the first entry)
|
|
261
|
-
const projectRoot = findProjectRoot(routerEntries[0]);
|
|
262
|
-
|
|
263
281
|
for (const entry of routerEntries) {
|
|
282
|
+
const projectRoot = findProjectRoot(entry);
|
|
264
283
|
const result = await discoverAndWriteRouteTypes({
|
|
265
284
|
root: projectRoot,
|
|
266
285
|
configFile,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyActionResponse,
|
|
3
|
+
type ActionScenario,
|
|
4
|
+
} from "./action-response-classifier.js";
|
|
5
|
+
import type { ActionEntry } from "./event-controller.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plain data inputs for classifying a post-reconciliation action outcome.
|
|
9
|
+
* No browser objects or controller references — all values are snapshots.
|
|
10
|
+
*/
|
|
11
|
+
export interface ActionOutcomeInput {
|
|
12
|
+
/** This action's unique instance ID */
|
|
13
|
+
handleId: string;
|
|
14
|
+
/** All in-flight action entries (snapshot from event controller) */
|
|
15
|
+
inflightActions: Map<string, ActionEntry>;
|
|
16
|
+
/** Whether any concurrent actions occurred (controller-level shared flag) */
|
|
17
|
+
hadAnyConcurrentActions: boolean;
|
|
18
|
+
/** Segments revalidated by concurrent actions (from tracking set) */
|
|
19
|
+
revalidatedSegments: Set<string>;
|
|
20
|
+
/** window.location.pathname captured at action start */
|
|
21
|
+
actionStartPathname: string;
|
|
22
|
+
/** window.location.pathname at classification time */
|
|
23
|
+
currentPathname: string;
|
|
24
|
+
/** window.history.state?.key captured at action start */
|
|
25
|
+
actionStartLocationKey: string | undefined;
|
|
26
|
+
/** window.history.state?.key at classification time */
|
|
27
|
+
currentLocationKey: string | undefined;
|
|
28
|
+
/** Number of segments after reconciliation */
|
|
29
|
+
reconciledSegmentCount: number;
|
|
30
|
+
/** Number of matched segment IDs from server */
|
|
31
|
+
matchedCount: number;
|
|
32
|
+
/** Current intercept source URL (null when not on intercept route) */
|
|
33
|
+
currentInterceptSource: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute consolidation segments from concurrent action state.
|
|
38
|
+
*
|
|
39
|
+
* Returns segment IDs that need re-fetching when concurrent actions
|
|
40
|
+
* have each revalidated different parts of the tree, or null if
|
|
41
|
+
* consolidation is not needed.
|
|
42
|
+
*/
|
|
43
|
+
function computeConsolidationSegments(
|
|
44
|
+
input: ActionOutcomeInput,
|
|
45
|
+
): string[] | null {
|
|
46
|
+
if (!input.hadAnyConcurrentActions) return null;
|
|
47
|
+
if (input.revalidatedSegments.size === 0) return null;
|
|
48
|
+
|
|
49
|
+
// Can't consolidate while any action is still waiting for a server response
|
|
50
|
+
const stillFetchingCount = [...input.inflightActions.values()].filter(
|
|
51
|
+
(a) => a.phase === "fetching",
|
|
52
|
+
).length;
|
|
53
|
+
if (stillFetchingCount > 0) return null;
|
|
54
|
+
|
|
55
|
+
return Array.from(input.revalidatedSegments);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Count other actions still in "fetching" phase (excluding this handle).
|
|
60
|
+
*/
|
|
61
|
+
function countOtherFetchingActions(input: ActionOutcomeInput): number {
|
|
62
|
+
let count = 0;
|
|
63
|
+
for (const [, a] of input.inflightActions) {
|
|
64
|
+
if (a.phase === "fetching" && a.id !== input.handleId) {
|
|
65
|
+
count++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return count;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify a post-reconciliation action outcome into one of 5 scenarios.
|
|
73
|
+
*
|
|
74
|
+
* This is the single entry point for post-action decision logic.
|
|
75
|
+
* It gathers consolidation and concurrency data from the plain inputs,
|
|
76
|
+
* then delegates to the pure classifyActionResponse function.
|
|
77
|
+
*
|
|
78
|
+
* The server-action-bridge calls this after reconciliation to decide
|
|
79
|
+
* whether to render, skip, consolidate, or refetch.
|
|
80
|
+
*/
|
|
81
|
+
export function classifyActionOutcome(
|
|
82
|
+
input: ActionOutcomeInput,
|
|
83
|
+
): ActionScenario {
|
|
84
|
+
return classifyActionResponse({
|
|
85
|
+
actionStartPathname: input.actionStartPathname,
|
|
86
|
+
currentPathname: input.currentPathname,
|
|
87
|
+
actionStartLocationKey: input.actionStartLocationKey,
|
|
88
|
+
currentLocationKey: input.currentLocationKey,
|
|
89
|
+
reconciledSegmentCount: input.reconciledSegmentCount,
|
|
90
|
+
matchedCount: input.matchedCount,
|
|
91
|
+
currentInterceptSource: input.currentInterceptSource,
|
|
92
|
+
consolidationSegments: computeConsolidationSegments(input),
|
|
93
|
+
otherFetchingActionCount: countOtherFetchingActions(input),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type { ActionScenario };
|