@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.
Files changed (43) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +19 -9
  3. package/package.json +14 -15
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/hooks/SKILL.md +4 -2
  6. package/skills/links/SKILL.md +88 -16
  7. package/skills/loader/SKILL.md +35 -2
  8. package/skills/typesafety/SKILL.md +3 -1
  9. package/src/browser/app-shell.ts +52 -0
  10. package/src/browser/navigation-bridge.ts +51 -2
  11. package/src/browser/navigation-store.ts +25 -1
  12. package/src/browser/partial-update.ts +20 -1
  13. package/src/browser/prefetch/cache.ts +16 -0
  14. package/src/browser/rango-state.ts +53 -13
  15. package/src/browser/react/NavigationProvider.tsx +44 -9
  16. package/src/browser/react/use-router.ts +8 -1
  17. package/src/browser/rsc-router.tsx +34 -6
  18. package/src/browser/types.ts +13 -0
  19. package/src/cache/cf/cf-cache-store.ts +5 -7
  20. package/src/index.rsc.ts +3 -0
  21. package/src/index.ts +3 -0
  22. package/src/outlet-context.ts +1 -1
  23. package/src/reverse.ts +3 -2
  24. package/src/router/handler-context.ts +20 -3
  25. package/src/router/lazy-includes.ts +1 -1
  26. package/src/router/loader-resolution.ts +3 -0
  27. package/src/router/match-api.ts +3 -3
  28. package/src/router/middleware-types.ts +2 -22
  29. package/src/router/middleware.ts +18 -3
  30. package/src/router/pattern-matching.ts +60 -9
  31. package/src/router/trie-matching.ts +10 -4
  32. package/src/router/url-params.ts +49 -0
  33. package/src/router.ts +1 -2
  34. package/src/rsc/handler.ts +2 -1
  35. package/src/rsc/response-route-handler.ts +3 -0
  36. package/src/server/request-context.ts +10 -42
  37. package/src/types/handler-context.ts +2 -34
  38. package/src/types/loader-types.ts +2 -6
  39. package/src/types/request-scope.ts +126 -0
  40. package/src/urls/response-types.ts +2 -10
  41. package/src/vite/rango.ts +23 -7
  42. package/src/vite/utils/banner.ts +1 -1
  43. 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
- router.reverse("product", { slug: "widget" }); // "/product/widget"
168
- router.reverse("search", undefined, { q: "rsc" }); // "/search?q=rsc"
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 Components)
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 BlogIndex() {
530
+ export function Nav({ home, post }: { home: string; post: string }) {
491
531
  return (
492
532
  <nav>
493
- <Link to={reverse("home")}>Home</Link>
494
- <Link to={reverse("blogPost", { slug: "my-post" })}>My Post</Link>
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
- `reverse()` is type-safe route names and required params are checked at compile time. Included routes use dotted names: `reverse("api.health")`.
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";
@@ -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.84",
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
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
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
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
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
- "@vitejs/plugin-rsc/vendor/react-server-dom/client.edge"
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
- "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge"
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.84",
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": "catalog:",
154
- "@types/react-dom": "catalog:",
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": "catalog:",
158
- "react-dom": "catalog:",
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
- ("use client");
148
+ "use client";
147
149
  import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
148
150
 
149
151
  function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
@@ -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
- ("use client");
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 including server-side `ctx.reverse`.
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
 
@@ -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"); // Type-safe local name
115
- reverse("product", { slug: "widget" }); // Type-safe with params
116
- reverse("blog.post"); // Absolute names (dot notation) always allowed
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 | Resolves | Use for |
191
- | ---------------- | ------------------------------- | ------------------------------- | ----------------------------------- |
192
- | Server handler | `ctx.reverse("name")` | Named routes (local + absolute) | Server-side URL generation |
193
- | Server handler | `scopedReverse<T>(ctx.reverse)` | Same, with type safety | Type-safe server URLs |
194
- | Client component | `href("/path")` | Absolute paths | Global navigation |
195
- | Client component | `useHref()` | Mount-prefixed paths | Local navigation inside `include()` |
196
- | Client component | `useMount()` | Raw mount path | Custom mount-aware logic |
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
 
@@ -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
- return { product: await fetchProduct(slug) };
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
- ("use client");
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 { store, client, eventController, onUpdate, renderSegments } = config;
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