@rangojs/router 0.0.0-experimental.0f44aca1

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 (305) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +899 -0
  3. package/dist/bin/rango.js +1601 -0
  4. package/dist/vite/index.js +5214 -0
  5. package/package.json +176 -0
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +220 -0
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +112 -0
  11. package/skills/document-cache/SKILL.md +182 -0
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +704 -0
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +313 -0
  16. package/skills/layout/SKILL.md +310 -0
  17. package/skills/links/SKILL.md +239 -0
  18. package/skills/loader/SKILL.md +596 -0
  19. package/skills/middleware/SKILL.md +339 -0
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +305 -0
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +118 -0
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +385 -0
  26. package/skills/router-setup/SKILL.md +439 -0
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +79 -0
  29. package/skills/typesafety/SKILL.md +623 -0
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +273 -0
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/event-controller.ts +899 -0
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/index.ts +18 -0
  38. package/src/browser/intercept-utils.ts +52 -0
  39. package/src/browser/link-interceptor.ts +141 -0
  40. package/src/browser/logging.ts +55 -0
  41. package/src/browser/merge-segment-loaders.ts +134 -0
  42. package/src/browser/navigation-bridge.ts +645 -0
  43. package/src/browser/navigation-client.ts +215 -0
  44. package/src/browser/navigation-store.ts +806 -0
  45. package/src/browser/navigation-transaction.ts +295 -0
  46. package/src/browser/network-error-handler.ts +61 -0
  47. package/src/browser/partial-update.ts +550 -0
  48. package/src/browser/prefetch/cache.ts +146 -0
  49. package/src/browser/prefetch/fetch.ts +135 -0
  50. package/src/browser/prefetch/observer.ts +65 -0
  51. package/src/browser/prefetch/policy.ts +42 -0
  52. package/src/browser/prefetch/queue.ts +88 -0
  53. package/src/browser/rango-state.ts +112 -0
  54. package/src/browser/react/Link.tsx +360 -0
  55. package/src/browser/react/NavigationProvider.tsx +386 -0
  56. package/src/browser/react/ScrollRestoration.tsx +94 -0
  57. package/src/browser/react/context.ts +59 -0
  58. package/src/browser/react/filter-segment-order.ts +11 -0
  59. package/src/browser/react/index.ts +52 -0
  60. package/src/browser/react/location-state-shared.ts +162 -0
  61. package/src/browser/react/location-state.ts +107 -0
  62. package/src/browser/react/mount-context.ts +37 -0
  63. package/src/browser/react/nonce-context.ts +23 -0
  64. package/src/browser/react/shallow-equal.ts +27 -0
  65. package/src/browser/react/use-action.ts +218 -0
  66. package/src/browser/react/use-client-cache.ts +58 -0
  67. package/src/browser/react/use-handle.ts +162 -0
  68. package/src/browser/react/use-href.tsx +40 -0
  69. package/src/browser/react/use-link-status.ts +135 -0
  70. package/src/browser/react/use-mount.ts +31 -0
  71. package/src/browser/react/use-navigation.ts +99 -0
  72. package/src/browser/react/use-params.ts +65 -0
  73. package/src/browser/react/use-pathname.ts +47 -0
  74. package/src/browser/react/use-router.ts +63 -0
  75. package/src/browser/react/use-search-params.ts +56 -0
  76. package/src/browser/react/use-segments.ts +171 -0
  77. package/src/browser/response-adapter.ts +73 -0
  78. package/src/browser/rsc-router.tsx +431 -0
  79. package/src/browser/scroll-restoration.ts +400 -0
  80. package/src/browser/segment-reconciler.ts +216 -0
  81. package/src/browser/segment-structure-assert.ts +83 -0
  82. package/src/browser/server-action-bridge.ts +667 -0
  83. package/src/browser/shallow.ts +40 -0
  84. package/src/browser/types.ts +538 -0
  85. package/src/browser/validate-redirect-origin.ts +29 -0
  86. package/src/build/generate-manifest.ts +438 -0
  87. package/src/build/generate-route-types.ts +36 -0
  88. package/src/build/index.ts +35 -0
  89. package/src/build/route-trie.ts +265 -0
  90. package/src/build/route-types/ast-helpers.ts +25 -0
  91. package/src/build/route-types/ast-route-extraction.ts +98 -0
  92. package/src/build/route-types/codegen.ts +102 -0
  93. package/src/build/route-types/include-resolution.ts +411 -0
  94. package/src/build/route-types/param-extraction.ts +48 -0
  95. package/src/build/route-types/per-module-writer.ts +128 -0
  96. package/src/build/route-types/router-processing.ts +469 -0
  97. package/src/build/route-types/scan-filter.ts +78 -0
  98. package/src/build/runtime-discovery.ts +231 -0
  99. package/src/cache/background-task.ts +34 -0
  100. package/src/cache/cache-key-utils.ts +44 -0
  101. package/src/cache/cache-policy.ts +125 -0
  102. package/src/cache/cache-runtime.ts +338 -0
  103. package/src/cache/cache-scope.ts +382 -0
  104. package/src/cache/cf/cf-cache-store.ts +540 -0
  105. package/src/cache/cf/index.ts +25 -0
  106. package/src/cache/document-cache.ts +369 -0
  107. package/src/cache/handle-capture.ts +81 -0
  108. package/src/cache/handle-snapshot.ts +41 -0
  109. package/src/cache/index.ts +43 -0
  110. package/src/cache/memory-segment-store.ts +328 -0
  111. package/src/cache/profile-registry.ts +73 -0
  112. package/src/cache/read-through-swr.ts +134 -0
  113. package/src/cache/segment-codec.ts +256 -0
  114. package/src/cache/taint.ts +98 -0
  115. package/src/cache/types.ts +342 -0
  116. package/src/client.rsc.tsx +85 -0
  117. package/src/client.tsx +601 -0
  118. package/src/component-utils.ts +76 -0
  119. package/src/components/DefaultDocument.tsx +27 -0
  120. package/src/context-var.ts +86 -0
  121. package/src/debug.ts +243 -0
  122. package/src/default-error-boundary.tsx +88 -0
  123. package/src/deps/browser.ts +8 -0
  124. package/src/deps/html-stream-client.ts +2 -0
  125. package/src/deps/html-stream-server.ts +2 -0
  126. package/src/deps/rsc.ts +10 -0
  127. package/src/deps/ssr.ts +2 -0
  128. package/src/errors.ts +365 -0
  129. package/src/handle.ts +135 -0
  130. package/src/handles/MetaTags.tsx +246 -0
  131. package/src/handles/breadcrumbs.ts +66 -0
  132. package/src/handles/index.ts +7 -0
  133. package/src/handles/meta.ts +264 -0
  134. package/src/host/cookie-handler.ts +165 -0
  135. package/src/host/errors.ts +97 -0
  136. package/src/host/index.ts +53 -0
  137. package/src/host/pattern-matcher.ts +214 -0
  138. package/src/host/router.ts +352 -0
  139. package/src/host/testing.ts +79 -0
  140. package/src/host/types.ts +146 -0
  141. package/src/host/utils.ts +25 -0
  142. package/src/href-client.ts +222 -0
  143. package/src/index.rsc.ts +233 -0
  144. package/src/index.ts +277 -0
  145. package/src/internal-debug.ts +11 -0
  146. package/src/loader.rsc.ts +89 -0
  147. package/src/loader.ts +64 -0
  148. package/src/network-error-thrower.tsx +23 -0
  149. package/src/outlet-context.ts +15 -0
  150. package/src/outlet-provider.tsx +45 -0
  151. package/src/prerender/param-hash.ts +37 -0
  152. package/src/prerender/store.ts +185 -0
  153. package/src/prerender.ts +463 -0
  154. package/src/reverse.ts +330 -0
  155. package/src/root-error-boundary.tsx +289 -0
  156. package/src/route-content-wrapper.tsx +196 -0
  157. package/src/route-definition/dsl-helpers.ts +934 -0
  158. package/src/route-definition/helper-factories.ts +200 -0
  159. package/src/route-definition/helpers-types.ts +430 -0
  160. package/src/route-definition/index.ts +52 -0
  161. package/src/route-definition/redirect.ts +93 -0
  162. package/src/route-definition.ts +1 -0
  163. package/src/route-map-builder.ts +275 -0
  164. package/src/route-name.ts +53 -0
  165. package/src/route-types.ts +259 -0
  166. package/src/router/content-negotiation.ts +116 -0
  167. package/src/router/debug-manifest.ts +72 -0
  168. package/src/router/error-handling.ts +287 -0
  169. package/src/router/find-match.ts +158 -0
  170. package/src/router/handler-context.ts +451 -0
  171. package/src/router/intercept-resolution.ts +395 -0
  172. package/src/router/lazy-includes.ts +234 -0
  173. package/src/router/loader-resolution.ts +420 -0
  174. package/src/router/logging.ts +248 -0
  175. package/src/router/manifest.ts +267 -0
  176. package/src/router/match-api.ts +620 -0
  177. package/src/router/match-context.ts +266 -0
  178. package/src/router/match-handlers.ts +440 -0
  179. package/src/router/match-middleware/background-revalidation.ts +223 -0
  180. package/src/router/match-middleware/cache-lookup.ts +634 -0
  181. package/src/router/match-middleware/cache-store.ts +295 -0
  182. package/src/router/match-middleware/index.ts +81 -0
  183. package/src/router/match-middleware/intercept-resolution.ts +306 -0
  184. package/src/router/match-middleware/segment-resolution.ts +192 -0
  185. package/src/router/match-pipelines.ts +179 -0
  186. package/src/router/match-result.ts +219 -0
  187. package/src/router/metrics.ts +282 -0
  188. package/src/router/middleware-cookies.ts +55 -0
  189. package/src/router/middleware-types.ts +222 -0
  190. package/src/router/middleware.ts +748 -0
  191. package/src/router/pattern-matching.ts +563 -0
  192. package/src/router/prerender-match.ts +402 -0
  193. package/src/router/preview-match.ts +170 -0
  194. package/src/router/revalidation.ts +289 -0
  195. package/src/router/router-context.ts +316 -0
  196. package/src/router/router-interfaces.ts +452 -0
  197. package/src/router/router-options.ts +592 -0
  198. package/src/router/router-registry.ts +24 -0
  199. package/src/router/segment-resolution/fresh.ts +570 -0
  200. package/src/router/segment-resolution/helpers.ts +263 -0
  201. package/src/router/segment-resolution/loader-cache.ts +198 -0
  202. package/src/router/segment-resolution/revalidation.ts +1239 -0
  203. package/src/router/segment-resolution/static-store.ts +67 -0
  204. package/src/router/segment-resolution.ts +21 -0
  205. package/src/router/segment-wrappers.ts +289 -0
  206. package/src/router/telemetry-otel.ts +299 -0
  207. package/src/router/telemetry.ts +300 -0
  208. package/src/router/timeout.ts +148 -0
  209. package/src/router/trie-matching.ts +239 -0
  210. package/src/router/types.ts +170 -0
  211. package/src/router.ts +1002 -0
  212. package/src/rsc/handler-context.ts +45 -0
  213. package/src/rsc/handler.ts +1089 -0
  214. package/src/rsc/helpers.ts +198 -0
  215. package/src/rsc/index.ts +36 -0
  216. package/src/rsc/loader-fetch.ts +209 -0
  217. package/src/rsc/manifest-init.ts +86 -0
  218. package/src/rsc/nonce.ts +32 -0
  219. package/src/rsc/origin-guard.ts +141 -0
  220. package/src/rsc/progressive-enhancement.ts +379 -0
  221. package/src/rsc/response-error.ts +37 -0
  222. package/src/rsc/response-route-handler.ts +347 -0
  223. package/src/rsc/rsc-rendering.ts +235 -0
  224. package/src/rsc/runtime-warnings.ts +42 -0
  225. package/src/rsc/server-action.ts +348 -0
  226. package/src/rsc/ssr-setup.ts +128 -0
  227. package/src/rsc/types.ts +263 -0
  228. package/src/search-params.ts +230 -0
  229. package/src/segment-system.tsx +454 -0
  230. package/src/server/context.ts +591 -0
  231. package/src/server/cookie-store.ts +190 -0
  232. package/src/server/fetchable-loader-store.ts +37 -0
  233. package/src/server/handle-store.ts +308 -0
  234. package/src/server/loader-registry.ts +133 -0
  235. package/src/server/request-context.ts +914 -0
  236. package/src/server/root-layout.tsx +10 -0
  237. package/src/server/tsconfig.json +14 -0
  238. package/src/server.ts +51 -0
  239. package/src/ssr/index.tsx +365 -0
  240. package/src/static-handler.ts +114 -0
  241. package/src/theme/ThemeProvider.tsx +297 -0
  242. package/src/theme/ThemeScript.tsx +61 -0
  243. package/src/theme/constants.ts +62 -0
  244. package/src/theme/index.ts +48 -0
  245. package/src/theme/theme-context.ts +44 -0
  246. package/src/theme/theme-script.ts +155 -0
  247. package/src/theme/types.ts +182 -0
  248. package/src/theme/use-theme.ts +44 -0
  249. package/src/types/boundaries.ts +158 -0
  250. package/src/types/cache-types.ts +198 -0
  251. package/src/types/error-types.ts +192 -0
  252. package/src/types/global-namespace.ts +100 -0
  253. package/src/types/handler-context.ts +687 -0
  254. package/src/types/index.ts +88 -0
  255. package/src/types/loader-types.ts +183 -0
  256. package/src/types/route-config.ts +170 -0
  257. package/src/types/route-entry.ts +102 -0
  258. package/src/types/segments.ts +148 -0
  259. package/src/types.ts +1 -0
  260. package/src/urls/include-helper.ts +197 -0
  261. package/src/urls/index.ts +53 -0
  262. package/src/urls/path-helper-types.ts +339 -0
  263. package/src/urls/path-helper.ts +329 -0
  264. package/src/urls/pattern-types.ts +95 -0
  265. package/src/urls/response-types.ts +106 -0
  266. package/src/urls/type-extraction.ts +372 -0
  267. package/src/urls/urls-function.ts +98 -0
  268. package/src/urls.ts +1 -0
  269. package/src/use-loader.tsx +354 -0
  270. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  271. package/src/vite/discovery/discover-routers.ts +344 -0
  272. package/src/vite/discovery/prerender-collection.ts +385 -0
  273. package/src/vite/discovery/route-types-writer.ts +258 -0
  274. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  275. package/src/vite/discovery/state.ts +110 -0
  276. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  277. package/src/vite/index.ts +16 -0
  278. package/src/vite/plugin-types.ts +131 -0
  279. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  280. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  281. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  282. package/src/vite/plugins/expose-action-id.ts +365 -0
  283. package/src/vite/plugins/expose-id-utils.ts +287 -0
  284. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  285. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  286. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  287. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  288. package/src/vite/plugins/expose-ids/types.ts +45 -0
  289. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  290. package/src/vite/plugins/refresh-cmd.ts +65 -0
  291. package/src/vite/plugins/use-cache-transform.ts +323 -0
  292. package/src/vite/plugins/version-injector.ts +83 -0
  293. package/src/vite/plugins/version-plugin.ts +254 -0
  294. package/src/vite/plugins/version.d.ts +12 -0
  295. package/src/vite/plugins/virtual-entries.ts +123 -0
  296. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  297. package/src/vite/rango.ts +510 -0
  298. package/src/vite/router-discovery.ts +785 -0
  299. package/src/vite/utils/ast-handler-extract.ts +517 -0
  300. package/src/vite/utils/banner.ts +36 -0
  301. package/src/vite/utils/bundle-analysis.ts +137 -0
  302. package/src/vite/utils/manifest-utils.ts +70 -0
  303. package/src/vite/utils/package-resolution.ts +121 -0
  304. package/src/vite/utils/prerender-utils.ts +189 -0
  305. package/src/vite/utils/shared-utils.ts +169 -0
@@ -0,0 +1,550 @@
1
+ import type {
2
+ NavigationStore,
3
+ NavigationClient,
4
+ UpdateSubscriber,
5
+ ResolvedSegment,
6
+ } from "./types.js";
7
+ import type { ReactNode } from "react";
8
+ import * as React from "react";
9
+ import { startTransition } from "react";
10
+
11
+ // addTransitionType is only available in React experimental
12
+ const addTransitionType: ((type: string) => void) | undefined =
13
+ "addTransitionType" in React ? (React as any).addTransitionType : undefined;
14
+ import type { RenderSegmentsOptions } from "../segment-system.js";
15
+ import { reconcileSegments } from "./segment-reconciler.js";
16
+ import type { ReconcileActor } from "./segment-reconciler.js";
17
+ import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
18
+ import type { BoundTransaction } from "./navigation-transaction.js";
19
+ import { ServerRedirect } from "../errors.js";
20
+ import { debugLog } from "./logging.js";
21
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
22
+
23
+ /**
24
+ * Configuration for creating a partial updater
25
+ */
26
+ export interface PartialUpdateConfig {
27
+ store: NavigationStore;
28
+ client: NavigationClient;
29
+ onUpdate: UpdateSubscriber;
30
+ renderSegments: (
31
+ segments: ResolvedSegment[],
32
+ options?: RenderSegmentsOptions,
33
+ ) => Promise<ReactNode> | ReactNode;
34
+ /** RSC version received from server (from initial payload metadata) */
35
+ version?: string;
36
+ }
37
+
38
+ /**
39
+ * Options that can override the pre-configured commit settings
40
+ */
41
+ export interface CommitOverrides {
42
+ /** Override scroll behavior (e.g., disable for intercepts) */
43
+ scroll?: boolean;
44
+ /** Override replace behavior (e.g., force replace for intercepts) */
45
+ replace?: boolean;
46
+ /** Mark this as an intercept route */
47
+ intercept?: boolean;
48
+ /** Source URL where intercept was triggered from */
49
+ interceptSourceUrl?: string;
50
+ /** Server-set location state to merge into history.pushState */
51
+ serverState?: Record<string, unknown>;
52
+ }
53
+
54
+ /**
55
+ * Discriminated update mode for partial updates.
56
+ */
57
+ export type UpdateMode =
58
+ | {
59
+ type: "navigate";
60
+ /** Cached segments for the target URL. When provided, these are used to build
61
+ * the segment map instead of the current page's segments. This ensures consistency
62
+ * when we send cached segment IDs to the server - if the server returns empty diff,
63
+ * we use the same segments we told the server we have. */
64
+ targetCacheSegments?: ResolvedSegment[];
65
+ /** Cached handle data for the target URL. When server returns empty diff and we're
66
+ * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
67
+ targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
68
+ /** Source URL for intercept restore (popstate cache miss) */
69
+ interceptSourceUrl?: string;
70
+ }
71
+ | { type: "leave-intercept" }
72
+ | { type: "stale-revalidation"; interceptSourceUrl?: string }
73
+ | { type: "action"; interceptSourceUrl?: string };
74
+
75
+ /**
76
+ * Type for the fetchPartialUpdate function
77
+ */
78
+ export type PartialUpdater = (
79
+ targetUrl: string,
80
+ segmentIds: string[] | undefined,
81
+ isRetry: boolean,
82
+ signal: AbortSignal | undefined,
83
+ tx: BoundTransaction,
84
+ mode?: UpdateMode,
85
+ ) => Promise<void>;
86
+
87
+ /**
88
+ * Create a partial updater for fetching and applying RSC partial updates
89
+ *
90
+ * This function is shared between navigation-bridge and server-action-bridge
91
+ * to handle partial RSC updates with HMR resilience.
92
+ *
93
+ * @param config - Partial update configuration
94
+ * @returns fetchPartialUpdate function
95
+ */
96
+ export function createPartialUpdater(
97
+ config: PartialUpdateConfig,
98
+ ): PartialUpdater {
99
+ const { store, client, onUpdate, renderSegments, version } = config;
100
+
101
+ /**
102
+ * Get current page's cached segments as an array
103
+ */
104
+ function getCurrentCachedSegments(): ResolvedSegment[] {
105
+ const currentKey = store.getHistoryKey();
106
+ const cached = store.getCachedSegments(currentKey);
107
+ return cached?.segments || [];
108
+ }
109
+
110
+ /**
111
+ * Fetch partial update and trigger UI update
112
+ *
113
+ * @param tx - Transaction for committing segment state (required)
114
+ * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
115
+ */
116
+ async function fetchPartialUpdate(
117
+ targetUrl: string,
118
+ segmentIds: string[] | undefined,
119
+ isRetry: boolean,
120
+ signal: AbortSignal | undefined,
121
+ tx: BoundTransaction,
122
+ mode: UpdateMode = { type: "navigate" },
123
+ ): Promise<void> {
124
+ const segmentState = store.getSegmentState();
125
+ const url = targetUrl || window.location.href;
126
+
127
+ // Capture history key at start for stale revalidation consistency check
128
+ const historyKeyAtStart = store.getHistoryKey();
129
+
130
+ // Derive interceptSourceUrl from modes that carry it
131
+ const interceptSourceUrl =
132
+ mode.type === "stale-revalidation" ||
133
+ mode.type === "action" ||
134
+ mode.type === "navigate"
135
+ ? mode.interceptSourceUrl
136
+ : undefined;
137
+
138
+ // When leaving intercept, filter out intercept-specific segments
139
+ let segments: string[];
140
+ if (mode.type === "leave-intercept") {
141
+ const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
142
+ const currentCached = getCurrentCachedSegments();
143
+ const interceptIds = new Set(
144
+ currentCached
145
+ .filter((s) => s.namespace?.startsWith("intercept:"))
146
+ .map((s) => s.id),
147
+ );
148
+ segments = currentSegments.filter((id) => !interceptIds.has(id));
149
+ debugLog(
150
+ `[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
151
+ );
152
+ } else {
153
+ segments = segmentIds ?? segmentState.currentSegmentIds;
154
+ }
155
+
156
+ // For intercept revalidation, use the intercept source URL as previousUrl
157
+ const previousUrl =
158
+ interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
159
+
160
+ debugLog(`\n[Browser] >>> NAVIGATION`);
161
+ debugLog(`[Browser] From: ${previousUrl}`);
162
+ debugLog(`[Browser] To: ${url}`);
163
+ debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
164
+ if (interceptSourceUrl) {
165
+ debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
166
+ }
167
+
168
+ // Get cached segments for merging with server diff.
169
+ // When navigating with targetCacheSegments, use those for consistency.
170
+ // Otherwise fall back to current page's segments (for same-route revalidation).
171
+ const targetCache =
172
+ mode.type === "navigate" ? mode.targetCacheSegments : undefined;
173
+ const cachedSegs =
174
+ targetCache && targetCache.length > 0
175
+ ? targetCache
176
+ : getCurrentCachedSegments();
177
+
178
+ // Fetch partial payload (no abort signal - RSC doesn't support it well)
179
+ let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
180
+ fetchResult = await client.fetchPartial({
181
+ targetUrl: url,
182
+ segmentIds: segments,
183
+ previousUrl,
184
+ // Mark stale when explicitly requested OR when no segments are sent
185
+ // (action redirect sends empty segments for a fresh render).
186
+ staleRevalidation:
187
+ mode.type === "stale-revalidation" || segments.length === 0,
188
+ version,
189
+ });
190
+ // Mark navigation as streaming (response received, now parsing RSC).
191
+ // Called after fetchPartial so pendingUrl stays set during the network wait,
192
+ // allowing useLinkStatus to show per-link pending indicators.
193
+ const streamingToken = tx.startStreaming();
194
+ const { payload, streamComplete: rawStreamComplete } = fetchResult;
195
+ debugLog("payload.metadata", payload.metadata);
196
+
197
+ const streamComplete = rawStreamComplete.then(() => {
198
+ streamingToken.end();
199
+ });
200
+
201
+ // Handle server-side redirect with state
202
+ if (payload.metadata?.redirect) {
203
+ if (signal?.aborted) {
204
+ debugLog("[Browser] Ignoring stale redirect (aborted)");
205
+ return;
206
+ }
207
+ const redirectUrl = validateRedirectOrigin(
208
+ payload.metadata.redirect.url,
209
+ window.location.origin,
210
+ );
211
+ if (!redirectUrl) {
212
+ debugLog("[Browser] Ignoring blocked redirect payload");
213
+ return;
214
+ }
215
+ const serverState = payload.metadata.locationState;
216
+ throw new ServerRedirect(redirectUrl, serverState);
217
+ }
218
+
219
+ if (payload.metadata?.isPartial) {
220
+ const { segments: newSegments, matched, diff } = payload.metadata;
221
+
222
+ // Check if this navigation is stale (a newer one started)
223
+ if (signal?.aborted) {
224
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
225
+ return;
226
+ }
227
+
228
+ debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
229
+ debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
230
+
231
+ // If diff is empty, nothing changed on server side.
232
+ if (!diff || diff.length === 0) {
233
+ const matchedIds = matched || [];
234
+ const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
235
+ const existingSegments = matchedIds
236
+ .map((id: string) => cacheMap.get(id))
237
+ .filter(Boolean) as ResolvedSegment[];
238
+
239
+ // When navigating with cached segments to a different route, render them.
240
+ if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
241
+ debugLog(
242
+ "[Browser] No diff but navigating with cached segments - rendering target route",
243
+ );
244
+
245
+ const newTree = await renderSegments(existingSegments, {
246
+ forceAwait: true,
247
+ });
248
+
249
+ tx.commit(matchedIds, existingSegments);
250
+
251
+ // Include cachedHandleData in metadata so NavigationProvider can restore
252
+ // breadcrumbs and other handle data from cache.
253
+ // Remove `handles` from metadata to prevent NavigationProvider from
254
+ // processing an empty handles stream, which would clear the cached breadcrumbs.
255
+ const { handles: _unusedHandles, ...metadataWithoutHandles } =
256
+ payload.metadata!;
257
+ const cachedUpdate = {
258
+ root: newTree,
259
+ metadata: {
260
+ ...metadataWithoutHandles,
261
+ cachedHandleData: mode.targetCacheHandleData,
262
+ },
263
+ };
264
+
265
+ const cachedHasTransition = existingSegments.some(
266
+ (s) => s.transition,
267
+ );
268
+ if (cachedHasTransition) {
269
+ startTransition(() => {
270
+ if (addTransitionType) {
271
+ addTransitionType("navigation");
272
+ }
273
+ onUpdate(cachedUpdate);
274
+ });
275
+ } else {
276
+ onUpdate(cachedUpdate);
277
+ }
278
+
279
+ debugLog("[Browser] Navigation complete (rendered from cache)");
280
+ return;
281
+ }
282
+
283
+ // When leaving intercept, force re-render even with empty diff
284
+ if (mode.type === "leave-intercept") {
285
+ debugLog(
286
+ "[Browser] Leaving intercept - forcing re-render to remove modal",
287
+ );
288
+
289
+ const newTree = await renderSegments(existingSegments, {
290
+ forceAwait: true,
291
+ });
292
+
293
+ tx.commit(matchedIds, existingSegments);
294
+
295
+ onUpdate({
296
+ root: newTree,
297
+ metadata: payload.metadata,
298
+ });
299
+
300
+ debugLog("[Browser] Navigation complete (left intercept)");
301
+ return;
302
+ }
303
+
304
+ // Same route revalidation with no changes - skip UI update
305
+ debugLog(
306
+ "[Browser] No changes - all revalidations returned false, keeping existing UI",
307
+ );
308
+ tx.commit(matchedIds, existingSegments);
309
+ debugLog("[Browser] Navigation complete (no re-render)");
310
+ return;
311
+ }
312
+
313
+ // Reconcile server segments with cached segments (single source of truth)
314
+ const matchedIds = matched || [];
315
+ const actor: ReconcileActor =
316
+ mode.type === "stale-revalidation" || mode.type === "action"
317
+ ? "stale-revalidation"
318
+ : "navigation";
319
+
320
+ const reconciled = reconcileSegments({
321
+ actor,
322
+ matched: matchedIds,
323
+ diff: diff || [],
324
+ serverSegments: newSegments || [],
325
+ cachedSegments: cachedSegs,
326
+ insertMissingDiff: true,
327
+ });
328
+
329
+ // HMR RESILIENCE: Check if we're missing any matched segments
330
+ const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
331
+ const missingIds = matchedIds.filter(
332
+ (id: string) => !reconciledIdSet.has(id),
333
+ );
334
+
335
+ if (missingIds.length > 0) {
336
+ const missingCount = missingIds.length;
337
+
338
+ if (isRetry) {
339
+ console.warn("Missing ids", { missingIds });
340
+ throw new Error(
341
+ `[Browser] Failed to fetch segments after retry. Missing: [${missingIds.join(", ")}]`,
342
+ );
343
+ }
344
+ if (signal?.aborted) {
345
+ debugLog(
346
+ "[Browser] Ignoring stale navigation (aborted during HMR retry)",
347
+ );
348
+ return;
349
+ }
350
+ if (mode.type === "action") {
351
+ return;
352
+ }
353
+ console.warn(
354
+ `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
355
+ );
356
+
357
+ // Refetch with empty segments = server sends everything
358
+ return fetchPartialUpdate(url, [], true, signal, tx, mode);
359
+ }
360
+
361
+ if (signal?.aborted) {
362
+ debugLog("[Browser] Ignoring stale navigation (aborted before render)");
363
+ return;
364
+ }
365
+
366
+ // Rebuild tree on client (await for loader data resolution)
367
+ const renderOptions = {
368
+ isAction: mode.type === "action",
369
+ forceAwait: mode.type === "stale-revalidation",
370
+ interceptSegments:
371
+ reconciled.interceptSegments.length > 0
372
+ ? reconciled.interceptSegments
373
+ : undefined,
374
+ };
375
+ const newTree = await (signal
376
+ ? Promise.race([
377
+ renderSegments(reconciled.mainSegments, renderOptions),
378
+ new Promise<never>((_, reject) => {
379
+ if (signal.aborted) {
380
+ reject(new DOMException("Navigation aborted", "AbortError"));
381
+ }
382
+ signal.addEventListener("abort", () => {
383
+ reject(new DOMException("Navigation aborted", "AbortError"));
384
+ });
385
+ }),
386
+ ])
387
+ : renderSegments(reconciled.mainSegments, renderOptions));
388
+
389
+ // Final abort check before committing - another navigation may have started
390
+ if (signal?.aborted) {
391
+ debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
392
+ return;
393
+ }
394
+
395
+ // Check if this is an intercept response (any slot is active)
396
+ const isInterceptResponse = hasActiveInterceptSlots(
397
+ payload.metadata?.slots,
398
+ );
399
+
400
+ // Track intercept context (only on navigation, not actions or stale revalidation)
401
+ // Use the authoritative source from mode/history state when restoring an
402
+ // intercept via popstate cache miss; fall back to the current URL for fresh
403
+ // intercept navigations.
404
+ const effectiveInterceptSource =
405
+ interceptSourceUrl || segmentState.currentUrl;
406
+ if (mode.type !== "action" && mode.type !== "stale-revalidation") {
407
+ if (isInterceptResponse) {
408
+ store.setInterceptSourceUrl(effectiveInterceptSource);
409
+ } else {
410
+ store.setInterceptSourceUrl(null);
411
+ }
412
+ }
413
+
414
+ // Commit navigation - transaction handles all store mutations atomically
415
+ const allSegmentIds = reconciled.segments.map((s) => s.id);
416
+ const serverLocationState = payload.metadata?.locationState;
417
+ const overrides: CommitOverrides | undefined = isInterceptResponse
418
+ ? {
419
+ scroll: false,
420
+ intercept: true,
421
+ interceptSourceUrl: effectiveInterceptSource,
422
+ ...(serverLocationState && { serverState: serverLocationState }),
423
+ }
424
+ : serverLocationState
425
+ ? { serverState: serverLocationState }
426
+ : undefined;
427
+ tx.commit(allSegmentIds, reconciled.segments, overrides);
428
+
429
+ // For stale revalidation: verify history key hasn't changed before updating UI
430
+ if (mode.type === "stale-revalidation") {
431
+ const historyKeyNow = store.getHistoryKey();
432
+ if (historyKeyNow !== historyKeyAtStart) {
433
+ debugLog(
434
+ `[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
435
+ );
436
+ return;
437
+ }
438
+ }
439
+
440
+ debugLog("[partial-update] updating document");
441
+
442
+ // Emit update to trigger React render
443
+ const hasTransition = reconciled.mainSegments.some((s) => s.transition);
444
+
445
+ if (mode.type === "action" || mode.type === "stale-revalidation") {
446
+ startTransition(() => {
447
+ if (hasTransition && addTransitionType) {
448
+ addTransitionType("action");
449
+ }
450
+ onUpdate({
451
+ root: newTree,
452
+ metadata: payload.metadata!,
453
+ });
454
+ });
455
+ } else if (hasTransition) {
456
+ startTransition(() => {
457
+ if (addTransitionType) {
458
+ addTransitionType("navigation");
459
+ }
460
+ onUpdate({
461
+ root: newTree,
462
+ metadata: payload.metadata!,
463
+ });
464
+ });
465
+ } else {
466
+ onUpdate({
467
+ root: newTree,
468
+ metadata: payload.metadata!,
469
+ });
470
+ }
471
+
472
+ debugLog("[Browser] Navigation complete");
473
+ return;
474
+ } else {
475
+ // Full update (fallback)
476
+ console.warn(`[Browser] Full update (fallback)`);
477
+
478
+ const segments = payload.metadata?.segments || [];
479
+
480
+ if (signal?.aborted) {
481
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
482
+ return;
483
+ }
484
+
485
+ const segmentIds = segments.map((s: ResolvedSegment) => s.id);
486
+
487
+ const newTree = await renderSegments(segments);
488
+
489
+ if (signal?.aborted) {
490
+ debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
491
+ return;
492
+ }
493
+
494
+ const fullUpdateServerState = payload.metadata?.locationState;
495
+ if (fullUpdateServerState) {
496
+ tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
497
+ } else {
498
+ tx.commit(segmentIds, segments);
499
+ }
500
+
501
+ const fullHasTransition = segments.some(
502
+ (s: ResolvedSegment) => s.transition,
503
+ );
504
+
505
+ if (mode.type === "stale-revalidation") {
506
+ await rawStreamComplete;
507
+ startTransition(() => {
508
+ if (fullHasTransition && addTransitionType) {
509
+ addTransitionType("action");
510
+ }
511
+ onUpdate({
512
+ root: newTree,
513
+ metadata: payload.metadata!,
514
+ });
515
+ });
516
+ } else if (mode.type === "action") {
517
+ startTransition(async () => {
518
+ if (fullHasTransition && addTransitionType) {
519
+ addTransitionType("action");
520
+ }
521
+ onUpdate({
522
+ root: newTree,
523
+ metadata: payload.metadata!,
524
+ });
525
+ });
526
+ } else if (fullHasTransition) {
527
+ startTransition(() => {
528
+ if (addTransitionType) {
529
+ addTransitionType("navigation");
530
+ }
531
+ onUpdate({
532
+ root: newTree,
533
+ metadata: payload.metadata!,
534
+ });
535
+ });
536
+ } else {
537
+ onUpdate({
538
+ root: newTree,
539
+ metadata: payload.metadata!,
540
+ });
541
+ }
542
+
543
+ return;
544
+ }
545
+ }
546
+
547
+ return fetchPartialUpdate;
548
+ }
549
+
550
+ export { createPartialUpdater as default };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Prefetch Cache
3
+ *
4
+ * In-memory cache storing prefetch Response objects for instant cache hits
5
+ * on subsequent navigation. Cache key is source-dependent (includes the
6
+ * current page URL) because the server's diff-based response depends on
7
+ * where the user navigates from.
8
+ *
9
+ * Replaces the previous browser HTTP cache approach which was unreliable
10
+ * due to response draining race conditions and browser inconsistencies.
11
+ */
12
+
13
+ import { cancelAllPrefetches } from "./queue.js";
14
+ import { invalidateRangoState } from "../rango-state.js";
15
+
16
+ // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
17
+ // the server-configured prefetchCacheTTL from router options.
18
+ // 0 disables the in-memory cache entirely.
19
+ let cacheTTL = 300_000;
20
+
21
+ /**
22
+ * Initialize the prefetch cache with the configured TTL.
23
+ * Called once at app startup with the value from server metadata.
24
+ * A TTL of 0 disables the in-memory cache.
25
+ */
26
+ export function initPrefetchCache(ttlMs: number): void {
27
+ cacheTTL = ttlMs;
28
+ }
29
+ const MAX_PREFETCH_CACHE_SIZE = 50;
30
+
31
+ interface PrefetchCacheEntry {
32
+ response: Response;
33
+ timestamp: number;
34
+ }
35
+
36
+ const cache = new Map<string, PrefetchCacheEntry>();
37
+ const inflight = new Set<string>();
38
+
39
+ // Generation counter incremented on each clearPrefetchCache(). Fetches that
40
+ // started before a clear carry a stale generation and must not store their
41
+ // response (the data may be stale due to a server action invalidation).
42
+ let generation = 0;
43
+
44
+ /**
45
+ * Build a source-dependent cache key.
46
+ * Includes the source page href so the same target prefetched from
47
+ * different pages gets separate entries — the server response varies
48
+ * based on the source page context (diff-based rendering).
49
+ */
50
+ export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
51
+ return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
52
+ }
53
+
54
+ /**
55
+ * Check if a prefetch is already cached, in-flight, or queued for the given key.
56
+ */
57
+ export function hasPrefetch(key: string): boolean {
58
+ if (inflight.has(key)) return true;
59
+ if (cacheTTL <= 0) return false;
60
+ const entry = cache.get(key);
61
+ if (!entry) return false;
62
+ if (Date.now() - entry.timestamp > cacheTTL) {
63
+ cache.delete(key);
64
+ return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Consume a cached prefetch response. Returns null if not found or expired.
71
+ * One-time consumption: the entry is deleted after retrieval.
72
+ * Returns null when caching is disabled (TTL <= 0).
73
+ */
74
+ export function consumePrefetch(key: string): Response | null {
75
+ if (cacheTTL <= 0) return null;
76
+ const entry = cache.get(key);
77
+ if (!entry) return null;
78
+ if (Date.now() - entry.timestamp > cacheTTL) {
79
+ cache.delete(key);
80
+ return null;
81
+ }
82
+ cache.delete(key);
83
+ return entry.response;
84
+ }
85
+
86
+ /**
87
+ * Store a prefetch response in the in-memory cache.
88
+ * The response body must be fully buffered (e.g. via arrayBuffer()) before
89
+ * storing, so the cached Response is self-contained and network-independent.
90
+ *
91
+ * Skips storage if the generation has changed since the fetch started
92
+ * (a server action invalidated the cache mid-flight).
93
+ */
94
+ export function storePrefetch(
95
+ key: string,
96
+ response: Response,
97
+ fetchGeneration: number,
98
+ ): void {
99
+ if (cacheTTL <= 0) return;
100
+ if (fetchGeneration !== generation) return;
101
+
102
+ // Evict expired entries
103
+ const now = Date.now();
104
+ for (const [k, entry] of cache) {
105
+ if (now - entry.timestamp > cacheTTL) {
106
+ cache.delete(k);
107
+ }
108
+ }
109
+
110
+ // FIFO eviction if at capacity
111
+ if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
112
+ const oldest = cache.keys().next().value;
113
+ if (oldest) cache.delete(oldest);
114
+ }
115
+
116
+ cache.set(key, { response, timestamp: now });
117
+ }
118
+
119
+ /**
120
+ * Capture the current generation. The returned value is passed to
121
+ * storePrefetch so it can detect stale completions.
122
+ */
123
+ export function currentGeneration(): number {
124
+ return generation;
125
+ }
126
+
127
+ export function markPrefetchInflight(key: string): void {
128
+ inflight.add(key);
129
+ }
130
+
131
+ export function clearPrefetchInflight(key: string): void {
132
+ inflight.delete(key);
133
+ }
134
+
135
+ /**
136
+ * Invalidate all prefetch state. Called when server actions mutate data.
137
+ * Clears the in-memory cache, cancels in-flight prefetches, and rotates
138
+ * the Rango state key so CDN-cached responses are also invalidated.
139
+ */
140
+ export function clearPrefetchCache(): void {
141
+ generation++;
142
+ inflight.clear();
143
+ cache.clear();
144
+ cancelAllPrefetches();
145
+ invalidateRangoState();
146
+ }