@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -0,0 +1,153 @@
1
+ # Params and Search Types
2
+
3
+ ## Typed Search Params
4
+
5
+ Add a `search` schema to `path()` options for type-safe query parameters:
6
+
7
+ ```typescript
8
+ // Route definition with search schema
9
+ path("/search", SearchPage, {
10
+ name: "search",
11
+ search: { q: "string", page: "number?", sort: "string?" },
12
+ });
13
+ ```
14
+
15
+ ### Handler with typed search params
16
+
17
+ `Handler<"name">` automatically resolves route params and search params from the
18
+ global `GeneratedRouteMap` (the gen file). No explicit route map import needed:
19
+
20
+ ```typescript
21
+ // pages/search.tsx
22
+ import type { Handler } from "@rangojs/router";
23
+
24
+ export const SearchPage: Handler<"search"> = (ctx) => {
25
+ // ctx.search is typed: { q: string; page?: number; sort?: string }
26
+ const { q, page, sort } = ctx.search;
27
+ return <SearchResults q={q} page={page} sort={sort} />;
28
+ };
29
+ ```
30
+
31
+ This avoids circular references because `Handler` defaults to `GeneratedRouteMap`
32
+ (from `router.named-routes.gen.ts`) instead of `RegisteredRoutes` (which depends on `router.tsx`).
33
+
34
+ You can also pass an explicit route map for per-module isolation (opt-in,
35
+ after running `npx rango generate`). With a local map, the route name is
36
+ **dot-prefixed** so params and search resolve from `routes`, not the global map:
37
+
38
+ ```typescript
39
+ import type { Handler } from "@rangojs/router";
40
+ import type { routes } from "./urls.gen.js";
41
+
42
+ export const SearchPage: Handler<".search", routes> = (ctx) => { ... };
43
+ ```
44
+
45
+ Note the difference: `Handler<"search">` (no dot) resolves against the global
46
+ `GeneratedRouteMap`; `Handler<".search", routes>` resolves against the local
47
+ `routes` map. Mixing them — `Handler<"search", routes>` — silently ignores
48
+ `routes` for param/search inference and only uses it for local `ctx.reverse(".x")`.
49
+
50
+ Supported types: `"string"`, `"number"`, `"boolean"`, with `?` suffix for optional.
51
+ Values are automatically coerced from query string (e.g., `"2"` becomes `2` for numbers).
52
+ Routes without a `search` schema keep the standard `URLSearchParams` behavior.
53
+
54
+ ### RouteSearchParams and RouteParams utility types
55
+
56
+ Extract typed params by route name for use in component props, return types, or anywhere:
57
+
58
+ ```typescript
59
+ import type { RouteSearchParams, RouteParams } from "@rangojs/router";
60
+
61
+ // RouteSearchParams<"name"> resolves the search schema to a typed object
62
+ type SP = RouteSearchParams<"search">;
63
+ // { q: string | undefined; page?: number; sort?: string }
64
+
65
+ // RouteParams<"name"> resolves URL params from the route pattern
66
+ type P = RouteParams<"blogPost">;
67
+ // { slug: string }
68
+
69
+ // Optional URL params (`:slug?`) resolve to `string | undefined`
70
+ // because absent segments are omitted from `ctx.params` at runtime.
71
+ type C = RouteParams<"checkout">;
72
+ // { step?: string }
73
+ // → ctx.params.step is `string | undefined`; use `?? "default"` to coalesce.
74
+
75
+ // Use in component props
76
+ interface SearchResultsProps {
77
+ params: RouteSearchParams<"search">;
78
+ }
79
+ ```
80
+
81
+ Both default to the global route map (`RegisteredRoutes` or `GeneratedRouteMap`).
82
+ Pass an explicit route map as the second type argument when needed:
83
+
84
+ ```typescript
85
+ import type { routes } from "./urls.gen.js";
86
+
87
+ type SP = RouteSearchParams<"search", routes>;
88
+ type P = RouteParams<"blogPost", routes>;
89
+ ```
90
+
91
+ ### Generated route types
92
+
93
+ In the generated `router.named-routes.gen.ts`, routes with search schemas
94
+ use `{ path, search }` objects:
95
+
96
+ ```typescript
97
+ // router.named-routes.gen.ts (auto-generated)
98
+ export const NamedRoutes = {
99
+ "search.index": {
100
+ path: "/search",
101
+ search: { q: "string", page: "number?", sort: "string?" },
102
+ },
103
+ "home.index": "/", // No search schema -> plain string
104
+ } as const;
105
+ ```
106
+
107
+ You never open a `.gen.ts` by hand. Treat the generated types as call-site
108
+ honesty checks, not modules to read:
109
+
110
+ - **Do not import `router.named-routes.gen.ts` directly**, and don't reach for
111
+ `Rango.GeneratedRouteMap`. It is the whole-app manifest, auto-wired
112
+ globally — `Handler<"name">` and `ctx.reverse("name")` already see it.
113
+ - **Per-module `*.gen.ts` imports are fine** — they are the opt-in local-route
114
+ pattern for `useReverse(routes)` and explicit local handler typing
115
+ (`Handler<".name", routes>`). See `/links`.
116
+
117
+ If a type error points at a generated map instead of your call site, that's a
118
+ smell — fix the call site (or regenerate), never edit the generated file.
119
+
120
+ ## Loader Type Safety
121
+
122
+ Loaders have typed return values:
123
+
124
+ ```typescript
125
+ // loaders/product.ts
126
+ export const ProductLoader = createLoader(async (ctx) => {
127
+ return {
128
+ id: ctx.params.slug,
129
+ name: "Widget",
130
+ price: 99,
131
+ };
132
+ });
133
+
134
+ // In server component - type is inferred
135
+ import { useLoader } from "@rangojs/router/client";
136
+
137
+ async function ProductPage() {
138
+ const product = await useLoader(ProductLoader);
139
+ // product: { id: string; name: string; price: number }
140
+ return <h1>{product.name}</h1>;
141
+ }
142
+
143
+ // In client component - same type
144
+ "use client";
145
+ import { useLoader } from "@rangojs/router/client";
146
+
147
+ function ProductPrice() {
148
+ const { data } = useLoader(ProductLoader);
149
+ // data: { id: string; name: string; price: number }
150
+ const product = data;
151
+ return <span>${product.price}</span>;
152
+ }
153
+ ```
@@ -0,0 +1,209 @@
1
+ # Route Types
2
+
3
+ ## Route Definition with Type-Safe Names
4
+
5
+ ```typescript
6
+ // urls.tsx
7
+ import { urls } from "@rangojs/router";
8
+
9
+ export const urlpatterns = urls(({ path, layout }) => [
10
+ path("/", HomePage, { name: "home" }),
11
+ path("/products", ProductsPage, { name: "products" }),
12
+ path("/product/:slug", ProductPage, { name: "product" }),
13
+ path("/cart", CartPage, { name: "cart" }),
14
+ path("/checkout/:step?", CheckoutPage, { name: "checkout" }),
15
+ ]);
16
+
17
+ // Route names are inferred from the { name } option
18
+ ```
19
+
20
+ ## Type-Safe href()
21
+
22
+ ### Server: ctx.reverse with route names
23
+
24
+ In route handlers, `ctx.reverse()` uses two namespaces:
25
+
26
+ - **`.name`** — local route, resolved within the current `include()` scope
27
+ - **`name`** — global route, from the named-routes definition
28
+
29
+ ```typescript
30
+ import type { Handler } from "@rangojs/router";
31
+
32
+ export const ProductHandler: Handler<"shop.product"> = (ctx) => {
33
+ ctx.reverse(".cart"); // Local: /shop/cart
34
+ ctx.reverse(".product", { slug: "widget" }); // Local: /shop/product/widget
35
+ ctx.reverse("blog.post", { slug: "1" }); // Global: /blog/1
36
+ };
37
+ ```
38
+
39
+ For type-safe local names, generate a route types file with `npx rango generate urls/shop.tsx`
40
+ and pass it as the second generic to `Handler` or `Prerender`:
41
+
42
+ ```typescript
43
+ import type { Handler } from "@rangojs/router";
44
+ import type { routes } from "./shop.gen.js";
45
+
46
+ export const ProductHandler: Handler<"shop.product", routes> = (ctx) => {
47
+ ctx.reverse(".cart"); // Type-safe local name
48
+ ctx.reverse(".product", { slug: "widget" }); // Type-safe local with params
49
+ ctx.reverse("blog.post", { slug: "hi" }); // Type-safe global name
50
+ };
51
+ ```
52
+
53
+ ### Client: href + useHref
54
+
55
+ On the client, `href()` validates paths against registered route patterns at compile time:
56
+
57
+ ```typescript
58
+ "use client";
59
+ import { href, useHref, Link } from "@rangojs/router/client";
60
+
61
+ // href() validates absolute paths via PatternToPath types
62
+ href("/about"); // Valid path
63
+ href("/blog/hello"); // Matches /blog/:slug
64
+
65
+ // useHref() auto-prefixes with include() mount
66
+ function ShopNav() {
67
+ const href = useHref();
68
+ return <Link to={href("/cart")}>Cart</Link>; // "/shop/cart"
69
+ }
70
+ ```
71
+
72
+ `href()` and the `Rango.Path` type read from `RegisteredRoutes` when you augment
73
+ it, otherwise from the auto-generated `GeneratedRouteMap` — so `rango generate`
74
+ alone type-checks `href()` paths with no manual augmentation. The augmentation
75
+ below is only needed for **`Rango.PathResponse`** (response-payload inference), which
76
+ `GeneratedRouteMap` cannot provide:
77
+
78
+ ```typescript
79
+ declare global {
80
+ namespace Rango {
81
+ interface RegisteredRoutes extends typeof router.routeMap {}
82
+ }
83
+ }
84
+ ```
85
+
86
+ For wrapper helpers, type the path parameter as `Rango.Path`. It is ambient (no
87
+ import) and shares `href()`'s compile-time path checking, so a wrapper stays in
88
+ sync with your routes automatically:
89
+
90
+ ```typescript
91
+ import { href } from "@rangojs/router/client";
92
+
93
+ export const appHref = (path: Rango.Path): string => href(path);
94
+ ```
95
+
96
+ For response-route payloads, `Rango.PathResponse<T>` is the ambient lookup. It
97
+ accepts a route _pattern_ **or** a concrete path, so it also serves as the return
98
+ type of a typed `fetch` wrapper. It only resolves once `RegisteredRoutes` carries
99
+ response metadata:
100
+
101
+ ```typescript
102
+ import { href } from "@rangojs/router/client";
103
+
104
+ type Product = Rango.PathResponse<"/api/products/:id">; // by pattern
105
+ type Same = Rango.PathResponse<"/api/products/42">; // by concrete path
106
+
107
+ // Response inferred from the concrete path passed in:
108
+ async function get<T extends Rango.Path>(
109
+ path: T,
110
+ ): Promise<Rango.PathResponse<T>> {
111
+ return fetch(href(path)).then((r) => r.json());
112
+ }
113
+ const product = await get("/api/products/42"); // Product (bare value)
114
+ ```
115
+
116
+ Pattern keys (`/:id`) match exactly; a concrete path under a _nested_ dynamic
117
+ route can match several patterns and union their responses.
118
+
119
+ `Rango.PathResponse` describes the JSON **wire** shape, not the handler's raw
120
+ return. A `path.json()` handler returning `{ createdAt: Date }` resolves here to
121
+ `{ createdAt: string }` (bare value), matching what `r.json()` yields. This
122
+ is applied via the ambient `Rango.JsonSerialize<T>` transform (`Date -> string`,
123
+ honors `toJSON()`, drops functions/`undefined`, `bigint -> never`). A separate
124
+ `Rango.FlightSerialize<T>` models the higher-fidelity RSC Flight boundary
125
+ (loaders / RSC props, where `Date` is preserved) — do **not** use it for
126
+ `path.json()`.
127
+
128
+ ### Overriding serialization globally
129
+
130
+ For your own types, the zero-config way to control the JSON wire shape is a
131
+ `toJSON()` method — `Rango.JsonSerialize` honors it, and it matches the runtime
132
+ exactly (`JSON.stringify` calls `toJSON()`):
133
+
134
+ ```typescript
135
+ class Money {
136
+ constructor(private cents: number) {}
137
+ toJSON(): number {
138
+ return this.cents;
139
+ }
140
+ }
141
+ // Rango.JsonSerialize<Money> is number; Rango.PathResponse reflects it.
142
+ ```
143
+
144
+ To override a transform for types you **don't** own (or for the Flight boundary,
145
+ which has no `toJSON()`), augment its override slot. Because `Rango.JsonSerialize`
146
+ / `Rango.FlightSerialize` are type _aliases_ (TS can't merge those), you provide a
147
+ single member that is your **complete** transform, delegating to the built-in for
148
+ the cases you don't change:
149
+
150
+ ```typescript
151
+ declare global {
152
+ namespace Rango {
153
+ interface JsonSerializeOverride<T> {
154
+ app: T extends Decimal ? string : Rango.JsonSerializeBuiltin<T>;
155
+ }
156
+ interface FlightSerializeOverride<T> {
157
+ app: T extends Money ? number : Rango.FlightSerializeBuiltin<T>;
158
+ }
159
+ }
160
+ }
161
+ // Rango.JsonSerialize<Decimal> -> string; Rango.FlightSerialize<Money> -> number;
162
+ // everything else stays on the built-in, recursively (nested fields too).
163
+ ```
164
+
165
+ Rules: provide **exactly one** member (the slot is read as
166
+ `Override<T>[keyof Override<T>]`, so multiple members union and conflict).
167
+ Overrides win over `toJSON()` and apply at every nesting level. Caveat for JSON:
168
+ the `path.json()` runtime is plain `JSON.stringify`, which only honors `toJSON()`,
169
+ so a `JsonSerializeOverride` that disagrees with what the runtime emits will lie —
170
+ prefer `toJSON()` for your own types and use the slot only for types you can't
171
+ modify.
172
+
173
+ See `/links` for full URL generation guide.
174
+
175
+ ## Stable identity: `path#export`
176
+
177
+ Loaders, handles, cached functions (`functionId`), and server actions
178
+ (`actionId`) all share one identity scheme: `{modulePath}#{exportName}`,
179
+ injected at build by the `exposeInternalIds` and `exposeActionId` Vite plugins.
180
+ This is also the identity React server actions carry across the Flight boundary,
181
+ which is why a `revalidate()` predicate sees an action as a `path#export` string:
182
+
183
+ ```typescript
184
+ revalidate(
185
+ ({ actionId }) => actionId === "src/actions/cart.ts#addToCart" || undefined,
186
+ );
187
+ ```
188
+
189
+ `actionId` is the only stable reference React exposes across the Flight boundary,
190
+ so it stays as the floor and escape hatch. The hand-written-string surface
191
+ (`actionId?.includes("cart.ts#")`) is brittle: a renamed action or moved file
192
+ silently stops matching with no compile error. Prefer **`ctx.isAction()`** in a
193
+ revalidate predicate — it resolves the action's id from an imported reference, so
194
+ a rename is a type error in one place instead of silent drift:
195
+
196
+ ```ts
197
+ import { addToCart, removeFromCart } from "./actions/cart";
198
+ import * as CartActions from "./actions/cart";
199
+
200
+ revalidate((ctx) => ctx.isAction(addToCart) || undefined); // one action
201
+ revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
202
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
203
+ ```
204
+
205
+ `ctx.isAction()` (only available on the revalidate predicate's context) returns a
206
+ raw boolean — combine with `|| undefined` for the "revalidate on match, else
207
+ defer" intent. It resolves the reference the same way the router derives
208
+ `actionId` (`$id` in production, `$$id` in dev), so matching
209
+ works in both modes. `actionId` stays available for advanced cases.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: use-cache
3
- description: Function-level caching with "use cache" directive for RSC data functions and components
3
+ description: Function-level caching with the "use cache" directive for RSC data functions and components in @rangojs/router. Use when a single function or component should memoize its own output, not a whole route/segment subtree or HTTP response.
4
4
  argument-hint: [profile-name]
5
5
  ---
6
6
 
@@ -10,6 +10,12 @@ Function-level caching for async server functions and RSC components. Caches
10
10
  return values with TTL + stale-while-revalidate. Complementary to the route-level
11
11
  `cache()` DSL and build-time `Static()`/`Prerender()`.
12
12
 
13
+ ## Not this skill if…
14
+
15
+ - You want to cache a whole route or a rendered subtree of segments — that is
16
+ the segment-level `cache()` DSL: see `/caching`.
17
+ - You are unsure which cache layer you need — start at `/cache-guide`.
18
+
13
19
  ## Basic Usage
14
20
 
15
21
  ### File-level (all exports cached with default profile)
@@ -63,12 +69,23 @@ createRouter({
63
69
  short: { ttl: 60, swr: 120 },
64
70
  long: { ttl: 3600, swr: 7200 },
65
71
  products: { ttl: 300, swr: 600, tags: ["products"] },
72
+ // Opt-in: a stale entry re-executes in the foreground during a server
73
+ // action's revalidation render (fresh action response), instead of SWR.
74
+ cms: { ttl: 300, swr: 600, foregroundOnAction: true },
66
75
  },
67
76
  });
68
77
  ```
69
78
 
70
79
  - `"use cache"` (no name) resolves to `default`.
71
80
  - `"use cache: short"` resolves to the `short` profile.
81
+ - `foregroundOnAction: true` (default false): a stale entry serves stale +
82
+ revalidates in the background on a plain navigation (SWR), but re-executes in
83
+ the FOREGROUND during a server action's revalidation render so the action
84
+ response reflects a fresh value (only the store write is deferred). Use it for
85
+ mutation-related cached data; incidental TTL staleness on an ordinary action
86
+ stays SWR so the action is not turned into a synchronous cache-refresh barrier.
87
+ For strong read-your-own-writes after a mutation, prefer `updateTag()` (a hard
88
+ purge, so the action's own re-render is a fresh foreground miss).
72
89
  - Unknown profile names throw at runtime, on the first invocation of the cached
73
90
  function (the Vite transform does not validate names at build/boot). The error
74
91
  is actionable -- it names the missing profile and shows the `createRouter({
@@ -136,6 +153,13 @@ const locale = cookies().get("locale")?.value ?? "en";
136
153
  const data = await getCachedData(locale); // locale is now in the cache key
137
154
  ```
138
155
 
156
+ The guard does not reach into LOADER bodies consumed inside the cached
157
+ function (`await ctx.use(loader)`): loaders always run fresh, so their reads
158
+ are exempt — but the CONSUMED VALUE is captured into the shared cache entry
159
+ like any other computed data. Same rule across `cache()` and the PPR shell:
160
+ handler/cached-scope consumption = baked copy, client-side `useLoader` = live
161
+ (the consumption-lane rule, `/rango` → Invariants).
162
+
139
163
  ### Side-Effect Guards
140
164
 
141
165
  These ctx methods **throw** inside a `"use cache"` function because their effects
@@ -329,8 +353,11 @@ export async function getProducts() {
329
353
  Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
330
354
  One store, one configuration.
331
355
 
332
- Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or, inside
333
- a `"use cache"` function, runtime `cacheTag(...tags)`. The built-in
356
+ Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or runtime
357
+ `cacheTag(...tags)`. `cacheTag` has two forms: inside a `"use cache"` function it
358
+ tags that entry; called during a request render outside `"use cache"` it tags the
359
+ request's document/shell artifact (rides `_requestTags`) instead of throwing. The
360
+ built-in
334
361
  `MemorySegmentCacheStore` and `CFCacheStore` index by tag. Invalidate on demand
335
362
  with `updateTag(...tags)` (awaitable, read-your-own-writes; for server actions) or
336
363
  `revalidateTag(...tags)` (background, non-blocking; for route handlers/webhooks).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vercel
3
- description: Deploy a Rango app to Vercel Functions (Build Output API v3)
3
+ description: Deploy a Rango app to Vercel Functions (Build Output API v3). Use when deploying a Rango app to Vercel, or asking how the router works with Vercel Functions and the Build Output API.
4
4
  argument-hint:
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: view-transitions
3
- description: Configure React View Transitions on layouts, routes, and parallel slots in @rangojs/router
3
+ description: Configure React View Transitions on layouts, routes, and parallel slots in @rangojs/router. Use when navigation should animate smoothly between pages, or wiring up React View Transitions on a route or layout.
4
4
  argument-hint: [layout|route|parallel|intercept]
5
5
  ---
6
6
 
@@ -209,6 +209,10 @@ interface TransitionConfig {
209
209
  default?: string | Record<string, string>; // fallback for any phase
210
210
  name?: string; // explicit view-transition-name
211
211
  viewTransition?: "auto" | false; // boundary opt-out (see below)
212
+ // Conditional gate, evaluated server-side AFTER the route handler. Return
213
+ // false to drop this transition for the request, so the navigation streams its
214
+ // loading() fallback instead of holding. See the gate section below.
215
+ when?: (ctx: TransitionWhenContext) => boolean;
212
216
  }
213
217
  ```
214
218
 
@@ -217,6 +221,45 @@ interface TransitionConfig {
217
221
  - `name` lets you participate in cross-page morphs by name (advanced; you usually don't need this on a layout/route-level wrap).
218
222
  - `viewTransition` toggles whether rango places its own `<ViewTransition>` boundary. `"auto"` (default) wraps as described above; `false` opts out — see the next section.
219
223
 
224
+ ## Conditional transitions (`when`)
225
+
226
+ `transition({ when })` gates the hold per request. The predicate runs **server-side, AFTER the route handler** and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
227
+
228
+ Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for post-handler reads:
229
+
230
+ ```ts
231
+ import type { TransitionWhenContext } from "@rangojs/router";
232
+
233
+ // Hold only when the handler marked this request (handler sets, gate reads):
234
+ transition({ when: (ctx) => ctx.get(KeepScroll) === true });
235
+
236
+ // Hold only when arriving from a specific page (the navigation SOURCE):
237
+ transition({
238
+ when: ({ currentUrl }) => currentUrl?.pathname.startsWith("/list") === true,
239
+ });
240
+ transition({ when: ({ fromRouteName }) => fromRouteName === "products.list" });
241
+
242
+ // Hold only after a specific action revalidated the route:
243
+ transition({
244
+ when: ({ actionId }) => actionId === "src/actions/cart.ts#addToCart",
245
+ });
246
+ ```
247
+
248
+ | field | meaning | populated |
249
+ | ------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------- |
250
+ | `currentUrl` / `currentParams` / `fromRouteName` | navigation **source** | soft nav + action-success; `undefined` on initial full load and action/PE error paths |
251
+ | `nextUrl` / `nextParams` | navigation **target** | always |
252
+ | `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
253
+ | `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
254
+ | `method` | `"GET"` (nav) / `"POST"` (action) | always |
255
+ | `get` / `env` | read handler/middleware vars + app env | always |
256
+
257
+ A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
258
+
259
+ **Same-route content-holds need the transition present on the FIRST render.** The same-route hold works by giving the route a param-agnostic key so a param change reconciles instead of remounting — but that key is established when the route first mounts. A source gate that returns `false` on the initial full load (where `currentUrl`/`currentParams`/`fromRouteName` are undefined) drops the transition before the route mounts, so the route mounts _outside_ a transition scope and **every** later same-route param nav remounts (flashing the skeleton) regardless of what the gate decides on those navs. Write source gates so they hold when there is no source — e.g. `({ currentParams }) => currentParams?.tab !== "raw"` (true on the initial load) rather than `=== "details"` (false on the initial load) — when the same-route content-hold must engage. This only affects same-route param navigations; action-only or cross-route gating is unaffected (no shared param key is in play).
260
+
261
+ **Prefetch / cache caveat.** The gate runs during resolution, so a **prefetched** navigation decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source — and a `cache()`/prerender hit replays the stored transition without re-running the predicate. A source-sensitive gate can therefore be frozen to prefetch/store-time state. This covers ~99% of navigations; if yours must reflect the exact click-time source, source-scope the prefetch (`<Link prefetchKey=":source">`) and don't `cache()` that segment.
262
+
220
263
  ## Opting out of the router boundary (place your own `<ViewTransition>`)
221
264
 
222
265
  By default a `transition()` segment gets a rango-placed `<ViewTransition>` boundary — a cross-fade of the whole outlet/route. If you'd rather animate specific elements yourself (place `<ViewTransition name="...">` in your components), set `viewTransition: false`. The router then contributes **no boundary of its own** but still:
@@ -249,6 +249,14 @@ export interface EventController {
249
249
  resolvedIds?: string[],
250
250
  ): void;
251
251
  getHandleState(): HandleState;
252
+ /**
253
+ * Update ONLY `routeSegmentIds` (what `useSegments` reads) from `matched`,
254
+ * leaving `data` and `segmentOrder` (what `useHandle` collects over) untouched.
255
+ * Used while a deferred handle is resolving: the route has changed (so
256
+ * `useSegments` must reflect the new segment ids) but `useHandle` still holds
257
+ * its previous value until the deferred snapshot is applied.
258
+ */
259
+ setRouteSegmentIds(matched: string[]): void;
252
260
 
253
261
  // Params operations
254
262
  setParams(params: Record<string, string>): void;
@@ -269,6 +277,13 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
269
277
  result: null,
270
278
  };
271
279
 
280
+ // Shared empty inflight-actions list. getState() hands back this exact reference
281
+ // whenever no action is inflight (the overwhelmingly common case), so the derived
282
+ // snapshot's `inflightActions` is referentially stable across notifies instead of
283
+ // a fresh [] each call. Read-only by contract (consumers only read length/spread),
284
+ // same as the shared DEFAULT_ACTION_STATE.
285
+ const EMPTY_INFLIGHT_ACTIONS: InflightAction[] = [];
286
+
272
287
  /**
273
288
  * Check if a subscription ID matches an action's full ID.
274
289
  *
@@ -363,7 +378,19 @@ export function createEventController(
363
378
  const actionListeners = new Map<string, Set<ActionStateListener>>();
364
379
  const handleListeners = new Set<HandleListener>();
365
380
 
366
- const notify = makeDebouncedNotifier(stateListeners);
381
+ const notifyStateListeners = makeDebouncedNotifier(stateListeners);
382
+
383
+ // Memoized derived snapshot. Every state mutation already funnels through
384
+ // notify(), so invalidating here (synchronously, before the debounced fire)
385
+ // means a getState() call between two mutations reuses the same object — an
386
+ // unchanged state returns the SAME reference — while any real change recomputes
387
+ // on the next read. Kept null when dirty.
388
+ let cachedDerivedState: DerivedNavigationState | null = null;
389
+
390
+ function notify(): void {
391
+ cachedDerivedState = null;
392
+ notifyStateListeners();
393
+ }
367
394
 
368
395
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
369
396
 
@@ -389,14 +416,21 @@ export function createEventController(
389
416
  const notifyHandles = makeDebouncedNotifier(handleListeners);
390
417
 
391
418
  function getState(): DerivedNavigationState {
392
- const inflightActionsList: InflightAction[] = [...inflightActions.values()]
393
- .filter((a) => a.phase !== "settling")
394
- .map((a) => ({
395
- id: a.id,
396
- actionId: a.actionId,
397
- payload: a.payload,
398
- startedAt: a.startedAt,
399
- }));
419
+ if (cachedDerivedState) return cachedDerivedState;
420
+
421
+ // Skip the spread/filter/map entirely when idle — the common case — and hand
422
+ // back the shared frozen empty list for referential stability.
423
+ const inflightActionsList: InflightAction[] =
424
+ inflightActions.size === 0
425
+ ? EMPTY_INFLIGHT_ACTIONS
426
+ : [...inflightActions.values()]
427
+ .filter((a) => a.phase !== "settling")
428
+ .map((a) => ({
429
+ id: a.id,
430
+ actionId: a.actionId,
431
+ payload: a.payload,
432
+ startedAt: a.startedAt,
433
+ }));
400
434
 
401
435
  const hasActiveActions = inflightActionsList.length > 0;
402
436
  const isVisibleNavigation =
@@ -406,7 +440,7 @@ export function createEventController(
406
440
 
407
441
  const isStreaming = activeStreamCount > 0 || state === "loading";
408
442
 
409
- return {
443
+ cachedDerivedState = {
410
444
  state,
411
445
  isStreaming,
412
446
  // True when a navigation is active (fetching or streaming, before
@@ -422,9 +456,14 @@ export function createEventController(
422
456
  : null,
423
457
  inflightActions: inflightActionsList,
424
458
  };
459
+ return cachedDerivedState;
425
460
  }
426
461
 
427
462
  function getActionState(actionId: string): TrackedActionState {
463
+ // Nothing inflight — skip building/scanning the list and return the shared
464
+ // idle snapshot (the same reference use-action falls back to).
465
+ if (inflightActions.size === 0) return DEFAULT_ACTION_STATE;
466
+
428
467
  const entry = [...inflightActions.values()]
429
468
  .filter((a) => matchesActionId(actionId, a.actionId))
430
469
  .reduce<ActionEntry | undefined>((best, a) => {
@@ -860,6 +899,18 @@ export function createEventController(
860
899
  };
861
900
  }
862
901
 
902
+ function setRouteSegmentIds(matched: string[]): void {
903
+ const next = filterRouteSegmentIds(matched);
904
+ if (
905
+ next.length === routeSegmentIds.length &&
906
+ next.every((id, i) => id === routeSegmentIds[i])
907
+ ) {
908
+ return;
909
+ }
910
+ routeSegmentIds = next;
911
+ notifyHandles();
912
+ }
913
+
863
914
  // ========================================================================
864
915
  // Subscriptions
865
916
  // ========================================================================
@@ -928,6 +979,7 @@ export function createEventController(
928
979
  // Handles
929
980
  setHandleData,
930
981
  getHandleState,
982
+ setRouteSegmentIds,
931
983
 
932
984
  // Params
933
985
  setParams,
@@ -1,5 +1,15 @@
1
1
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
2
 
3
+ /**
4
+ * Module-level debug flag for guarding call sites whose debug arguments do
5
+ * non-trivial work (joins, maps, spreads, object literals). Because the Vite
6
+ * transform folds INTERNAL_RANGO_DEBUG to a literal, an `if (IS_BROWSER_DEBUG)`
7
+ * guard is minifier-DCE-able — the whole block drops from the production bundle.
8
+ * Prefer this over isBrowserDebugEnabled() at hot call sites: a const guard folds
9
+ * where a function call may not.
10
+ */
11
+ export const IS_BROWSER_DEBUG: boolean = INTERNAL_RANGO_DEBUG;
12
+
3
13
  interface BrowserLogContext {
4
14
  requestId: string;
5
15
  txId: string;
@@ -53,3 +63,21 @@ export function debugLog(msg: string, ...args: unknown[]): void {
53
63
  console.log(msg, ...args);
54
64
  }
55
65
  }
66
+
67
+ /**
68
+ * Boot-sequence debug log: one line per initial-document step (flight decode,
69
+ * handle stream, bridge wiring, initial tree build, hydration commit), each
70
+ * stamped with performance.now() so the gap BEFORE hydrateRoot is visible.
71
+ * The initial document path was otherwise silent — FE debug only started
72
+ * talking at the first soft navigation, so a boot stall (e.g. an await that
73
+ * holds initBrowserApp, and with it hydrateRoot) was invisible.
74
+ */
75
+ export function bootLog(step: string, details?: Record<string, unknown>): void {
76
+ if (!INTERNAL_RANGO_DEBUG) return;
77
+ const prefix = `[Browser][boot] ${step} @ ${Math.round(performance.now())}ms`;
78
+ if (details) {
79
+ console.log(prefix, details);
80
+ return;
81
+ }
82
+ console.log(prefix);
83
+ }