@rangojs/router 0.0.0-experimental.83 → 0.0.0-experimental.8332dbe4

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 (100) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1197 -454
  3. package/package.json +4 -2
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/handler-use/SKILL.md +2 -0
  6. package/skills/hooks/SKILL.md +30 -2
  7. package/skills/i18n/SKILL.md +276 -0
  8. package/skills/intercept/SKILL.md +25 -0
  9. package/skills/layout/SKILL.md +2 -0
  10. package/skills/links/SKILL.md +234 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +2 -0
  13. package/skills/migrate-nextjs/SKILL.md +3 -1
  14. package/skills/migrate-react-router/SKILL.md +4 -0
  15. package/skills/parallel/SKILL.md +9 -0
  16. package/skills/rango/SKILL.md +2 -0
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +9 -1
  22. package/skills/view-transitions/SKILL.md +212 -0
  23. package/src/browser/app-shell.ts +52 -0
  24. package/src/browser/event-controller.ts +44 -4
  25. package/src/browser/navigation-bridge.ts +113 -6
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +44 -10
  28. package/src/browser/prefetch/cache.ts +16 -0
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/NavigationProvider.tsx +64 -16
  31. package/src/browser/react/filter-segment-order.ts +51 -7
  32. package/src/browser/react/index.ts +3 -0
  33. package/src/browser/react/use-params.ts +8 -5
  34. package/src/browser/react/use-reverse.ts +99 -0
  35. package/src/browser/react/use-router.ts +8 -1
  36. package/src/browser/react/use-segments.ts +11 -8
  37. package/src/browser/rsc-router.tsx +34 -6
  38. package/src/browser/types.ts +19 -0
  39. package/src/build/route-trie.ts +2 -1
  40. package/src/cache/cf/cf-cache-store.ts +5 -7
  41. package/src/client.rsc.tsx +3 -0
  42. package/src/client.tsx +5 -1
  43. package/src/href-client.ts +4 -1
  44. package/src/index.rsc.ts +3 -0
  45. package/src/index.ts +3 -0
  46. package/src/outlet-context.ts +1 -1
  47. package/src/response-utils.ts +28 -0
  48. package/src/reverse.ts +62 -39
  49. package/src/route-definition/dsl-helpers.ts +16 -3
  50. package/src/route-definition/helpers-types.ts +6 -1
  51. package/src/route-definition/resolve-handler-use.ts +6 -0
  52. package/src/router/handler-context.ts +21 -41
  53. package/src/router/lazy-includes.ts +1 -1
  54. package/src/router/loader-resolution.ts +3 -0
  55. package/src/router/match-api.ts +4 -3
  56. package/src/router/match-handlers.ts +1 -0
  57. package/src/router/match-result.ts +21 -2
  58. package/src/router/middleware-types.ts +14 -25
  59. package/src/router/middleware.ts +54 -7
  60. package/src/router/pattern-matching.ts +101 -17
  61. package/src/router/revalidation.ts +15 -1
  62. package/src/router/segment-resolution/fresh.ts +8 -0
  63. package/src/router/segment-resolution/revalidation.ts +128 -100
  64. package/src/router/substitute-pattern-params.ts +56 -0
  65. package/src/router/trie-matching.ts +18 -13
  66. package/src/router/url-params.ts +49 -0
  67. package/src/router.ts +1 -2
  68. package/src/rsc/handler.ts +8 -4
  69. package/src/rsc/progressive-enhancement.ts +2 -0
  70. package/src/rsc/response-route-handler.ts +11 -10
  71. package/src/rsc/rsc-rendering.ts +3 -0
  72. package/src/rsc/server-action.ts +2 -0
  73. package/src/rsc/types.ts +6 -0
  74. package/src/segment-system.tsx +60 -9
  75. package/src/server/request-context.ts +10 -42
  76. package/src/ssr/index.tsx +5 -1
  77. package/src/types/handler-context.ts +12 -39
  78. package/src/types/loader-types.ts +5 -6
  79. package/src/types/request-scope.ts +126 -0
  80. package/src/types/segments.ts +17 -0
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/expose-action-id.ts +52 -28
  91. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  92. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  93. package/src/vite/plugins/performance-tracks.ts +17 -9
  94. package/src/vite/plugins/use-cache-transform.ts +56 -43
  95. package/src/vite/plugins/version-injector.ts +37 -11
  96. package/src/vite/rango.ts +49 -14
  97. package/src/vite/router-discovery.ts +498 -52
  98. package/src/vite/utils/banner.ts +1 -1
  99. package/src/vite/utils/package-resolution.ts +41 -1
  100. package/src/vite/utils/prerender-utils.ts +5 -4
@@ -0,0 +1,283 @@
1
+ ---
2
+ name: streams-and-websockets
3
+ description: Long-lived Response handlers — Server-Sent Events (SSE) via path.stream and WebSocket upgrades via path.any on Cloudflare Workers, including middleware interaction and runtime caveats.
4
+ argument-hint: "[sse | websocket | agents]"
5
+ ---
6
+
7
+ # Streams and WebSockets
8
+
9
+ Response routes can return long-lived responses — SSE streams and WebSocket
10
+ upgrades. Both require a `Response` that the router must forward through the
11
+ middleware chain without reconstruction.
12
+
13
+ ## When each fits
14
+
15
+ | Shape | Tag | Status | Body | Runtime |
16
+ | ----------- | --------------- | ------ | ------------------------------- | -------------------------------- |
17
+ | Server-Sent | `path.stream()` | 200 | `ReadableStream` (event-stream) | any runtime (Node, workerd, bun) |
18
+ | WebSocket | `path.any()` | 101 | `null` + `webSocket` property | Cloudflare Workers (workerd) |
19
+
20
+ - **SSE** is a regular 200 response with `content-type: text/event-stream`
21
+ and a `ReadableStream` body. Works everywhere, flows through middleware
22
+ normally.
23
+ - **WebSocket upgrades** produce a status-101 response with a non-standard
24
+ `webSocket` property (Cloudflare). The router detects these and forwards
25
+ them without reconstruction; `Vary` and `Server-Timing` are skipped, and
26
+ stub headers are merged in place on a best-effort basis.
27
+
28
+ ## Server-Sent Events (SSE)
29
+
30
+ Use `path.stream()` (or `path.any()` if you need full control) to return a
31
+ `ReadableStream`. Each chunk is an `event-stream` frame:
32
+
33
+ ```typescript
34
+ import { urls } from "@rangojs/router";
35
+
36
+ export const urlpatterns = urls(({ path }) => [
37
+ path.stream(
38
+ "/events/ticks",
39
+ (ctx) => {
40
+ const encoder = new TextEncoder();
41
+
42
+ const stream = new ReadableStream({
43
+ async start(controller) {
44
+ let count = 0;
45
+ const interval = setInterval(() => {
46
+ controller.enqueue(
47
+ encoder.encode(`event: tick\ndata: ${++count}\n\n`),
48
+ );
49
+ }, 1000);
50
+
51
+ // Honor client disconnect — signal comes from ctx.request.signal
52
+ ctx.request.signal.addEventListener("abort", () => {
53
+ clearInterval(interval);
54
+ controller.close();
55
+ });
56
+ },
57
+ });
58
+
59
+ return new Response(stream, {
60
+ headers: {
61
+ "content-type": "text/event-stream",
62
+ "cache-control": "no-store",
63
+ // Disable proxy buffering on Nginx/Traefik deployments
64
+ "x-accel-buffering": "no",
65
+ },
66
+ });
67
+ },
68
+ { name: "ticks" },
69
+ ),
70
+ ]);
71
+ ```
72
+
73
+ ### Client
74
+
75
+ ```typescript
76
+ "use client";
77
+ const source = new EventSource("/events/ticks");
78
+ source.addEventListener("tick", (e) => console.log("tick", e.data));
79
+ ```
80
+
81
+ ### SSE caveats
82
+
83
+ - **Never wrap SSE routes in `cache()`** — a cached `ReadableStream` is read
84
+ once and would replay an empty body on the next hit. `path.stream` is
85
+ already excluded from response-route caching, but don't layer a custom
86
+ cache() middleware on top.
87
+ - **Middleware is fine.** Global/route middleware rewraps the SSE `Response`
88
+ as `new Response(response.body, { status, headers })` to merge stub headers.
89
+ The `ReadableStream` body is passed by reference, not consumed, so the
90
+ client sees the stream unchanged. (WebSocket upgrades are the exception —
91
+ those bypass rewrap entirely; see below.)
92
+ - **Honor `ctx.request.signal`.** Without wiring abort to your source
93
+ (timer, DB cursor, upstream fetch), the stream leaks when the client
94
+ disconnects.
95
+ - **Disable Nginx/CDN buffering** via `x-accel-buffering: no` and ensure
96
+ no intermediate proxy rebuffers. On Cloudflare Workers this is a non-issue.
97
+
98
+ ## WebSockets (Cloudflare Workers)
99
+
100
+ WebSocket upgrades on workerd produce a response with `status: 101` and a
101
+ non-standard `webSocket` property. The router detects this shape and forwards
102
+ the `Response` without reconstruction — the 101 status and the `webSocket`
103
+ property are preserved. `Vary` and `Server-Timing` writes are skipped, and
104
+ stub-header merging (cookies/custom headers set via `ctx.header()` or
105
+ `cookies().set()`) is best-effort: the router attempts to apply them in
106
+ place, but silently skips any write rejected by a runtime that exposes
107
+ immutable upgrade headers.
108
+
109
+ ### Minimal upgrade handler
110
+
111
+ ```typescript
112
+ import { urls } from "@rangojs/router";
113
+
114
+ export const urlpatterns = urls(({ path }) => [
115
+ path.any(
116
+ "/ws",
117
+ (ctx) => {
118
+ // Manual WebSocketPair on workerd
119
+ const upgrade = ctx.request.headers.get("upgrade");
120
+ if (upgrade !== "websocket") {
121
+ return new Response("expected upgrade: websocket", { status: 426 });
122
+ }
123
+
124
+ const { 0: client, 1: server } = new WebSocketPair();
125
+ server.accept();
126
+ server.addEventListener("message", (e) => {
127
+ server.send(`echo: ${e.data}`);
128
+ });
129
+
130
+ return new Response(null, {
131
+ status: 101,
132
+ webSocket: client,
133
+ } as ResponseInit);
134
+ },
135
+ { name: "ws" },
136
+ ),
137
+ ]);
138
+ ```
139
+
140
+ ### Durable Object pattern
141
+
142
+ Route into a Durable Object that owns the connection:
143
+
144
+ ```typescript
145
+ export const urlpatterns = urls(({ path }) => [
146
+ path.any(
147
+ "/rooms/:roomId",
148
+ async (ctx) => {
149
+ const id = ctx.env.ROOMS.idFromName(ctx.params.roomId);
150
+ const stub = ctx.env.ROOMS.get(id);
151
+ // The DO's fetch handler calls handleWebSocketUpgrade(request)
152
+ // and returns the 101 Response. We forward it unchanged.
153
+ return stub.fetch(ctx.request);
154
+ },
155
+ { name: "room" },
156
+ ),
157
+ ]);
158
+ ```
159
+
160
+ ### Using the `agents` library
161
+
162
+ `routeAgentRequest` from `agents` returns a 101 `Response` targeted at a
163
+ Durable Object. Return it directly from `path.any()`:
164
+
165
+ ```typescript
166
+ import { routeAgentRequest } from "agents";
167
+ import { urls } from "@rangojs/router";
168
+
169
+ export const urlpatterns = urls(({ path }) => [
170
+ path.any("/agents/*", async (ctx) => {
171
+ const response = await routeAgentRequest(ctx.request, ctx.env);
172
+ if (!response) {
173
+ return new Response("not found", { status: 404 });
174
+ }
175
+ return response;
176
+ }),
177
+ ]);
178
+ ```
179
+
180
+ ## Middleware interaction
181
+
182
+ ### Forwarded, not reconstructed
183
+
184
+ When a middleware is matched for the upgrade URL, the middleware still runs
185
+ **before** `next()` — but the Response from `next()` is forwarded as-is
186
+ rather than re-wrapped. This preserves:
187
+
188
+ - The 101 status (which would otherwise throw `RangeError: Responses may
189
+ only be constructed with status codes in the range 200 to 599, inclusive`
190
+ on standards-compliant runtimes).
191
+ - The Cloudflare `webSocket` property (which would otherwise be silently
192
+ dropped by `new Response(body, ...)` on workerd).
193
+
194
+ ```typescript
195
+ // This works — logger runs, but the 101 flows through unchanged.
196
+ router.use(async (ctx, next) => {
197
+ console.log("ws request", ctx.url.pathname);
198
+ return next();
199
+ });
200
+ ```
201
+
202
+ ### Don't try to set cookies on an upgrade
203
+
204
+ Stub cookie/header writes made before `await next()` are applied to the
205
+ upgrade response on a best-effort basis — the router attempts an in-place
206
+ merge and skips any write rejected by runtimes that expose immutable 101
207
+ headers. Either way, a browser completing a WS handshake never reads them.
208
+ Do not rely on this for auth or state propagation: set cookies via a prior
209
+ HTTP request instead (e.g. during login), then read them at upgrade time
210
+ via `ctx.request.headers.get("cookie")`.
211
+
212
+ ```typescript
213
+ // Avoid: this cookie may not land on the upgrade response, and the client
214
+ // never reads it during the handshake regardless.
215
+ router.use(async (ctx, next) => {
216
+ cookies().set("last-ws-at", Date.now().toString());
217
+ return next();
218
+ });
219
+
220
+ // Prefer: authenticate by reading a cookie set on a prior HTTP request.
221
+ path.any("/ws", (ctx) => {
222
+ const session = parseCookie(ctx.request.headers.get("cookie"))?.session;
223
+ if (!verify(session)) return new Response("unauthorized", { status: 401 });
224
+ // ...upgrade
225
+ });
226
+ ```
227
+
228
+ ### Short-circuit before upgrade
229
+
230
+ Middleware can return a non-101 Response to deny the upgrade outright:
231
+
232
+ ```typescript
233
+ router.use(async (ctx, next) => {
234
+ if (!isAllowed(ctx.request)) {
235
+ return new Response("forbidden", { status: 403 });
236
+ }
237
+ return next();
238
+ });
239
+ ```
240
+
241
+ ## Caching
242
+
243
+ - **SSE** — do not combine with `cache()` (streams can't be replayed).
244
+ - **WebSocket** — `cache()` is inert because only `status === 200` is cacheable.
245
+
246
+ ## Runtime caveats
247
+
248
+ | Runtime | SSE | WebSocket upgrade (101) |
249
+ | -------------------------------------- | --- | ---------------------------------------------------- |
250
+ | Cloudflare Workers (workerd) | OK | OK (native `WebSocketPair`, DO, `agents`) |
251
+ | Node (undici fetch) | OK | N/A — Node's HTTP server must upgrade |
252
+ | Bun | OK | Bun's native `upgrade()` — not a Response-based path |
253
+ | Dev (Vite + `@cloudflare/vite-plugin`) | OK | OK via workerd emulation |
254
+
255
+ When running in pure Node without workerd, a `status: 101` Response cannot
256
+ even be constructed (`new Response(null, { status: 101 })` throws). For
257
+ tests, fabricate upgrade-style responses by overriding `.status` on a real
258
+ Response instance:
259
+
260
+ ```typescript
261
+ const upgrade = new Response(null, { status: 200 });
262
+ Object.defineProperty(upgrade, "status", { value: 101, configurable: true });
263
+ // optional: attach a webSocket stub
264
+ Object.defineProperty(upgrade, "webSocket", {
265
+ value: { stub: "ws" },
266
+ configurable: true,
267
+ enumerable: true,
268
+ });
269
+ ```
270
+
271
+ ## Testing
272
+
273
+ - Unit tests: `isWebSocketUpgradeResponse` and `executeMiddleware` passthrough
274
+ cases live in `src/rsc/__tests__/helpers.test.ts` and
275
+ `src/router/middleware.test.ts`.
276
+ - E2E: cover both dev and production modes against a workerd target. SSE
277
+ can be tested on any runtime; WS upgrades need workerd (use
278
+ `@cloudflare/vite-plugin` or `wrangler dev`).
279
+
280
+ ## See also
281
+
282
+ - `response-routes` — the parent skill for `path.json/text/html/stream/any`.
283
+ - `middleware` — how global and route-level middleware compose with handlers.
@@ -287,6 +287,12 @@ type SP = RouteSearchParams<"search">;
287
287
  type P = RouteParams<"blogPost">;
288
288
  // { slug: string }
289
289
 
290
+ // Optional URL params (`:slug?`) resolve to `string | undefined`
291
+ // because absent segments are omitted from `ctx.params` at runtime.
292
+ type C = RouteParams<"checkout">;
293
+ // { step?: string }
294
+ // → ctx.params.step is `string | undefined`; use `?? "default"` to coalesce.
295
+
290
296
  // Use in component props
291
297
  interface SearchResultsProps {
292
298
  params: RouteSearchParams<"search">;
@@ -462,9 +468,11 @@ export const ProductLoader = createLoader(async (ctx) => {
462
468
  });
463
469
 
464
470
  // Built-in Breadcrumbs — or any custom handle created with createHandle()
471
+ ```
465
472
 
473
+ ```tsx
466
474
  // Client component — typeof infers all generics
467
- ("use client");
475
+ "use client";
468
476
  import { useLoader, useHandle, type Breadcrumbs } from "@rangojs/router/client";
469
477
  import type { ProductLoader } from "../loaders";
470
478
 
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: view-transitions
3
+ description: Configure React View Transitions on layouts, routes, and parallel slots in @rangojs/router
4
+ argument-hint: [layout|route|parallel|intercept]
5
+ ---
6
+
7
+ # View Transitions
8
+
9
+ Rango wires React's experimental `<ViewTransition>` into the segment tree via the `transition()` helper. Each segment can declare its own transition config; rango wraps it at the right tree position so navigations morph the right pieces and modals do not.
10
+
11
+ > Requires React experimental (the build that exports `<ViewTransition>` and `addTransitionType`). With stable React, `transition()` is a no-op — your routes still render, just without view-transition wrappers.
12
+
13
+ ## What `transition()` does
14
+
15
+ `transition(config)` attaches a [`TransitionConfig`](#transitionconfig) to the surrounding entry. Where the wrap actually lands in the rendered React tree depends on the segment type:
16
+
17
+ | Segment type | Wrap location |
18
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | `layout()` | Around the layout's **default outlet content** (what the layout's `<Outlet />` renders), recursively pushed past nested layouts. Parallel slots (`<ParallelOutlet />`) are siblings of the wrap, not subtree members. |
20
+ | `path()` / `route()` | Around the **route's component itself** (the leaf content). |
21
+ | `parallel()` / `intercept()` slot | `transition()` is accepted by the DSL today, but slot-level rendering does not currently apply a `<ViewTransition>` wrapper. Mount intercept slots in layouts so layout transitions stay scoped to the default outlet. For modal-specific morphs today, use an element-level React `<ViewTransition>` inside the modal component. |
22
+
23
+ The layout case is the important one: stacking a layout transition does **not** wrap the layout chrome (header, sidebar, modal slot); it only morphs whatever flows through that layout's `<Outlet />`.
24
+
25
+ ## Basic Usage
26
+
27
+ A simple cross-fade between pages that share a layout:
28
+
29
+ ```tsx
30
+ import { urls } from "@rangojs/router";
31
+ import { Outlet } from "@rangojs/router/client";
32
+
33
+ function ShopShell({ children }: { children: React.ReactNode }) {
34
+ return (
35
+ <div className="shop">
36
+ <NavBar />
37
+ <main>
38
+ <Outlet /> {/* fade applies HERE */}
39
+ </main>
40
+ <Footer />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export const urlpatterns = urls(({ layout, path, transition }) => [
46
+ layout(<ShopShell />, () => [
47
+ transition({ default: "page-fade" }),
48
+ path("/", ShopIndex, { name: "index" }),
49
+ path("/about", AboutPage, { name: "about" }),
50
+ path("/contact", ContactPage, { name: "contact" }),
51
+ ]),
52
+ ]);
53
+ ```
54
+
55
+ ```css
56
+ ::view-transition-old(root) {
57
+ animation: fade-out 200ms ease both;
58
+ }
59
+ ::view-transition-new(root) {
60
+ animation: fade-in 200ms ease both;
61
+ }
62
+ .page-fade {
63
+ /* class hooks per phase */
64
+ }
65
+ ```
66
+
67
+ Navigating between `/`, `/about`, and `/contact` morphs the `<Outlet />` content with the `page-fade` class. The shell (NavBar, Footer) does not morph because the wrap sits inside the shell, not around it.
68
+
69
+ ## Direction-aware transitions
70
+
71
+ `ViewTransitionClass` accepts an object form keyed by transition type. Rango tags forward navigations as `"navigation"` and back/forward popstate as `"navigation-back"`:
72
+
73
+ ```tsx
74
+ layout(<ShopShell />, () => [
75
+ transition({
76
+ default: {
77
+ navigation: "slide-left",
78
+ "navigation-back": "slide-right",
79
+ },
80
+ }),
81
+ path("/", ShopIndex, { name: "index" }),
82
+ path("/about", AboutPage, { name: "about" }),
83
+ ]);
84
+ ```
85
+
86
+ ```css
87
+ .slide-left {
88
+ animation-name: slide-from-right;
89
+ }
90
+ .slide-right {
91
+ animation-name: slide-from-left;
92
+ }
93
+ ```
94
+
95
+ > Note: `"action"` is only tagged on partial-update action/refetch paths today; ordinary `server-action-bridge` commits (`useAction` / `useActionState` revalidations) are not currently tagged. Don't rely on an `action`-keyed class to fire on every form action.
96
+
97
+ ## Wrapper form: applying transition to a group of routes
98
+
99
+ `transition(config, () => [...])` creates a transparent layout that applies the config to its children — useful when you want a transition without authoring a real layout component:
100
+
101
+ ```tsx
102
+ urls(({ path, transition }) => [
103
+ // No layout component, but every route inside gets the fade.
104
+ transition({ default: "fade" }, () => [
105
+ path("/", HomePage, { name: "home" }),
106
+ path("/about", AboutPage, { name: "about" }),
107
+ ]),
108
+ // Outside the wrapper — no transition applied.
109
+ path("/admin", AdminPage, { name: "admin" }),
110
+ ]);
111
+ ```
112
+
113
+ ## Intercept (modal) interaction
114
+
115
+ This is where the rango-specific behavior pays off. A common shape:
116
+
117
+ ```tsx
118
+ import { urls } from "@rangojs/router";
119
+ import { Outlet, ParallelOutlet } from "@rangojs/router/client";
120
+
121
+ function GalleryShell() {
122
+ return (
123
+ <>
124
+ <NavBar />
125
+ <main>
126
+ <Outlet /> {/* page transition lands here */}
127
+ </main>
128
+ <ParallelOutlet name="@modal" />{" "}
129
+ {/* modal mounts here — sibling of the VT */}
130
+ </>
131
+ );
132
+ }
133
+
134
+ export const urlpatterns = urls(
135
+ ({ layout, path, intercept, transition, loader, loading }) => [
136
+ layout(<GalleryShell />, () => [
137
+ transition({ default: "fade" }),
138
+
139
+ path("/", GalleryFeed, { name: "feed" }),
140
+ path("/photos/:id", PhotoPage, { name: "photo" }),
141
+
142
+ intercept("@modal", "photo", <PhotoModal />, () => [
143
+ loader(PhotoLoader),
144
+ loading(<PhotoModalSkeleton />),
145
+ ]),
146
+ ]),
147
+ ],
148
+ );
149
+ ```
150
+
151
+ | Action | What fires |
152
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
153
+ | Navigate `/` ↔ `/about` (within `GalleryShell`) | Layout transition fires; `<Outlet />` content cross-fades |
154
+ | Click `<Link to="/photos/42" />` from `/` | Soft navigation opens `<PhotoModal />` in `@modal`; **no** view transition fires on the underlying feed |
155
+ | Submit a form action inside `<PhotoModal />` | Revalidation commits without firing the layout VT; modal subtree identity is preserved (no remount, `useActionState` survives) |
156
+ | Close modal via `router.back()` | Underlying page is restored; **no** view transition fires |
157
+ | Direct URL load `/photos/42` | Renders the full `<PhotoPage />` with no modal; the layout transition applies on subsequent in-layout navs |
158
+
159
+ The "no VT on modal open" guarantee holds at any depth — if the layout that owns `@modal` is itself nested inside another transitioned layout, the outer transition is pushed past the inner layout into its default outlet content, so the modal slot ends up outside both VTs.
160
+
161
+ ## Per-route transition
162
+
163
+ Routes are leaves: their `transition()` wraps the route component itself.
164
+
165
+ ```tsx
166
+ urls(({ path, transition }) => [
167
+ path("/checkout", CheckoutPage, { name: "checkout" }, () => [
168
+ transition({ default: "fade-in" }),
169
+ ]),
170
+ ]);
171
+ ```
172
+
173
+ This is the right level for one-off route-specific morphs that should not propagate to siblings.
174
+
175
+ ## TransitionConfig
176
+
177
+ `transition()` accepts the props of React's `<ViewTransition>` (minus `children`/refs). Each phase prop accepts either a plain class string or an object keyed by transition type:
178
+
179
+ ```ts
180
+ import type { TransitionConfig } from "@rangojs/router";
181
+
182
+ interface TransitionConfig {
183
+ enter?: string | Record<string, string>;
184
+ exit?: string | Record<string, string>;
185
+ update?: string | Record<string, string>;
186
+ share?: string | Record<string, string>;
187
+ default?: string | Record<string, string>; // fallback for any phase
188
+ name?: string; // explicit view-transition-name
189
+ }
190
+ ```
191
+
192
+ - `default` is the catch-all if a phase-specific prop is unset.
193
+ - The object form keys are React transition types tagged by rango: `"navigation"` (forward navigations), `"navigation-back"` (popstate cache restores), and `"action"` (partial-update action/refetch paths only — see the caveat in "Direction-aware transitions").
194
+ - `name` lets you participate in cross-page morphs by name (advanced; you usually don't need this on a layout/route-level wrap).
195
+
196
+ ## Recommendations
197
+
198
+ **Put `<ParallelOutlet />` in layouts, not routes.** A route-level `transition` wraps the route component itself, so a `<ParallelOutlet />` rendered directly inside that route component remains inside the route VT subtree — modal opens on a route with a parallel outlet _will_ trigger the route's VT walker. The narrowing fix only applies at layout boundaries. If you combine intercept modals with route-level transitions, mount the slot one level up in a layout.
199
+
200
+ **Don't stack `transition()` on every layout level.** When ancestor and descendant layouts both configure transitions, both wraps end up nested around the deepest default outlet content. Two VTs fire on every nav within the inner layout. That's usually not what you want — pick the level where the morph belongs and apply it once.
201
+
202
+ **Need a modal-only morph?** Per-slot `transition()` is currently a no-op at render time, so use an element-level React `<ViewTransition>` inside the modal component (or a CSS animation) for the modal-entrance effect.
203
+
204
+ **Action revalidation inside a modal is safe.** Server-action submits inside an open modal don't fire the underlying layout VT. Modal subtree identity is preserved across revalidation — so `useActionState`, focus, and scroll all survive the round-trip.
205
+
206
+ ## Notes
207
+
208
+ - `transition()` is part of the route DSL. The allow-list table in [skills/handler-use](../handler-use/SKILL.md) permits it inside `layout()`, `path()`/`route()`, `parallel()` (per-slot or shared), and `intercept()`. At render time, only the layout and route wraps actually take effect today; `parallel()`/`intercept()` slot-level rendering does not currently apply the wrap.
209
+ - Wrap location for layouts: rango walks the rendered tree past `MountContextProvider`/`OutletProvider`/`LoaderBoundary` for layout segments and applies the wrap at the first non-layout target ([segment-system.tsx](../../src/segment-system.tsx) — `wrapDefaultOutletContent`). This is what keeps parallel slots out of the VT subtree.
210
+ - Tree consistency: the wrapper structure is identical across normal commits, intercept-active commits, and action revalidations — React never sees an element-type swap, so layout/modal subtrees are not remounted across these transitions.
211
+ - Element-level `<ViewTransition>` (importing it directly from React and using `name`/`share` to morph specific elements across pages) composes with rango's segment-level wraps as usual; rango doesn't intercept those.
212
+ - See also: [skills/intercept](../intercept/SKILL.md), [skills/parallel](../parallel/SKILL.md), [skills/layout](../layout/SKILL.md).
@@ -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
+ }
@@ -113,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
113
113
  export type HandleListener = () => void;
114
114
 
115
115
  /**
116
- * Internal handle state stored in controller
116
+ * Internal handle state stored in controller.
117
+ *
118
+ * Two segment lists are exposed because they serve different consumers:
119
+ *
120
+ * - `segmentOrder` drives handle collection (collectHandleData). Includes
121
+ * parallel slot ids and reorders them after their parent so later-wins
122
+ * collect functions (e.g. Meta) get the right precedence.
123
+ * - `routeSegmentIds` is the layouts-and-routes-only list documented by
124
+ * `useSegments().segmentIds`. Parallels and loader sub-ids are stripped;
125
+ * raw matched order is preserved.
126
+ *
127
+ * Both are derived from the same `matched` input on each setHandleData call
128
+ * so they stay in sync.
117
129
  */
118
130
  export interface HandleState {
119
131
  data: HandleData;
120
132
  segmentOrder: string[];
133
+ routeSegmentIds: string[];
121
134
  }
122
135
 
123
136
  /**
@@ -202,6 +215,14 @@ export interface EventController {
202
215
  data: HandleData,
203
216
  matched?: string[],
204
217
  isPartial?: boolean,
218
+ /**
219
+ * Segment ids that were re-resolved on the server this request (the
220
+ * partial response's `diff`). On a partial update, any existing bucket
221
+ * keyed under one of these ids that has no incoming entry is treated as
222
+ * stale and cleared. Without this, a parallel slot that revalidates but
223
+ * pushes nothing leaves its previous bucket in place forever.
224
+ */
225
+ resolvedIds?: string[],
205
226
  ): void;
206
227
  getHandleState(): HandleState;
207
228
 
@@ -300,6 +321,7 @@ export function createEventController(
300
321
  // Handle data from RSC payload
301
322
  let handleData: HandleData = {};
302
323
  let handleSegmentOrder: string[] = [];
324
+ let routeSegmentIds: string[] = [];
303
325
 
304
326
  // Merged route params from current match
305
327
  let routeParams: Record<string, string> = {};
@@ -744,8 +766,15 @@ export function createEventController(
744
766
  data: HandleData,
745
767
  matched?: string[],
746
768
  isPartial?: boolean,
769
+ resolvedIds?: string[],
747
770
  ): void {
748
- const newSegmentOrder = filterSegmentOrder(matched ?? []);
771
+ const rawMatched = matched ?? [];
772
+ const newSegmentOrder = filterSegmentOrder(rawMatched);
773
+ // Separate list for useSegments(): "layouts and routes only" — strip
774
+ // parallels (".@") and loader sub-ids (D digit) without reordering.
775
+ const newRouteSegmentIds = rawMatched.filter(
776
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
777
+ );
749
778
 
750
779
  if (isPartial && newSegmentOrder.length > 0) {
751
780
  // Partial update: merge new data with existing
@@ -757,10 +786,19 @@ export function createEventController(
757
786
  handleData[handleName][segmentId] = data[handleName][segmentId];
758
787
  }
759
788
  }
760
- // Clean up data from segments no longer in the matched list
789
+ const resolvedIdSet =
790
+ resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
791
+ // Cleanup pass:
792
+ // a) segment dropped from the match list — delete its bucket.
793
+ // b) segment was re-resolved this request but pushed nothing for
794
+ // this handle — its previous bucket is stale.
795
+ // (a) is the existing behavior; (b) requires resolvedIds.
761
796
  for (const handleName of Object.keys(handleData)) {
762
797
  for (const segmentId of Object.keys(handleData[handleName])) {
763
- if (!newSegmentOrder.includes(segmentId)) {
798
+ const droppedFromMatch = !newSegmentOrder.includes(segmentId);
799
+ const reresolvedWithoutPush =
800
+ resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
801
+ if (droppedFromMatch || reresolvedWithoutPush) {
764
802
  delete handleData[handleName][segmentId];
765
803
  }
766
804
  }
@@ -770,6 +808,7 @@ export function createEventController(
770
808
  handleData = data;
771
809
  }
772
810
  handleSegmentOrder = newSegmentOrder;
811
+ routeSegmentIds = newRouteSegmentIds;
773
812
 
774
813
  notifyHandles();
775
814
  }
@@ -778,6 +817,7 @@ export function createEventController(
778
817
  return {
779
818
  data: handleData,
780
819
  segmentOrder: handleSegmentOrder,
820
+ routeSegmentIds,
781
821
  };
782
822
  }
783
823