@rangojs/router 0.0.0-experimental.84 → 0.0.0-experimental.86
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 +50 -20
- package/dist/vite/index.js +19 -9
- package/package.json +14 -15
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +4 -2
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +35 -2
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +51 -2
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +20 -1
- package/src/browser/prefetch/cache.ts +16 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/NavigationProvider.tsx +44 -9
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/types.ts +13 -0
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/outlet-context.ts +1 -1
- package/src/reverse.ts +3 -2
- package/src/router/handler-context.ts +20 -3
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/match-api.ts +3 -3
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +18 -3
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +2 -1
- package/src/rsc/response-route-handler.ts +3 -0
- package/src/server/request-context.ts +10 -42
- package/src/types/handler-context.ts +2 -34
- package/src/types/loader-types.ts +2 -6
- package/src/types/request-scope.ts +126 -0
- package/src/urls/response-types.ts +2 -10
- package/src/vite/rango.ts +23 -7
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +1 -1
package/README.md
CHANGED
|
@@ -161,13 +161,18 @@ const urlpatterns = urls(({ path }) => [
|
|
|
161
161
|
]);
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
Use `reverse()` as the default way to link to routes:
|
|
164
|
+
Use `ctx.reverse()` from handler context as the default way to link to routes from server code:
|
|
165
165
|
|
|
166
166
|
```tsx
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
const ProductPage: Handler<"product"> = (ctx) => {
|
|
168
|
+
const url = ctx.reverse("product", { slug: "widget" }); // "/product/widget"
|
|
169
|
+
const searchUrl = ctx.reverse("search", undefined, { q: "rsc" }); // "/search?q=rsc"
|
|
170
|
+
return <Link to={url}>Widget</Link>;
|
|
171
|
+
};
|
|
169
172
|
```
|
|
170
173
|
|
|
174
|
+
`router.reverse()` (exported from the router module) is the same function without a handler context, useful in scripts or tests. In request code, prefer `ctx.reverse()` — it auto-fills mount params from the current match.
|
|
175
|
+
|
|
171
176
|
### Composable URL Modules
|
|
172
177
|
|
|
173
178
|
Local route names compose cleanly with `include(..., { name })`:
|
|
@@ -479,39 +484,64 @@ const urlpatterns = urls(({ path, loader }) => [
|
|
|
479
484
|
|
|
480
485
|
## Navigation & Links
|
|
481
486
|
|
|
482
|
-
### Named Routes with `reverse()` (Server
|
|
487
|
+
### Named Routes with `ctx.reverse()` (Server)
|
|
483
488
|
|
|
484
|
-
In server components, use `reverse()` to generate URLs by route name:
|
|
489
|
+
In server components and handlers, use `ctx.reverse()` to generate URLs by route name. This is the default — it is typed, auto-fills mount params from the current match, and resolves both local (`.name`) and absolute (`name.sub`) names:
|
|
485
490
|
|
|
486
491
|
```tsx
|
|
487
492
|
import { Link } from "@rangojs/router/client";
|
|
493
|
+
import type { Handler } from "@rangojs/router";
|
|
494
|
+
|
|
495
|
+
const BlogPostPage: Handler<"blogPost"> = (ctx) => {
|
|
496
|
+
const backUrl = ctx.reverse("blog");
|
|
497
|
+
return <Link to={backUrl}>Back to blog</Link>;
|
|
498
|
+
};
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`reverse()` is type-safe — route names and required params are checked at compile time. Included routes use dotted names: `ctx.reverse("api.health")`.
|
|
502
|
+
|
|
503
|
+
For scripts, tests, or other code without a handler context, import the router-level `reverse`:
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
488
506
|
import { reverse } from "./router";
|
|
507
|
+
reverse("blogPost", { slug: "my-post" });
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Client Components
|
|
511
|
+
|
|
512
|
+
**`reverse()` is server-only.** It depends on the route manifest and handler context — neither is available in the browser bundle. Client components receive URLs as props, loader data, or server-action return values:
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
// server
|
|
516
|
+
function BlogIndex(ctx: HandlerContext) {
|
|
517
|
+
return (
|
|
518
|
+
<Nav
|
|
519
|
+
home={ctx.reverse("home")}
|
|
520
|
+
post={ctx.reverse("blogPost", { slug: "my-post" })}
|
|
521
|
+
/>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
```tsx
|
|
527
|
+
"use client";
|
|
528
|
+
import { Link } from "@rangojs/router/client";
|
|
489
529
|
|
|
490
|
-
function
|
|
530
|
+
export function Nav({ home, post }: { home: string; post: string }) {
|
|
491
531
|
return (
|
|
492
532
|
<nav>
|
|
493
|
-
<Link to={
|
|
494
|
-
<Link to={
|
|
495
|
-
<Link to={reverse("about")}>About</Link>
|
|
533
|
+
<Link to={home}>Home</Link>
|
|
534
|
+
<Link to={post}>My Post</Link>
|
|
496
535
|
</nav>
|
|
497
536
|
);
|
|
498
537
|
}
|
|
499
538
|
```
|
|
500
539
|
|
|
501
|
-
`
|
|
502
|
-
|
|
503
|
-
Handlers also have `ctx.reverse()` directly on the context:
|
|
504
|
-
|
|
505
|
-
```tsx
|
|
506
|
-
const BlogPostPage: Handler<"blogPost"> = (ctx) => {
|
|
507
|
-
const backUrl = ctx.reverse("blog");
|
|
508
|
-
return <Link to={backUrl}>Back to blog</Link>;
|
|
509
|
-
};
|
|
510
|
-
```
|
|
540
|
+
For client-side navigation to static paths (no named-route lookup), use `href()` — see below. For URLs tied to named routes, always generate on the server and pass the string in.
|
|
511
541
|
|
|
512
542
|
### `href()` for Path Validation (Client Components)
|
|
513
543
|
|
|
514
|
-
In client components, use `href()` for compile-time path validation:
|
|
544
|
+
In client components, use `href()` for compile-time path validation on static path strings:
|
|
515
545
|
|
|
516
546
|
```tsx
|
|
517
547
|
"use client";
|
package/dist/vite/index.js
CHANGED
|
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
|
|
|
1864
1864
|
// package.json
|
|
1865
1865
|
var package_default = {
|
|
1866
1866
|
name: "@rangojs/router",
|
|
1867
|
-
version: "0.0.0-experimental.
|
|
1867
|
+
version: "0.0.0-experimental.86",
|
|
1868
1868
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1869
1869
|
keywords: [
|
|
1870
1870
|
"react",
|
|
@@ -1999,7 +1999,7 @@ var package_default = {
|
|
|
1999
1999
|
scripts: {
|
|
2000
2000
|
build: "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
2001
2001
|
prepublishOnly: "pnpm build",
|
|
2002
|
-
typecheck: "tsc --noEmit",
|
|
2002
|
+
typecheck: "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
|
|
2003
2003
|
test: "playwright test",
|
|
2004
2004
|
"test:ui": "playwright test --ui",
|
|
2005
2005
|
"test:unit": "vitest run",
|
|
@@ -5409,6 +5409,8 @@ async function rango(options) {
|
|
|
5409
5409
|
// cjs-to-esm transform can patch the real file.
|
|
5410
5410
|
"@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
|
|
5411
5411
|
];
|
|
5412
|
+
const pkg = getPublishedPackageName();
|
|
5413
|
+
const nested = (spec) => `${pkg} > ${spec}`;
|
|
5412
5414
|
const routerRef = { path: void 0 };
|
|
5413
5415
|
const prerenderEnabled = true;
|
|
5414
5416
|
if (preset === "cloudflare") {
|
|
@@ -5446,7 +5448,7 @@ async function rango(options) {
|
|
|
5446
5448
|
// Pre-bundle rsc-html-stream to prevent discovery during first request
|
|
5447
5449
|
// Exclude rsc-router modules to ensure same Context instance
|
|
5448
5450
|
optimizeDeps: {
|
|
5449
|
-
include: ["rsc-html-stream/client"],
|
|
5451
|
+
include: [nested("rsc-html-stream/client")],
|
|
5450
5452
|
exclude: excludeDeps,
|
|
5451
5453
|
esbuildOptions: sharedEsbuildOptions
|
|
5452
5454
|
}
|
|
@@ -5471,8 +5473,10 @@ async function rango(options) {
|
|
|
5471
5473
|
"react-dom/static.edge",
|
|
5472
5474
|
"react/jsx-runtime",
|
|
5473
5475
|
"react/jsx-dev-runtime",
|
|
5474
|
-
"rsc-html-stream/server",
|
|
5475
|
-
|
|
5476
|
+
nested("rsc-html-stream/server"),
|
|
5477
|
+
nested(
|
|
5478
|
+
"@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
|
|
5479
|
+
)
|
|
5476
5480
|
],
|
|
5477
5481
|
exclude: excludeDeps,
|
|
5478
5482
|
esbuildOptions: sharedEsbuildOptions
|
|
@@ -5487,7 +5491,9 @@ async function rango(options) {
|
|
|
5487
5491
|
"react",
|
|
5488
5492
|
"react/jsx-runtime",
|
|
5489
5493
|
"react/jsx-dev-runtime",
|
|
5490
|
-
|
|
5494
|
+
nested(
|
|
5495
|
+
"@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
|
|
5496
|
+
)
|
|
5491
5497
|
],
|
|
5492
5498
|
exclude: excludeDeps,
|
|
5493
5499
|
esbuildOptions: sharedEsbuildOptions
|
|
@@ -5568,7 +5574,7 @@ ${list}`);
|
|
|
5568
5574
|
"react-dom",
|
|
5569
5575
|
"react/jsx-runtime",
|
|
5570
5576
|
"react/jsx-dev-runtime",
|
|
5571
|
-
"rsc-html-stream/client"
|
|
5577
|
+
nested("rsc-html-stream/client")
|
|
5572
5578
|
],
|
|
5573
5579
|
exclude: excludeDeps,
|
|
5574
5580
|
esbuildOptions: sharedEsbuildOptions,
|
|
@@ -5585,7 +5591,9 @@ ${list}`);
|
|
|
5585
5591
|
"react-dom/static.edge",
|
|
5586
5592
|
"react/jsx-runtime",
|
|
5587
5593
|
"react/jsx-dev-runtime",
|
|
5588
|
-
|
|
5594
|
+
nested(
|
|
5595
|
+
"@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
|
|
5596
|
+
)
|
|
5589
5597
|
],
|
|
5590
5598
|
exclude: excludeDeps,
|
|
5591
5599
|
esbuildOptions: sharedEsbuildOptions
|
|
@@ -5598,7 +5606,9 @@ ${list}`);
|
|
|
5598
5606
|
"react",
|
|
5599
5607
|
"react/jsx-runtime",
|
|
5600
5608
|
"react/jsx-dev-runtime",
|
|
5601
|
-
|
|
5609
|
+
nested(
|
|
5610
|
+
"@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
|
|
5611
|
+
)
|
|
5602
5612
|
],
|
|
5603
5613
|
esbuildOptions: sharedEsbuildOptions
|
|
5604
5614
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.86",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -132,15 +132,6 @@
|
|
|
132
132
|
"access": "public",
|
|
133
133
|
"tag": "experimental"
|
|
134
134
|
},
|
|
135
|
-
"scripts": {
|
|
136
|
-
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
137
|
-
"prepublishOnly": "pnpm build",
|
|
138
|
-
"typecheck": "tsc --noEmit",
|
|
139
|
-
"test": "playwright test",
|
|
140
|
-
"test:ui": "playwright test --ui",
|
|
141
|
-
"test:unit": "vitest run",
|
|
142
|
-
"test:unit:watch": "vitest"
|
|
143
|
-
},
|
|
144
135
|
"dependencies": {
|
|
145
136
|
"@vitejs/plugin-rsc": "^0.5.23",
|
|
146
137
|
"magic-string": "^0.30.17",
|
|
@@ -150,12 +141,12 @@
|
|
|
150
141
|
"devDependencies": {
|
|
151
142
|
"@playwright/test": "^1.49.1",
|
|
152
143
|
"@types/node": "^24.10.1",
|
|
153
|
-
"@types/react": "
|
|
154
|
-
"@types/react-dom": "
|
|
144
|
+
"@types/react": "^19.2.7",
|
|
145
|
+
"@types/react-dom": "^19.2.3",
|
|
155
146
|
"esbuild": "^0.27.0",
|
|
156
147
|
"jiti": "^2.6.1",
|
|
157
|
-
"react": "
|
|
158
|
-
"react-dom": "
|
|
148
|
+
"react": "^19.2.4",
|
|
149
|
+
"react-dom": "^19.2.4",
|
|
159
150
|
"tinyexec": "^0.3.2",
|
|
160
151
|
"typescript": "^5.3.0",
|
|
161
152
|
"vitest": "^4.0.0"
|
|
@@ -173,5 +164,13 @@
|
|
|
173
164
|
"vite": {
|
|
174
165
|
"optional": true
|
|
175
166
|
}
|
|
167
|
+
},
|
|
168
|
+
"scripts": {
|
|
169
|
+
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
170
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
|
|
171
|
+
"test": "playwright test",
|
|
172
|
+
"test:ui": "playwright test --ui",
|
|
173
|
+
"test:unit": "vitest run",
|
|
174
|
+
"test:unit:watch": "vitest"
|
|
176
175
|
}
|
|
177
|
-
}
|
|
176
|
+
}
|
|
@@ -141,9 +141,11 @@ path("/dashboard", (ctx) => {
|
|
|
141
141
|
breadcrumb({ label: "Dashboard", href: "/dashboard" });
|
|
142
142
|
return <DashboardNav handle={Breadcrumbs} />;
|
|
143
143
|
});
|
|
144
|
+
```
|
|
144
145
|
|
|
146
|
+
```tsx
|
|
145
147
|
// Client component
|
|
146
|
-
|
|
148
|
+
"use client";
|
|
147
149
|
import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
|
|
148
150
|
|
|
149
151
|
function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
|
package/skills/hooks/SKILL.md
CHANGED
|
@@ -298,9 +298,11 @@ path("/dashboard", (ctx) => {
|
|
|
298
298
|
push({ label: "Dashboard", href: "/dashboard" });
|
|
299
299
|
return <DashboardNav handle={Breadcrumbs} />;
|
|
300
300
|
});
|
|
301
|
+
```
|
|
301
302
|
|
|
303
|
+
```tsx
|
|
302
304
|
// Client component — typeof infers the full Handle<T> type
|
|
303
|
-
|
|
305
|
+
"use client";
|
|
304
306
|
import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
|
|
305
307
|
|
|
306
308
|
function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
|
|
@@ -687,7 +689,7 @@ function MountInfo() {
|
|
|
687
689
|
}
|
|
688
690
|
```
|
|
689
691
|
|
|
690
|
-
See `/links` for full URL generation guide
|
|
692
|
+
See `/links` for full URL generation guide. The default server API is `ctx.reverse()`; in client components, receive URLs as props, loader data, or server-action return values — `reverse()` is not available in the browser.
|
|
691
693
|
|
|
692
694
|
## Hook Summary
|
|
693
695
|
|
package/skills/links/SKILL.md
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: links
|
|
3
|
-
description: URL generation with ctx.reverse (server), href (client), useHref (mounted), useMount, and scopedReverse
|
|
4
|
-
argument-hint: [href|useHref|useMount|scopedReverse]
|
|
3
|
+
description: URL generation with ctx.reverse (server default), href (client), useHref (mounted), useMount, and scopedReverse
|
|
4
|
+
argument-hint: [ctx.reverse|href|useHref|useMount|scopedReverse]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Links & URL Generation
|
|
8
8
|
|
|
9
9
|
@rangojs/router provides different href APIs for server and client contexts.
|
|
10
10
|
|
|
11
|
+
**Default server API: `ctx.reverse()`.** Generate URLs from the handler context — it's typed, auto-fills mount params, and resolves local (`.name`) and absolute (`name.sub`) names.
|
|
12
|
+
|
|
13
|
+
**`reverse()` is server-only.** It depends on the route manifest and handler context, neither of which are available in the browser. Client components receive URLs as props, loader data, or server-action return values — they never call `reverse` directly.
|
|
14
|
+
|
|
11
15
|
## Server: ctx.reverse()
|
|
12
16
|
|
|
13
|
-
Available in route handlers via HandlerContext. Resolves named routes using the full route map.
|
|
17
|
+
Available in route handlers via HandlerContext. Resolves named routes using the full route map. This is the default way to generate URLs on the server.
|
|
14
18
|
|
|
15
19
|
```typescript
|
|
16
20
|
import { urls, scopedReverse } from "@rangojs/router";
|
|
@@ -103,7 +107,7 @@ path("/search", (ctx) => {
|
|
|
103
107
|
|
|
104
108
|
### scopedReverse() - type-safe ctx.reverse
|
|
105
109
|
|
|
106
|
-
Wraps `ctx.reverse` with local route type information for autocomplete and validation:
|
|
110
|
+
Wraps `ctx.reverse` with local route type information for autocomplete and validation. Runtime behavior is identical to `ctx.reverse` — `scopedReverse` is a type-only cast. The same dot-prefix rule applies: local names use `.name`, global names use `name.sub`.
|
|
107
111
|
|
|
108
112
|
```typescript
|
|
109
113
|
import { scopedReverse } from "@rangojs/router";
|
|
@@ -111,18 +115,83 @@ import { scopedReverse } from "@rangojs/router";
|
|
|
111
115
|
path("/product/:slug", (ctx) => {
|
|
112
116
|
const reverse = scopedReverse<typeof shopPatterns>(ctx.reverse);
|
|
113
117
|
|
|
114
|
-
reverse("cart"); //
|
|
115
|
-
reverse("product", { slug: "widget" }); //
|
|
116
|
-
reverse("blog.post");
|
|
117
|
-
reverse("/about"); // Path-based always allowed
|
|
118
|
+
reverse(".cart"); // Local name (dot-prefixed) — resolves in include scope
|
|
119
|
+
reverse(".product", { slug: "widget" }); // Local name with params
|
|
120
|
+
reverse("blog.post", { slug: "hi" }); // Global name (dotted) — full route map
|
|
118
121
|
|
|
119
122
|
return <ProductPage slug={ctx.params.slug} />;
|
|
120
123
|
}, { name: "product" })
|
|
121
124
|
```
|
|
122
125
|
|
|
126
|
+
`reverse()` does not accept raw path strings (`"/about"`). For static paths in client components, use `href("/about")`; on the server, look up the route by name.
|
|
127
|
+
|
|
128
|
+
## Client components: receive URLs as props
|
|
129
|
+
|
|
130
|
+
`reverse()` is not available inside `"use client"` modules — there is no handler context and no route manifest in the browser bundle. Generate the URL on the server and hand it to the client component.
|
|
131
|
+
|
|
132
|
+
Three patterns, in order of preference:
|
|
133
|
+
|
|
134
|
+
1. Pass as a prop from a server component:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
// server
|
|
138
|
+
function BlogPostPage(ctx: HandlerContext) {
|
|
139
|
+
return <ShareButton url={ctx.reverse(".post", { slug: ctx.params.slug })} />;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
"use client";
|
|
145
|
+
|
|
146
|
+
export function ShareButton({ url }: { url: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<button onClick={() => navigator.clipboard.writeText(url)}>Share</button>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
2. Return from a loader (attached to the route via the DSL):
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
// server — loaders/nav.ts
|
|
157
|
+
export const NavLoader = createLoader((ctx) => ({
|
|
158
|
+
home: ctx.reverse("home"),
|
|
159
|
+
blog: ctx.reverse("blog.index"),
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// server — urls.tsx: attach the loader so useLoader has data in context
|
|
163
|
+
const urlpatterns = urls(({ path, loader }) => [
|
|
164
|
+
path("/", HomePage, { name: "home" }, () => [loader(NavLoader)]),
|
|
165
|
+
]);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
"use client";
|
|
170
|
+
|
|
171
|
+
function Nav() {
|
|
172
|
+
const { data } = useLoader(NavLoader);
|
|
173
|
+
return <Link to={data.home}>Home</Link>;
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`useLoader()` requires the loader to be attached to an active route. If you need on-demand fetching instead, use `useFetchLoader()`.
|
|
178
|
+
|
|
179
|
+
3. Return from a server action:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
"use server";
|
|
183
|
+
|
|
184
|
+
export async function getProductUrl(slug: string) {
|
|
185
|
+
const ctx = getRequestContext();
|
|
186
|
+
return ctx.reverse("product", { slug });
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
For static path strings (not named routes), client components can use `href()` — see below.
|
|
191
|
+
|
|
123
192
|
## Client: href()
|
|
124
193
|
|
|
125
|
-
Plain function for absolute path-based URLs. No hook needed - works anywhere.
|
|
194
|
+
Plain function for absolute path-based URLs. No hook needed - works anywhere in client components. `href()` validates paths at compile time, but does **not** resolve named routes — for named routes, use one of the patterns above.
|
|
126
195
|
|
|
127
196
|
```typescript
|
|
128
197
|
"use client";
|
|
@@ -187,13 +256,16 @@ function MountInfo() {
|
|
|
187
256
|
|
|
188
257
|
## When to use what
|
|
189
258
|
|
|
190
|
-
| Context | API
|
|
191
|
-
| ---------------- |
|
|
192
|
-
| Server handler | `ctx.reverse("name")`
|
|
193
|
-
| Server handler | `scopedReverse<T>(ctx.reverse)`
|
|
194
|
-
| Client component |
|
|
195
|
-
| Client component | `
|
|
196
|
-
| Client component | `
|
|
259
|
+
| Context | API | Resolves | Use for |
|
|
260
|
+
| ---------------- | -------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- |
|
|
261
|
+
| Server handler | `ctx.reverse("name")` | Named routes (local + absolute) | **Default** server-side URL generation |
|
|
262
|
+
| Server handler | `scopedReverse<T>(ctx.reverse)` | Same, with type safety | Type-safe server URLs |
|
|
263
|
+
| Client component | (URL passed as prop / loader data / action return) | Named routes | Any URL derived from a named route — generate on server, pass in |
|
|
264
|
+
| Client component | `href("/path")` | Absolute paths (static strings) | Static navigation where no named-route lookup is needed |
|
|
265
|
+
| Client component | `useHref()` | Mount-prefixed paths | Local navigation inside `include()` |
|
|
266
|
+
| Client component | `useMount()` | Raw mount path | Custom mount-aware logic |
|
|
267
|
+
|
|
268
|
+
> `reverse()` is server-only. Client components never import or call it — they receive the already-resolved string.
|
|
197
269
|
|
|
198
270
|
## Complete example: mounted module
|
|
199
271
|
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -139,7 +139,29 @@ same memoized result — loaders never run twice per request.
|
|
|
139
139
|
|
|
140
140
|
## Loader Context
|
|
141
141
|
|
|
142
|
-
Loaders receive the same context as route handlers
|
|
142
|
+
Loaders receive the same context shape as route handlers.
|
|
143
|
+
|
|
144
|
+
### Full field surface
|
|
145
|
+
|
|
146
|
+
| Field | Type | Notes |
|
|
147
|
+
| -------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- |
|
|
148
|
+
| `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
|
|
149
|
+
| `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
|
|
150
|
+
| `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
|
|
151
|
+
| `url` | `URL` | Parsed request URL. |
|
|
152
|
+
| `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
|
|
153
|
+
| `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
|
|
154
|
+
| `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
|
|
155
|
+
| `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
|
|
156
|
+
| `get` | `(key \| ContextVar) => value` | Reads variables/context-vars set by middleware. |
|
|
157
|
+
| `use` | `(loader \| handle) => T` | Access another loader's data (Promise) or a handle's collected data (after `await ctx.rendered()`). |
|
|
158
|
+
| `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for non-loader segments before reading handle data. |
|
|
159
|
+
| `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
|
|
160
|
+
| `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
|
|
161
|
+
| `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
|
|
162
|
+
| `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
|
|
163
|
+
|
|
164
|
+
### Example
|
|
143
165
|
|
|
144
166
|
```typescript
|
|
145
167
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
@@ -163,10 +185,21 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
163
185
|
// Variables set by middleware (from RSCRouter.Vars augmentation)
|
|
164
186
|
const user = ctx.get("user");
|
|
165
187
|
|
|
166
|
-
|
|
188
|
+
// Type-checked URLs for payloads. `.name` resolves within the current
|
|
189
|
+
// include() scope; a bare `name` resolves globally. See /route and
|
|
190
|
+
// /typesafety for scope rules and route-name autocomplete.
|
|
191
|
+
const detailUrl = ctx.reverse(".detail", { slug });
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
product: await fetchProduct(slug),
|
|
195
|
+
links: { self: detailUrl },
|
|
196
|
+
};
|
|
167
197
|
});
|
|
168
198
|
```
|
|
169
199
|
|
|
200
|
+
See `/route` for the full handler-context contract (shared with loaders) and
|
|
201
|
+
`/typesafety` for route-name typing that powers `ctx.reverse` autocomplete.
|
|
202
|
+
|
|
170
203
|
### params vs routeParams
|
|
171
204
|
|
|
172
205
|
- `ctx.params` — merged route params + explicit loader params. For fetchable
|
|
@@ -462,9 +462,11 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
462
462
|
});
|
|
463
463
|
|
|
464
464
|
// Built-in Breadcrumbs — or any custom handle created with createHandle()
|
|
465
|
+
```
|
|
465
466
|
|
|
467
|
+
```tsx
|
|
466
468
|
// Client component — typeof infers all generics
|
|
467
|
-
|
|
469
|
+
"use client";
|
|
468
470
|
import { useLoader, useHandle, type Breadcrumbs } from "@rangojs/router/client";
|
|
469
471
|
import type { ProductLoader } from "../loaders";
|
|
470
472
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-shell metadata: the set of per-router fields that describe the
|
|
5
|
+
* "envelope" around the current app's segment tree. These fields are set
|
|
6
|
+
* from the initial RSC payload and must be replaced atomically when the
|
|
7
|
+
* client navigates into a different router (app switch).
|
|
8
|
+
*
|
|
9
|
+
* Intentionally NOT part of the shell (all document-lifetime):
|
|
10
|
+
* - themeConfig / initialTheme: ThemeProvider is mounted above the segment
|
|
11
|
+
* tree and must not remount on smooth transitions.
|
|
12
|
+
* - warmupEnabled: attached to the NavigationProvider's lifetime effect;
|
|
13
|
+
* toggling it mid-session would tear down and restart idle listeners.
|
|
14
|
+
* Also not serialized on every full-render path (e.g. the not-found
|
|
15
|
+
* fallback), so carrying it here would be unreliable.
|
|
16
|
+
* - prefetchCacheTTL: the not-found full-render payload does not serialize
|
|
17
|
+
* it, so a cross-app nav into a 404 would silently erase the setting.
|
|
18
|
+
* Mutable shell fields must be serialized on EVERY full-render path,
|
|
19
|
+
* otherwise absent fields are indistinguishable from "new app has no
|
|
20
|
+
* value" and the old app's value is dropped.
|
|
21
|
+
*
|
|
22
|
+
* A new document navigation (hard reload) applies these fields from the
|
|
23
|
+
* target app's initial payload.
|
|
24
|
+
*/
|
|
25
|
+
export interface AppShell {
|
|
26
|
+
/** Router identity. Used to namespace per-app client state (e.g. the
|
|
27
|
+
* rango-state localStorage key) so sibling apps on the same origin
|
|
28
|
+
* cannot observe each other's cache invalidations. */
|
|
29
|
+
routerId?: string;
|
|
30
|
+
rootLayout?: ComponentType<{ children: ReactNode }>;
|
|
31
|
+
basename?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mutable container for the active app shell. Read-through via `get()` so
|
|
37
|
+
* closures capture the ref, not the shell, and pick up updates at call time.
|
|
38
|
+
*/
|
|
39
|
+
export interface AppShellRef {
|
|
40
|
+
get(): AppShell;
|
|
41
|
+
update(next: AppShell): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createAppShellRef(initial: AppShell): AppShellRef {
|
|
45
|
+
let current = initial;
|
|
46
|
+
return {
|
|
47
|
+
get: () => current,
|
|
48
|
+
update: (next) => {
|
|
49
|
+
current = next;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
7
|
import { setAppVersion } from "./app-version.js";
|
|
8
|
+
import { setRangoStateLocal } from "./rango-state.js";
|
|
9
|
+
import type { AppShell, AppShellRef } from "./app-shell.js";
|
|
8
10
|
import * as React from "react";
|
|
9
11
|
import { startTransition } from "react";
|
|
10
12
|
import {
|
|
@@ -48,8 +50,13 @@ export { createNavigationTransaction };
|
|
|
48
50
|
*/
|
|
49
51
|
export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
|
|
50
52
|
eventController: EventController;
|
|
51
|
-
/** RSC version from initial payload metadata */
|
|
53
|
+
/** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
|
|
52
54
|
version?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Live app-shell ref. When supplied, the bridge reads version/basename
|
|
57
|
+
* from this ref so cross-app navigations propagate correctly.
|
|
58
|
+
*/
|
|
59
|
+
appShellRef?: AppShellRef;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/**
|
|
@@ -68,9 +75,46 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
68
75
|
export function createNavigationBridge(
|
|
69
76
|
config: NavigationBridgeConfigWithController,
|
|
70
77
|
): NavigationBridge {
|
|
71
|
-
const {
|
|
78
|
+
const {
|
|
79
|
+
store,
|
|
80
|
+
client,
|
|
81
|
+
eventController,
|
|
82
|
+
onUpdate,
|
|
83
|
+
renderSegments,
|
|
84
|
+
appShellRef,
|
|
85
|
+
} = config;
|
|
72
86
|
let version = config.version;
|
|
73
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Replace the active app-shell snapshot atomically. Called by the partial
|
|
90
|
+
* updater when a response's routerId indicates the navigation crossed
|
|
91
|
+
* into a different app. Runs the local-only side-effects tied to
|
|
92
|
+
* app-shell fields (app version, rango-state namespace) so the new app
|
|
93
|
+
* owns them after the swap. Theme, warmup, and prefetch TTL are
|
|
94
|
+
* document-lifetime and are NOT touched here.
|
|
95
|
+
*/
|
|
96
|
+
function applyAppShell(next: AppShell): void {
|
|
97
|
+
if (appShellRef) {
|
|
98
|
+
appShellRef.update(next);
|
|
99
|
+
}
|
|
100
|
+
if (next.version !== undefined) {
|
|
101
|
+
version = next.version;
|
|
102
|
+
setAppVersion(next.version);
|
|
103
|
+
// Use the local-only setter — initRangoState writes the shared
|
|
104
|
+
// localStorage key and fires a storage event in other tabs still in
|
|
105
|
+
// the old app. setRangoStateLocal only mutates this tab's in-memory
|
|
106
|
+
// cache and rebinds it to the target app's routerId-scoped key,
|
|
107
|
+
// preserving the "local-only, no broadcast/rotation" contract for
|
|
108
|
+
// smooth app-switch transitions.
|
|
109
|
+
setRangoStateLocal(next.version, next.routerId);
|
|
110
|
+
}
|
|
111
|
+
// Cross-app: prior cache entries belong to a different app's segments.
|
|
112
|
+
// Drop them locally only — do NOT broadcast invalidation or rotate the
|
|
113
|
+
// shared X-Rango-State token, since other tabs still in the old app are
|
|
114
|
+
// unaffected by this tab's transition.
|
|
115
|
+
store.clearHistoryCacheLocal();
|
|
116
|
+
}
|
|
117
|
+
|
|
74
118
|
// Create shared partial updater
|
|
75
119
|
const fetchPartialUpdate = createPartialUpdater({
|
|
76
120
|
store,
|
|
@@ -78,6 +122,7 @@ export function createNavigationBridge(
|
|
|
78
122
|
onUpdate,
|
|
79
123
|
renderSegments,
|
|
80
124
|
getVersion: () => version,
|
|
125
|
+
applyAppShell,
|
|
81
126
|
});
|
|
82
127
|
|
|
83
128
|
return {
|
|
@@ -664,6 +709,10 @@ export function createNavigationBridge(
|
|
|
664
709
|
setAppVersion(newVersion);
|
|
665
710
|
store.clearHistoryCache();
|
|
666
711
|
},
|
|
712
|
+
|
|
713
|
+
updateAppShell(next: AppShell): void {
|
|
714
|
+
applyAppShell(next);
|
|
715
|
+
},
|
|
667
716
|
};
|
|
668
717
|
}
|
|
669
718
|
|