@rangojs/router 0.0.0-experimental.b9cb8739 → 0.0.0-experimental.bd6e11bc

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 (285) hide show
  1. package/README.md +196 -43
  2. package/dist/bin/rango.js +277 -99
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2779 -1064
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +57 -11
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +155 -6
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +249 -17
  21. package/skills/loader/SKILL.md +273 -53
  22. package/skills/middleware/SKILL.md +49 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +197 -6
  28. package/skills/prerender/SKILL.md +123 -100
  29. package/skills/rango/SKILL.md +242 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +88 -4
  33. package/skills/router-setup/SKILL.md +90 -5
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +716 -0
  37. package/skills/typesafety/SKILL.md +329 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +117 -0
  42. package/src/__internal.ts +1 -1
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/navigation-bridge.ts +102 -16
  50. package/src/browser/navigation-client.ts +164 -59
  51. package/src/browser/navigation-store.ts +75 -17
  52. package/src/browser/navigation-transaction.ts +21 -37
  53. package/src/browser/partial-update.ts +139 -38
  54. package/src/browser/prefetch/cache.ts +175 -15
  55. package/src/browser/prefetch/fetch.ts +180 -33
  56. package/src/browser/prefetch/queue.ts +123 -20
  57. package/src/browser/prefetch/resource-ready.ts +77 -0
  58. package/src/browser/rango-state.ts +53 -13
  59. package/src/browser/react/Link.tsx +81 -9
  60. package/src/browser/react/NavigationProvider.tsx +110 -33
  61. package/src/browser/react/context.ts +7 -2
  62. package/src/browser/react/filter-segment-order.ts +51 -7
  63. package/src/browser/react/index.ts +3 -0
  64. package/src/browser/react/location-state-shared.ts +175 -4
  65. package/src/browser/react/location-state.ts +39 -13
  66. package/src/browser/react/use-handle.ts +23 -64
  67. package/src/browser/react/use-navigation.ts +22 -2
  68. package/src/browser/react/use-params.ts +20 -8
  69. package/src/browser/react/use-reverse.ts +106 -0
  70. package/src/browser/react/use-router.ts +43 -10
  71. package/src/browser/react/use-segments.ts +11 -8
  72. package/src/browser/response-adapter.ts +25 -0
  73. package/src/browser/rsc-router.tsx +191 -74
  74. package/src/browser/scroll-restoration.ts +41 -14
  75. package/src/browser/segment-reconciler.ts +36 -9
  76. package/src/browser/segment-structure-assert.ts +2 -2
  77. package/src/browser/server-action-bridge.ts +31 -36
  78. package/src/browser/types.ts +57 -5
  79. package/src/build/collect-fallback-refs.ts +107 -0
  80. package/src/build/generate-manifest.ts +65 -40
  81. package/src/build/generate-route-types.ts +5 -0
  82. package/src/build/index.ts +2 -0
  83. package/src/build/route-trie.ts +52 -25
  84. package/src/build/route-types/codegen.ts +4 -4
  85. package/src/build/route-types/include-resolution.ts +9 -2
  86. package/src/build/route-types/per-module-writer.ts +7 -4
  87. package/src/build/route-types/router-processing.ts +278 -88
  88. package/src/build/route-types/scan-filter.ts +9 -2
  89. package/src/build/route-types/source-scan.ts +118 -0
  90. package/src/build/runtime-discovery.ts +9 -20
  91. package/src/cache/cache-runtime.ts +15 -11
  92. package/src/cache/cache-scope.ts +76 -49
  93. package/src/cache/cf/cf-cache-store.ts +501 -18
  94. package/src/cache/cf/index.ts +5 -1
  95. package/src/cache/document-cache.ts +17 -7
  96. package/src/cache/index.ts +1 -0
  97. package/src/cache/taint.ts +55 -0
  98. package/src/client.rsc.tsx +3 -0
  99. package/src/client.tsx +94 -238
  100. package/src/context-var.ts +72 -2
  101. package/src/debug.ts +2 -2
  102. package/src/decode-loader-results.ts +36 -0
  103. package/src/errors.ts +30 -1
  104. package/src/handle.ts +65 -12
  105. package/src/host/index.ts +2 -2
  106. package/src/host/router.ts +129 -57
  107. package/src/host/types.ts +31 -2
  108. package/src/host/utils.ts +1 -1
  109. package/src/href-client.ts +140 -20
  110. package/src/index.rsc.ts +12 -5
  111. package/src/index.ts +61 -11
  112. package/src/loader-store.ts +500 -0
  113. package/src/loader.rsc.ts +2 -5
  114. package/src/loader.ts +3 -10
  115. package/src/missing-id-error.ts +68 -0
  116. package/src/outlet-context.ts +1 -1
  117. package/src/prerender/store.ts +5 -4
  118. package/src/prerender.ts +141 -80
  119. package/src/response-utils.ts +37 -0
  120. package/src/reverse.ts +65 -15
  121. package/src/route-content-wrapper.tsx +6 -28
  122. package/src/route-definition/dsl-helpers.ts +435 -260
  123. package/src/route-definition/helper-factories.ts +29 -139
  124. package/src/route-definition/helpers-types.ts +110 -34
  125. package/src/route-definition/index.ts +3 -0
  126. package/src/route-definition/redirect.ts +11 -3
  127. package/src/route-definition/resolve-handler-use.ts +155 -0
  128. package/src/route-definition/use-item-types.ts +32 -0
  129. package/src/route-map-builder.ts +7 -1
  130. package/src/route-types.ts +37 -41
  131. package/src/router/basename.ts +14 -0
  132. package/src/router/content-negotiation.ts +113 -1
  133. package/src/router/error-handling.ts +1 -1
  134. package/src/router/find-match.ts +4 -2
  135. package/src/router/handler-context.ts +77 -38
  136. package/src/router/intercept-resolution.ts +15 -22
  137. package/src/router/lazy-includes.ts +12 -9
  138. package/src/router/loader-resolution.ts +174 -22
  139. package/src/router/logging.ts +5 -2
  140. package/src/router/manifest.ts +31 -16
  141. package/src/router/match-api.ts +128 -192
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/background-revalidation.ts +30 -2
  144. package/src/router/match-middleware/cache-lookup.ts +136 -106
  145. package/src/router/match-middleware/cache-store.ts +54 -10
  146. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  147. package/src/router/match-middleware/segment-resolution.ts +61 -5
  148. package/src/router/match-result.ts +125 -10
  149. package/src/router/metrics.ts +7 -2
  150. package/src/router/middleware-types.ts +21 -34
  151. package/src/router/middleware.ts +103 -90
  152. package/src/router/navigation-snapshot.ts +182 -0
  153. package/src/router/pattern-matching.ts +101 -17
  154. package/src/router/prerender-match.ts +110 -10
  155. package/src/router/preview-match.ts +32 -102
  156. package/src/router/request-classification.ts +286 -0
  157. package/src/router/revalidation.ts +58 -2
  158. package/src/router/route-snapshot.ts +245 -0
  159. package/src/router/router-context.ts +6 -1
  160. package/src/router/router-interfaces.ts +77 -28
  161. package/src/router/router-options.ts +76 -11
  162. package/src/router/router-registry.ts +2 -5
  163. package/src/router/segment-resolution/fresh.ts +223 -24
  164. package/src/router/segment-resolution/helpers.ts +29 -24
  165. package/src/router/segment-resolution/loader-cache.ts +1 -0
  166. package/src/router/segment-resolution/revalidation.ts +466 -285
  167. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  168. package/src/router/segment-wrappers.ts +2 -0
  169. package/src/router/substitute-pattern-params.ts +56 -0
  170. package/src/router/telemetry.ts +99 -0
  171. package/src/router/trie-matching.ts +18 -13
  172. package/src/router/types.ts +9 -0
  173. package/src/router/url-params.ts +49 -0
  174. package/src/router.ts +91 -23
  175. package/src/rsc/handler-context.ts +2 -2
  176. package/src/rsc/handler.ts +440 -381
  177. package/src/rsc/helpers.ts +91 -43
  178. package/src/rsc/index.ts +1 -1
  179. package/src/rsc/loader-fetch.ts +23 -3
  180. package/src/rsc/manifest-init.ts +5 -1
  181. package/src/rsc/origin-guard.ts +28 -10
  182. package/src/rsc/progressive-enhancement.ts +18 -2
  183. package/src/rsc/response-route-handler.ts +46 -53
  184. package/src/rsc/rsc-rendering.ts +41 -48
  185. package/src/rsc/runtime-warnings.ts +9 -10
  186. package/src/rsc/server-action.ts +25 -37
  187. package/src/rsc/ssr-setup.ts +18 -2
  188. package/src/rsc/types.ts +17 -3
  189. package/src/search-params.ts +4 -4
  190. package/src/segment-content-promise.ts +67 -0
  191. package/src/segment-loader-promise.ts +122 -0
  192. package/src/segment-system.tsx +219 -67
  193. package/src/serialize.ts +243 -0
  194. package/src/server/context.ts +277 -61
  195. package/src/server/cookie-store.ts +28 -4
  196. package/src/server/handle-store.ts +19 -0
  197. package/src/server/loader-registry.ts +9 -8
  198. package/src/server/request-context.ts +204 -60
  199. package/src/ssr/index.tsx +9 -1
  200. package/src/static-handler.ts +19 -7
  201. package/src/testing/cache-status.ts +166 -0
  202. package/src/testing/collect-handle.ts +63 -0
  203. package/src/testing/dispatch.ts +440 -0
  204. package/src/testing/dom.entry.ts +22 -0
  205. package/src/testing/e2e/fixture.ts +154 -0
  206. package/src/testing/e2e/index.ts +149 -0
  207. package/src/testing/e2e/matchers.ts +51 -0
  208. package/src/testing/e2e/page-helpers.ts +272 -0
  209. package/src/testing/e2e/parity.ts +306 -0
  210. package/src/testing/e2e/server.ts +183 -0
  211. package/src/testing/flight-matchers.ts +104 -0
  212. package/src/testing/flight-runtime.d.ts +21 -0
  213. package/src/testing/flight.entry.ts +22 -0
  214. package/src/testing/flight.ts +182 -0
  215. package/src/testing/generated-routes.ts +223 -0
  216. package/src/testing/index.ts +106 -0
  217. package/src/testing/internal/context.ts +255 -0
  218. package/src/testing/render-route.tsx +565 -0
  219. package/src/testing/run-loader.ts +296 -0
  220. package/src/testing/run-middleware.ts +179 -0
  221. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  222. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  223. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  224. package/src/testing/vitest-stubs/version.ts +5 -0
  225. package/src/testing/vitest.ts +183 -0
  226. package/src/types/cache-types.ts +4 -4
  227. package/src/types/global-namespace.ts +39 -26
  228. package/src/types/handler-context.ts +194 -72
  229. package/src/types/index.ts +1 -0
  230. package/src/types/loader-types.ts +41 -15
  231. package/src/types/request-scope.ts +126 -0
  232. package/src/types/route-entry.ts +19 -1
  233. package/src/types/segments.ts +37 -1
  234. package/src/urls/include-helper.ts +34 -67
  235. package/src/urls/index.ts +0 -3
  236. package/src/urls/path-helper-types.ts +50 -9
  237. package/src/urls/path-helper.ts +63 -63
  238. package/src/urls/pattern-types.ts +48 -19
  239. package/src/urls/response-types.ts +25 -22
  240. package/src/urls/type-extraction.ts +26 -116
  241. package/src/urls/urls-function.ts +1 -5
  242. package/src/use-loader.tsx +487 -44
  243. package/src/vite/debug.ts +185 -0
  244. package/src/vite/discovery/bundle-postprocess.ts +34 -37
  245. package/src/vite/discovery/discover-routers.ts +105 -51
  246. package/src/vite/discovery/discovery-errors.ts +194 -0
  247. package/src/vite/discovery/gate-state.ts +171 -0
  248. package/src/vite/discovery/prerender-collection.ts +188 -93
  249. package/src/vite/discovery/route-types-writer.ts +40 -84
  250. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  251. package/src/vite/discovery/state.ts +46 -6
  252. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  253. package/src/vite/index.ts +6 -0
  254. package/src/vite/plugin-types.ts +111 -72
  255. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  256. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  257. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  258. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  259. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  260. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  261. package/src/vite/plugins/expose-action-id.ts +55 -33
  262. package/src/vite/plugins/expose-id-utils.ts +24 -8
  263. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  264. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  265. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  266. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  267. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  268. package/src/vite/plugins/performance-tracks.ts +92 -0
  269. package/src/vite/plugins/refresh-cmd.ts +88 -26
  270. package/src/vite/plugins/use-cache-transform.ts +65 -50
  271. package/src/vite/plugins/version-injector.ts +39 -23
  272. package/src/vite/plugins/version-plugin.ts +72 -3
  273. package/src/vite/plugins/virtual-entries.ts +2 -2
  274. package/src/vite/rango.ts +265 -226
  275. package/src/vite/router-discovery.ts +920 -137
  276. package/src/vite/utils/ast-handler-extract.ts +15 -15
  277. package/src/vite/utils/banner.ts +4 -4
  278. package/src/vite/utils/bundle-analysis.ts +4 -2
  279. package/src/vite/utils/client-chunks.ts +190 -0
  280. package/src/vite/utils/forward-user-plugins.ts +193 -0
  281. package/src/vite/utils/manifest-utils.ts +21 -5
  282. package/src/vite/utils/package-resolution.ts +41 -1
  283. package/src/vite/utils/prerender-utils.ts +38 -5
  284. package/src/vite/utils/shared-utils.ts +109 -27
  285. package/src/browser/action-response-classifier.ts +0 -99
@@ -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
+ }
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
79
79
  state: "idle" | "loading";
80
80
  /** Whether any operation is streaming */
81
81
  isStreaming: boolean;
82
+ /** Whether a navigation is active (fetching or streaming, before commit) */
83
+ isNavigating: boolean;
82
84
  /** Current committed location */
83
85
  location: NavigationLocation;
84
86
  /** URL being navigated to (null if idle) */
@@ -111,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
111
113
  export type HandleListener = () => void;
112
114
 
113
115
  /**
114
- * 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.
115
129
  */
116
130
  export interface HandleState {
117
131
  data: HandleData;
118
132
  segmentOrder: string[];
133
+ routeSegmentIds: string[];
119
134
  }
120
135
 
121
136
  /**
@@ -200,6 +215,14 @@ export interface EventController {
200
215
  data: HandleData,
201
216
  matched?: string[],
202
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[],
203
226
  ): void;
204
227
  getHandleState(): HandleState;
205
228
 
@@ -245,6 +268,20 @@ function matchesActionId(
245
268
  return entryActionId.endsWith(`#${subscriptionId}`);
246
269
  }
247
270
 
271
+ // Coalesce rapid notifications into one microtask-deferred fan-out; the
272
+ // setTimeout(0) batching prevents render storms. Each notifier owns its timer
273
+ // so listener kinds coalesce independently.
274
+ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
275
+ let timeout: ReturnType<typeof setTimeout> | null = null;
276
+ return () => {
277
+ if (timeout !== null) clearTimeout(timeout);
278
+ timeout = setTimeout(() => {
279
+ timeout = null;
280
+ listeners.forEach((listener) => listener());
281
+ }, 0);
282
+ };
283
+ }
284
+
248
285
  // ============================================================================
249
286
  // Implementation
250
287
  // ============================================================================
@@ -298,6 +335,7 @@ export function createEventController(
298
335
  // Handle data from RSC payload
299
336
  let handleData: HandleData = {};
300
337
  let handleSegmentOrder: string[] = [];
338
+ let routeSegmentIds: string[] = [];
301
339
 
302
340
  // Merged route params from current match
303
341
  let routeParams: Record<string, string> = {};
@@ -310,18 +348,7 @@ export function createEventController(
310
348
  const actionListeners = new Map<string, Set<ActionStateListener>>();
311
349
  const handleListeners = new Set<HandleListener>();
312
350
 
313
- // Debounce state notifications to batch rapid updates
314
- let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
315
-
316
- function notify() {
317
- if (notifyTimeout !== null) {
318
- clearTimeout(notifyTimeout);
319
- }
320
- notifyTimeout = setTimeout(() => {
321
- notifyTimeout = null;
322
- stateListeners.forEach((listener) => listener());
323
- }, 0);
324
- }
351
+ const notify = makeDebouncedNotifier(stateListeners);
325
352
 
326
353
  // Debounce per-action notifications
327
354
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -347,18 +374,7 @@ export function createEventController(
347
374
  );
348
375
  }
349
376
 
350
- // Debounce handle notifications
351
- let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
352
-
353
- function notifyHandles() {
354
- if (handleNotifyTimeout !== null) {
355
- clearTimeout(handleNotifyTimeout);
356
- }
357
- handleNotifyTimeout = setTimeout(() => {
358
- handleNotifyTimeout = null;
359
- handleListeners.forEach((listener) => listener());
360
- }, 0);
361
- }
377
+ const notifyHandles = makeDebouncedNotifier(handleListeners);
362
378
 
363
379
  // ========================================================================
364
380
  // Derived State
@@ -389,6 +405,9 @@ export function createEventController(
389
405
  return {
390
406
  state,
391
407
  isStreaming,
408
+ // True when a navigation is active (fetching or streaming, before
409
+ // commit). Broader than pendingUrl which clears during streaming.
410
+ isNavigating: currentNavigation !== null,
392
411
  location,
393
412
  // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
413
  // Background revalidations (skipLoadingState) don't expose a pending URL.
@@ -402,22 +421,17 @@ export function createEventController(
402
421
  }
403
422
 
404
423
  function getActionState(actionId: string): TrackedActionState {
405
- // Find the most recent action with this ID that's not settling
406
- // Uses suffix matching when actionId is just a name (no #)
407
- const activeEntry = [...inflightActions.values()]
408
- .filter(
409
- (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
410
- )
411
- .sort((a, b) => b.startedAt - a.startedAt)[0];
412
-
413
- // Also check for settling entries to get result/error
414
- const settlingEntry = [...inflightActions.values()]
415
- .filter(
416
- (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
417
- )
418
- .sort((a, b) => b.startedAt - a.startedAt)[0];
419
-
420
- const entry = activeEntry || settlingEntry;
424
+ // Prefer the most-recent non-settling entry; fall back to most-recent
425
+ // settling so a just-settled action's result/error stays readable.
426
+ const entry = [...inflightActions.values()]
427
+ .filter((a) => matchesActionId(actionId, a.actionId))
428
+ .reduce<ActionEntry | undefined>((best, a) => {
429
+ if (!best) return a;
430
+ const aActive = a.phase !== "settling";
431
+ const bActive = best.phase !== "settling";
432
+ if (aActive !== bActive) return aActive ? a : best;
433
+ return a.startedAt > best.startedAt ? a : best;
434
+ }, undefined);
421
435
 
422
436
  if (!entry) {
423
437
  return { ...DEFAULT_ACTION_STATE };
@@ -605,6 +619,19 @@ export function createEventController(
605
619
  doSettle();
606
620
  }
607
621
 
622
+ // streamingEnded is forced here for the "streaming never started" case so
623
+ // tryFinalize can run; otherwise the streaming token's end() finalizes.
624
+ function settleWith(result: NonNullable<typeof pendingResult>) {
625
+ if (!inflightActions.has(id) || settled) return;
626
+ actionCompleted = true;
627
+ entry.completed = true;
628
+ pendingResult = result;
629
+ if (entry.phase === "fetching" || streamingEnded) {
630
+ streamingEnded = true;
631
+ tryFinalize();
632
+ }
633
+ }
634
+
608
635
  return {
609
636
  id,
610
637
  abort,
@@ -641,35 +668,11 @@ export function createEventController(
641
668
  },
642
669
 
643
670
  complete(result?: unknown) {
644
- if (!inflightActions.has(id) || settled) return;
645
-
646
- actionCompleted = true;
647
- entry.completed = true;
648
- pendingResult = { type: "success", value: result };
649
-
650
- // If streaming never started or already ended, finalize immediately
651
- // Otherwise wait for streaming to end
652
- if (entry.phase === "fetching" || streamingEnded) {
653
- streamingEnded = true; // Mark as ended if never started
654
- tryFinalize();
655
- }
656
- // If streaming is in progress, tryFinalize() will be called when streaming ends
671
+ settleWith({ type: "success", value: result });
657
672
  },
658
673
 
659
674
  fail(error: unknown) {
660
- if (!inflightActions.has(id) || settled) return;
661
-
662
- actionCompleted = true;
663
- entry.completed = true;
664
- pendingResult = { type: "error", value: error };
665
-
666
- // If streaming never started or already ended, finalize immediately
667
- // Otherwise wait for streaming to end
668
- if (entry.phase === "fetching" || streamingEnded) {
669
- streamingEnded = true; // Mark as ended if never started
670
- tryFinalize();
671
- }
672
- // If streaming is in progress, tryFinalize() will be called when streaming ends
675
+ settleWith({ type: "error", value: error });
673
676
  },
674
677
 
675
678
  getRevalidatedSegments(): Set<string> {
@@ -739,8 +742,15 @@ export function createEventController(
739
742
  data: HandleData,
740
743
  matched?: string[],
741
744
  isPartial?: boolean,
745
+ resolvedIds?: string[],
742
746
  ): void {
743
- const newSegmentOrder = filterSegmentOrder(matched ?? []);
747
+ const rawMatched = matched ?? [];
748
+ const newSegmentOrder = filterSegmentOrder(rawMatched);
749
+ // Separate list for useSegments(): "layouts and routes only" — strip
750
+ // parallels (".@") and loader sub-ids (D digit) without reordering.
751
+ const newRouteSegmentIds = rawMatched.filter(
752
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
753
+ );
744
754
 
745
755
  if (isPartial && newSegmentOrder.length > 0) {
746
756
  // Partial update: merge new data with existing
@@ -752,10 +762,19 @@ export function createEventController(
752
762
  handleData[handleName][segmentId] = data[handleName][segmentId];
753
763
  }
754
764
  }
755
- // Clean up data from segments no longer in the matched list
765
+ const resolvedIdSet =
766
+ resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
767
+ // Cleanup pass:
768
+ // a) segment dropped from the match list — delete its bucket.
769
+ // b) segment was re-resolved this request but pushed nothing for
770
+ // this handle — its previous bucket is stale.
771
+ // (a) is the existing behavior; (b) requires resolvedIds.
756
772
  for (const handleName of Object.keys(handleData)) {
757
773
  for (const segmentId of Object.keys(handleData[handleName])) {
758
- if (!newSegmentOrder.includes(segmentId)) {
774
+ const droppedFromMatch = !newSegmentOrder.includes(segmentId);
775
+ const reresolvedWithoutPush =
776
+ resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
777
+ if (droppedFromMatch || reresolvedWithoutPush) {
759
778
  delete handleData[handleName][segmentId];
760
779
  }
761
780
  }
@@ -765,6 +784,7 @@ export function createEventController(
765
784
  handleData = data;
766
785
  }
767
786
  handleSegmentOrder = newSegmentOrder;
787
+ routeSegmentIds = newRouteSegmentIds;
768
788
 
769
789
  notifyHandles();
770
790
  }
@@ -773,6 +793,7 @@ export function createEventController(
773
793
  return {
774
794
  data: handleData,
775
795
  segmentOrder: handleSegmentOrder,
796
+ routeSegmentIds,
776
797
  };
777
798
  }
778
799
 
@@ -61,6 +61,27 @@ export function buildHistoryState(
61
61
  return Object.keys(result).length > 0 ? result : null;
62
62
  }
63
63
 
64
+ /**
65
+ * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
+ * Push increments the current idx; replace keeps it. Initial entry idx is 0.
67
+ * Used by useRouter().back() to detect "first entry in this session" without
68
+ * relying on the Navigation API.
69
+ */
70
+ export function pushHistoryWithIdx(
71
+ state: Record<string, unknown> | null,
72
+ url: string,
73
+ replace: boolean,
74
+ ): void {
75
+ const oldIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
76
+ const newIdx = replace ? oldIdx : oldIdx + 1;
77
+ const finalState = { ...(state ?? {}), idx: newIdx };
78
+ if (replace) {
79
+ window.history.replaceState(finalState, "", url);
80
+ } else {
81
+ window.history.pushState(finalState, "", url);
82
+ }
83
+ }
84
+
64
85
  /**
65
86
  * Merge server-set location state into the current history entry.
66
87
  * Replaces the current history state and dispatches notification event
@@ -1,9 +1,9 @@
1
1
  // ============================================================================
2
- // Browser Module - Browser entry point for RSC Router
2
+ // Browser Module - Browser entry point for Rango
3
3
  // ============================================================================
4
4
  //
5
5
  // Usage:
6
- // import { initBrowserApp, RSCRouter } from "rsc-router/browser";
6
+ // import { initBrowserApp, Rango } from "rsc-router/browser";
7
7
  //
8
8
  // For React components (Link, useNavigation, etc.):
9
9
  // import { Link, useNavigation, useAction, href } from "rsc-router/client";
@@ -13,6 +13,6 @@
13
13
  // Browser app initialization
14
14
  export {
15
15
  initBrowserApp,
16
- RSCRouter,
16
+ Rango,
17
17
  type InitBrowserAppOptions,
18
18
  } from "./rsc-router.js";
@@ -4,13 +4,16 @@ 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 {
10
13
  createNavigationTransaction,
11
14
  resolveNavigationState,
12
15
  } from "./navigation-transaction.js";
13
- import { buildHistoryState } from "./history-state.js";
16
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
14
17
  import {
15
18
  handleNavigationStart,
16
19
  handleNavigationEnd,
@@ -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 {
@@ -158,11 +204,7 @@ export function createNavigationBridge(
158
204
  },
159
205
  {},
160
206
  );
161
- if (options.replace) {
162
- window.history.replaceState(historyState, "", url);
163
- } else {
164
- window.history.pushState(historyState, "", url);
165
- }
207
+ pushHistoryWithIdx(historyState, url, options?.replace ?? false);
166
208
 
167
209
  // Ensure new history entry has a scroll restoration key
168
210
  ensureHistoryKey();
@@ -260,18 +302,24 @@ export function createNavigationBridge(
260
302
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
261
303
  // 3. when leaving intercept - we need fresh non-intercept segments from server
262
304
  // 4. redirect-with-state - force re-render so hooks read fresh state
305
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
263
306
  const hasUsableCache =
264
307
  cachedSegments &&
265
308
  cachedSegments.length > 0 &&
266
309
  !isInterceptOnlyCache(cachedSegments) &&
267
310
  !hasInterceptCache &&
268
311
  !isLeavingIntercept &&
312
+ !cached?.stale &&
269
313
  !options?._skipCache;
270
314
 
315
+ // Forward navigations always await fetchPartialUpdate before rendering,
316
+ // so useNavigation should always report "loading". skipLoadingState is
317
+ // only used for popstate background revalidation (line ~526) where
318
+ // cached content renders instantly without a network wait.
271
319
  const tx = createNavigationTransaction(store, eventController, url, {
272
320
  ...options,
273
321
  state: resolvedState,
274
- skipLoadingState: hasUsableCache,
322
+ skipLoadingState: false,
275
323
  });
276
324
 
277
325
  // REVALIDATE: Fetch fresh data from server
@@ -411,6 +459,15 @@ export function createNavigationBridge(
411
459
  eventController.abortAllActions();
412
460
  }
413
461
 
462
+ // Popstate that exits an intercept to a non-intercept destination. The
463
+ // fallback fetch path below needs `leave-intercept` mode so it filters
464
+ // the cached @modal segment from the request and forces a re-render —
465
+ // otherwise a cache-miss popstate whose server response has an empty
466
+ // diff hits the "no changes" branch in partial-update and the modal
467
+ // stays on screen.
468
+ const isLeavingIntercept =
469
+ !isIntercept && currentInterceptSource !== null;
470
+
414
471
  // Compute history key from URL (with intercept suffix if applicable)
415
472
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
416
473
 
@@ -447,6 +504,12 @@ export function createNavigationBridge(
447
504
  store.setCurrentUrl(url);
448
505
  store.setPath(new URL(url).pathname);
449
506
 
507
+ // Restore router identity from cache so subsequent navigations
508
+ // don't falsely detect an app switch.
509
+ if (cached?.routerId) {
510
+ store.setRouterId?.(cached.routerId);
511
+ }
512
+
450
513
  // Render from cache - force await to skip loading fallbacks
451
514
  try {
452
515
  const root = await renderSegments(cachedSegments, {
@@ -472,8 +535,16 @@ export function createNavigationBridge(
472
535
  cachedHandleData,
473
536
  params: cachedParams,
474
537
  },
538
+ scroll: { restore: true, isStreaming },
475
539
  };
476
- const hasTransition = cachedSegments.some((s) => s.transition);
540
+ // Intercept-driven popstate (entering OR leaving an intercept) only
541
+ // mutates the parallel slot; the main outlet shows the same content.
542
+ // Skip startViewTransition in those cases — same rationale as the
543
+ // intercept guard in partial-update.ts's hasTransition computation.
544
+ const hasTransition =
545
+ !isIntercept &&
546
+ !isLeavingIntercept &&
547
+ cachedSegments.some((s) => s.transition);
477
548
  if (hasTransition) {
478
549
  startTransition(() => {
479
550
  if (addTransitionType) {
@@ -485,9 +556,6 @@ export function createNavigationBridge(
485
556
  onUpdate(popstateUpdate);
486
557
  }
487
558
 
488
- // Restore scroll position for back/forward navigation
489
- handleNavigationEnd({ restore: true, isStreaming });
490
-
491
559
  // SWR: If stale, trigger background revalidation
492
560
  if (isStale) {
493
561
  debugLog("[Browser] Cache is stale, background revalidating...");
@@ -557,7 +625,11 @@ export function createNavigationBridge(
557
625
  intercept: isIntercept,
558
626
  interceptSourceUrl,
559
627
  }),
560
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
628
+ isIntercept
629
+ ? { type: "navigate", interceptSourceUrl }
630
+ : isLeavingIntercept
631
+ ? { type: "leave-intercept" }
632
+ : undefined,
561
633
  );
562
634
  // Restore scroll position after fetch completes
563
635
  handleNavigationEnd({ restore: true, isStreaming });
@@ -634,6 +706,20 @@ export function createNavigationBridge(
634
706
  window.removeEventListener("pageshow", handlePageShow);
635
707
  };
636
708
  },
709
+
710
+ getVersion(): string | undefined {
711
+ return version;
712
+ },
713
+
714
+ updateVersion(newVersion: string): void {
715
+ version = newVersion;
716
+ setAppVersion(newVersion);
717
+ store.clearHistoryCache();
718
+ },
719
+
720
+ updateAppShell(next: AppShell): void {
721
+ applyAppShell(next);
722
+ },
637
723
  };
638
724
  }
639
725