@rangojs/router 0.0.0-experimental.8678bb02 → 0.0.0-experimental.87

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 (147) hide show
  1. package/README.md +126 -38
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +847 -384
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +5 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/hooks/SKILL.md +28 -20
  10. package/skills/intercept/SKILL.md +20 -0
  11. package/skills/layout/SKILL.md +22 -0
  12. package/skills/links/SKILL.md +91 -17
  13. package/skills/loader/SKILL.md +35 -2
  14. package/skills/middleware/SKILL.md +34 -3
  15. package/skills/migrate-nextjs/SKILL.md +560 -0
  16. package/skills/migrate-react-router/SKILL.md +765 -0
  17. package/skills/parallel/SKILL.md +59 -0
  18. package/skills/prerender/SKILL.md +110 -68
  19. package/skills/rango/SKILL.md +24 -22
  20. package/skills/response-routes/SKILL.md +8 -0
  21. package/skills/route/SKILL.md +24 -0
  22. package/skills/router-setup/SKILL.md +35 -0
  23. package/skills/streams-and-websockets/SKILL.md +283 -0
  24. package/skills/typesafety/SKILL.md +3 -1
  25. package/src/__internal.ts +1 -1
  26. package/src/browser/app-shell.ts +52 -0
  27. package/src/browser/app-version.ts +14 -0
  28. package/src/browser/navigation-bridge.ts +87 -6
  29. package/src/browser/navigation-client.ts +128 -77
  30. package/src/browser/navigation-store.ts +68 -9
  31. package/src/browser/partial-update.ts +60 -7
  32. package/src/browser/prefetch/cache.ts +129 -21
  33. package/src/browser/prefetch/fetch.ts +156 -18
  34. package/src/browser/prefetch/queue.ts +36 -5
  35. package/src/browser/rango-state.ts +53 -13
  36. package/src/browser/react/Link.tsx +72 -8
  37. package/src/browser/react/NavigationProvider.tsx +57 -11
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-navigation.ts +22 -2
  41. package/src/browser/react/use-params.ts +11 -1
  42. package/src/browser/react/use-router.ts +29 -9
  43. package/src/browser/rsc-router.tsx +60 -9
  44. package/src/browser/scroll-restoration.ts +10 -8
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/server-action-bridge.ts +8 -18
  47. package/src/browser/types.ts +33 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +211 -72
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cf/cf-cache-store.ts +5 -7
  55. package/src/client.tsx +84 -230
  56. package/src/deps/browser.ts +0 -1
  57. package/src/handle.ts +40 -0
  58. package/src/index.rsc.ts +6 -1
  59. package/src/index.ts +49 -6
  60. package/src/outlet-context.ts +1 -1
  61. package/src/prerender/store.ts +5 -4
  62. package/src/prerender.ts +138 -77
  63. package/src/response-utils.ts +28 -0
  64. package/src/reverse.ts +27 -2
  65. package/src/route-definition/dsl-helpers.ts +210 -35
  66. package/src/route-definition/helpers-types.ts +61 -14
  67. package/src/route-definition/index.ts +3 -0
  68. package/src/route-definition/redirect.ts +9 -1
  69. package/src/route-definition/resolve-handler-use.ts +155 -0
  70. package/src/route-types.ts +18 -0
  71. package/src/router/content-negotiation.ts +100 -1
  72. package/src/router/handler-context.ts +70 -17
  73. package/src/router/intercept-resolution.ts +9 -4
  74. package/src/router/lazy-includes.ts +6 -6
  75. package/src/router/loader-resolution.ts +153 -21
  76. package/src/router/manifest.ts +22 -13
  77. package/src/router/match-api.ts +127 -192
  78. package/src/router/match-middleware/cache-lookup.ts +28 -8
  79. package/src/router/match-middleware/segment-resolution.ts +53 -0
  80. package/src/router/match-result.ts +82 -4
  81. package/src/router/middleware-types.ts +2 -28
  82. package/src/router/middleware.ts +32 -7
  83. package/src/router/navigation-snapshot.ts +182 -0
  84. package/src/router/pattern-matching.ts +60 -9
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-interfaces.ts +36 -4
  90. package/src/router/router-options.ts +37 -11
  91. package/src/router/segment-resolution/fresh.ts +70 -5
  92. package/src/router/segment-resolution/revalidation.ts +87 -9
  93. package/src/router/trie-matching.ts +10 -4
  94. package/src/router/url-params.ts +49 -0
  95. package/src/router.ts +54 -7
  96. package/src/rsc/handler.ts +478 -399
  97. package/src/rsc/helpers.ts +69 -41
  98. package/src/rsc/loader-fetch.ts +18 -3
  99. package/src/rsc/manifest-init.ts +5 -1
  100. package/src/rsc/progressive-enhancement.ts +14 -3
  101. package/src/rsc/response-route-handler.ts +14 -1
  102. package/src/rsc/rsc-rendering.ts +15 -2
  103. package/src/rsc/server-action.ts +10 -2
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +6 -4
  106. package/src/segment-content-promise.ts +67 -0
  107. package/src/segment-loader-promise.ts +122 -0
  108. package/src/segment-system.tsx +11 -61
  109. package/src/server/context.ts +65 -5
  110. package/src/server/handle-store.ts +19 -0
  111. package/src/server/loader-registry.ts +9 -8
  112. package/src/server/request-context.ts +142 -55
  113. package/src/ssr/index.tsx +3 -0
  114. package/src/static-handler.ts +18 -6
  115. package/src/types/cache-types.ts +4 -4
  116. package/src/types/handler-context.ts +17 -43
  117. package/src/types/loader-types.ts +37 -11
  118. package/src/types/request-scope.ts +126 -0
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +1 -1
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +47 -12
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +18 -16
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  134. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  135. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  136. package/src/vite/plugins/expose-id-utils.ts +12 -0
  137. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  138. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  139. package/src/vite/plugins/performance-tracks.ts +64 -206
  140. package/src/vite/plugins/refresh-cmd.ts +88 -26
  141. package/src/vite/rango.ts +40 -18
  142. package/src/vite/router-discovery.ts +237 -37
  143. package/src/vite/utils/banner.ts +1 -1
  144. package/src/vite/utils/package-resolution.ts +1 -1
  145. package/src/vite/utils/prerender-utils.ts +37 -5
  146. package/src/vite/utils/shared-utils.ts +3 -2
  147. package/src/browser/debug-channel.ts +0 -93
@@ -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.
@@ -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
 
package/src/__internal.ts CHANGED
@@ -225,7 +225,7 @@ export type {
225
225
  * @internal
226
226
  * Type guard for prerender handler definitions.
227
227
  */
228
- export { isPrerenderHandler } from "./prerender.js";
228
+ export { isPrerenderHandler, isPassthroughHandler } from "./prerender.js";
229
229
 
230
230
  /**
231
231
  * @internal
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mutable app version — updated after HMR revalidation.
3
+ * Read by prefetch, navigation, and context code.
4
+ */
5
+
6
+ let currentVersion: string | undefined;
7
+
8
+ export function getAppVersion(): string | undefined {
9
+ return currentVersion;
10
+ }
11
+
12
+ export function setAppVersion(version: string | undefined): void {
13
+ currentVersion = version;
14
+ }
@@ -4,6 +4,9 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
8
+ import { setRangoStateLocal } from "./rango-state.js";
9
+ import type { AppShell, AppShellRef } from "./app-shell.js";
7
10
  import * as React from "react";
8
11
  import { startTransition } from "react";
9
12
  import {
@@ -47,8 +50,13 @@ export { createNavigationTransaction };
47
50
  */
48
51
  export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
49
52
  eventController: EventController;
50
- /** RSC version from initial payload metadata */
53
+ /** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
51
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;
52
60
  }
53
61
 
54
62
  /**
@@ -67,8 +75,45 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
75
  export function createNavigationBridge(
68
76
  config: NavigationBridgeConfigWithController,
69
77
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
78
+ const {
79
+ store,
80
+ client,
81
+ eventController,
82
+ onUpdate,
83
+ renderSegments,
84
+ appShellRef,
85
+ } = config;
86
+ let version = config.version;
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
+ }
72
117
 
73
118
  // Create shared partial updater
74
119
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +121,8 @@ export function createNavigationBridge(
76
121
  client,
77
122
  onUpdate,
78
123
  renderSegments,
79
- version,
124
+ getVersion: () => version,
125
+ applyAppShell,
80
126
  });
81
127
 
82
128
  return {
@@ -260,18 +306,24 @@ export function createNavigationBridge(
260
306
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
261
307
  // 3. when leaving intercept - we need fresh non-intercept segments from server
262
308
  // 4. redirect-with-state - force re-render so hooks read fresh state
309
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
263
310
  const hasUsableCache =
264
311
  cachedSegments &&
265
312
  cachedSegments.length > 0 &&
266
313
  !isInterceptOnlyCache(cachedSegments) &&
267
314
  !hasInterceptCache &&
268
315
  !isLeavingIntercept &&
316
+ !cached?.stale &&
269
317
  !options?._skipCache;
270
318
 
319
+ // Forward navigations always await fetchPartialUpdate before rendering,
320
+ // so useNavigation should always report "loading". skipLoadingState is
321
+ // only used for popstate background revalidation (line ~526) where
322
+ // cached content renders instantly without a network wait.
271
323
  const tx = createNavigationTransaction(store, eventController, url, {
272
324
  ...options,
273
325
  state: resolvedState,
274
- skipLoadingState: hasUsableCache,
326
+ skipLoadingState: false,
275
327
  });
276
328
 
277
329
  // REVALIDATE: Fetch fresh data from server
@@ -411,6 +463,15 @@ export function createNavigationBridge(
411
463
  eventController.abortAllActions();
412
464
  }
413
465
 
466
+ // Popstate that exits an intercept to a non-intercept destination. The
467
+ // fallback fetch path below needs `leave-intercept` mode so it filters
468
+ // the cached @modal segment from the request and forces a re-render —
469
+ // otherwise a cache-miss popstate whose server response has an empty
470
+ // diff hits the "no changes" branch in partial-update and the modal
471
+ // stays on screen.
472
+ const isLeavingIntercept =
473
+ !isIntercept && currentInterceptSource !== null;
474
+
414
475
  // Compute history key from URL (with intercept suffix if applicable)
415
476
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
416
477
 
@@ -447,6 +508,12 @@ export function createNavigationBridge(
447
508
  store.setCurrentUrl(url);
448
509
  store.setPath(new URL(url).pathname);
449
510
 
511
+ // Restore router identity from cache so subsequent navigations
512
+ // don't falsely detect an app switch.
513
+ if (cached?.routerId) {
514
+ store.setRouterId?.(cached.routerId);
515
+ }
516
+
450
517
  // Render from cache - force await to skip loading fallbacks
451
518
  try {
452
519
  const root = await renderSegments(cachedSegments, {
@@ -555,7 +622,11 @@ export function createNavigationBridge(
555
622
  intercept: isIntercept,
556
623
  interceptSourceUrl,
557
624
  }),
558
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
625
+ isIntercept
626
+ ? { type: "navigate", interceptSourceUrl }
627
+ : isLeavingIntercept
628
+ ? { type: "leave-intercept" }
629
+ : undefined,
559
630
  );
560
631
  // Restore scroll position after fetch completes
561
632
  handleNavigationEnd({ restore: true, isStreaming });
@@ -632,6 +703,16 @@ export function createNavigationBridge(
632
703
  window.removeEventListener("pageshow", handlePageShow);
633
704
  };
634
705
  },
706
+
707
+ updateVersion(newVersion: string): void {
708
+ version = newVersion;
709
+ setAppVersion(newVersion);
710
+ store.clearHistoryCache();
711
+ },
712
+
713
+ updateAppShell(next: AppShell): void {
714
+ applyAppShell(next);
715
+ },
635
716
  };
636
717
  }
637
718