@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -24,20 +24,12 @@ import { debugLog } from "./logging.js";
24
24
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
25
25
  import type { NavigationUpdate } from "./types.js";
26
26
 
27
- /** Build a scroll payload from the commit's scroll option */
28
27
  function toScrollPayload(
29
28
  scroll: boolean | undefined,
30
29
  ): NonNullable<NavigationUpdate["scroll"]> {
31
30
  return { enabled: scroll !== false ? scroll : false };
32
31
  }
33
32
 
34
- /**
35
- * Whether to wrap an update in startViewTransition.
36
- *
37
- * Intercept-driven updates only mutate the parallel slot — the main outlet
38
- * shows the same content — so transitions on the underlying main segments
39
- * shouldn't fire (otherwise their elements get hoisted above the modal).
40
- */
41
33
  function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
42
34
  let hasIntercept = false;
43
35
  let hasTransition = false;
@@ -112,15 +104,6 @@ export type PartialUpdater = (
112
104
  mode?: UpdateMode,
113
105
  ) => Promise<void>;
114
106
 
115
- /**
116
- * Create a partial updater for fetching and applying RSC partial updates
117
- *
118
- * This function is shared between navigation-bridge and server-action-bridge
119
- * to handle partial RSC updates with HMR resilience.
120
- *
121
- * @param config - Partial update configuration
122
- * @returns fetchPartialUpdate function
123
- */
124
107
  export function createPartialUpdater(
125
108
  config: PartialUpdateConfig,
126
109
  ): PartialUpdater {
@@ -132,21 +115,12 @@ export function createPartialUpdater(
132
115
  getVersion = () => undefined,
133
116
  } = config;
134
117
 
135
- /**
136
- * Get current page's cached segments as an array
137
- */
138
118
  function getCurrentCachedSegments(): ResolvedSegment[] {
139
119
  const currentKey = store.getHistoryKey();
140
120
  const cached = store.getCachedSegments(currentKey);
141
121
  return cached?.segments || [];
142
122
  }
143
123
 
144
- /**
145
- * Fetch partial update and trigger UI update
146
- *
147
- * @param tx - Transaction for committing segment state (required)
148
- * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
149
- */
150
124
  async function fetchPartialUpdate(
151
125
  targetUrl: string,
152
126
  segmentIds: string[] | undefined,
@@ -158,20 +132,16 @@ export function createPartialUpdater(
158
132
  const segmentState = store.getSegmentState();
159
133
  const url = targetUrl || window.location.href;
160
134
 
161
- // Capture history key at start for stale revalidation consistency check
162
135
  const historyKeyAtStart = store.getHistoryKey();
163
136
 
164
137
  const interceptSourceUrl = mode.interceptSourceUrl;
165
138
 
166
- // When leaving intercept, filter out intercept-specific segments
167
139
  let segments: string[];
168
140
  if (mode.type === "leave-intercept") {
169
141
  const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
170
142
  const currentCached = getCurrentCachedSegments();
171
143
  const interceptIds = new Set(
172
- currentCached
173
- .filter((s) => s.namespace?.startsWith("intercept:"))
174
- .map((s) => s.id),
144
+ currentCached.filter(isInterceptSegment).map((s) => s.id),
175
145
  );
176
146
  segments = currentSegments.filter((id) => !interceptIds.has(id));
177
147
  debugLog(
@@ -181,12 +151,6 @@ export function createPartialUpdater(
181
151
  segments = segmentIds ?? segmentState.currentSegmentIds;
182
152
  }
183
153
 
184
- // For intercept revalidation, use the intercept source URL as previousUrl.
185
- // For leave-intercept, tx.currentUrl captures window.location.href at tx
186
- // creation, which on popstate is already the destination URL and would
187
- // tell the server "from == to". segmentState.currentUrl still points at
188
- // the URL the cached segments render (the intercept URL), which is the
189
- // correct "from" for the server's diff computation.
190
154
  const previousUrl =
191
155
  mode.type === "leave-intercept"
192
156
  ? segmentState.currentUrl || tx.currentUrl
@@ -200,9 +164,6 @@ export function createPartialUpdater(
200
164
  debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
201
165
  }
202
166
 
203
- // Get cached segments for merging with server diff.
204
- // When navigating with targetCacheSegments, use those for consistency.
205
- // Otherwise fall back to current page's segments (for same-route revalidation).
206
167
  const targetCache =
207
168
  mode.type === "navigate" && mode.targetCacheSegments?.length
208
169
  ? mode.targetCacheSegments
@@ -213,22 +174,16 @@ export function createPartialUpdater(
213
174
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
214
175
  );
215
176
 
216
- // Fetch partial payload (no abort signal - RSC doesn't support it well)
217
177
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
218
178
  fetchResult = await client.fetchPartial({
219
179
  targetUrl: url,
220
180
  segmentIds: segments,
221
181
  previousUrl,
222
- // Mark stale when explicitly requested OR when no segments are sent
223
- // (action redirect sends empty segments for a fresh render).
224
182
  staleRevalidation:
225
183
  mode.type === "stale-revalidation" || segments.length === 0,
226
184
  version: getVersion(),
227
185
  routerId: store.getRouterId?.(),
228
186
  });
229
- // Mark navigation as streaming (response received, now parsing RSC).
230
- // Called after fetchPartial so pendingUrl stays set during the network wait,
231
- // allowing useLinkStatus to show per-link pending indicators.
232
187
  const streamingToken = tx.startStreaming();
233
188
  const { payload, streamComplete: rawStreamComplete } = fetchResult;
234
189
  debugLog("payload.metadata", payload.metadata);
@@ -237,13 +192,6 @@ export function createPartialUpdater(
237
192
  streamingToken.end();
238
193
  });
239
194
 
240
- // Integrity guard (defense in depth). The server redirects on a cross-app
241
- // routerId mismatch (X-RSC-Reload), so a partial payload's routerId must
242
- // match this client's. If it doesn't — a stale/edge cache keyed without the
243
- // routerId, a proxy mixing app responses, or a server classification bug —
244
- // do NOT splice a foreign app's segments and client references into this
245
- // document. Force a full reload so the server re-establishes the
246
- // authoritative document for this URL.
247
195
  const currentRouterId = store.getRouterId?.();
248
196
  if (
249
197
  payload.metadata?.routerId &&
@@ -258,7 +206,6 @@ export function createPartialUpdater(
258
206
  return;
259
207
  }
260
208
 
261
- // Handle server-side redirect with state
262
209
  if (payload.metadata?.redirect) {
263
210
  if (signal?.aborted) {
264
211
  debugLog("[Browser] Ignoring stale redirect (aborted)");
@@ -288,7 +235,6 @@ export function createPartialUpdater(
288
235
  debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
289
236
  debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
290
237
 
291
- // If diff is empty, nothing changed on server side.
292
238
  if (!diff || diff.length === 0) {
293
239
  const matchedIds = matched || [];
294
240
  const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
@@ -296,7 +242,6 @@ export function createPartialUpdater(
296
242
  .map((id: string) => cacheMap.get(id))
297
243
  .filter(Boolean) as ResolvedSegment[];
298
244
 
299
- // When navigating with cached segments to a different route, render them.
300
245
  if (mode.type === "navigate" && targetCache) {
301
246
  debugLog(
302
247
  "[Browser] No diff but navigating with cached segments - rendering target route",
@@ -311,10 +256,6 @@ export function createPartialUpdater(
311
256
  existingSegments,
312
257
  );
313
258
 
314
- // tx.commit() cached the source page's handleData because
315
- // eventController hasn't been updated yet. Overwrite with the
316
- // correct cached handleData to prevent cache corruption on
317
- // subsequent navigations to this same URL.
318
259
  if (mode.targetCacheHandleData) {
319
260
  store.updateCacheHandleData(
320
261
  store.getHistoryKey(),
@@ -322,10 +263,6 @@ export function createPartialUpdater(
322
263
  );
323
264
  }
324
265
 
325
- // Include cachedHandleData in metadata so NavigationProvider can restore
326
- // breadcrumbs and other handle data from cache.
327
- // Remove `handles` from metadata to prevent NavigationProvider from
328
- // processing an empty handles stream, which would clear the cached breadcrumbs.
329
266
  const { handles: _unusedHandles, ...metadataWithoutHandles } =
330
267
  payload.metadata!;
331
268
  const cachedUpdate = {
@@ -352,7 +289,6 @@ export function createPartialUpdater(
352
289
  return;
353
290
  }
354
291
 
355
- // When leaving intercept, force re-render even with empty diff
356
292
  if (mode.type === "leave-intercept") {
357
293
  debugLog(
358
294
  "[Browser] Leaving intercept - forcing re-render to remove modal",
@@ -377,7 +313,6 @@ export function createPartialUpdater(
377
313
  return;
378
314
  }
379
315
 
380
- // Same route revalidation with no changes - skip UI update
381
316
  debugLog(
382
317
  "[Browser] No changes - all revalidations returned false, keeping existing UI",
383
318
  );
@@ -386,7 +321,6 @@ export function createPartialUpdater(
386
321
  return;
387
322
  }
388
323
 
389
- // Reconcile server segments with cached segments (single source of truth)
390
324
  const matchedIds = matched || [];
391
325
  const actor: ReconcileActor =
392
326
  mode.type === "stale-revalidation" || mode.type === "action"
@@ -402,7 +336,6 @@ export function createPartialUpdater(
402
336
  insertMissingDiff: true,
403
337
  });
404
338
 
405
- // HMR RESILIENCE: Check if we're missing any matched segments
406
339
  const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
407
340
  const missingIds = matchedIds.filter(
408
341
  (id: string) => !reconciledIdSet.has(id),
@@ -430,7 +363,6 @@ export function createPartialUpdater(
430
363
  `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
431
364
  );
432
365
 
433
- // Refetch with empty segments = server sends everything
434
366
  return fetchPartialUpdate(url, [], true, signal, tx, mode);
435
367
  }
436
368
 
@@ -439,7 +371,6 @@ export function createPartialUpdater(
439
371
  return;
440
372
  }
441
373
 
442
- // Rebuild tree on client (await for loader data resolution)
443
374
  const renderOptions = {
444
375
  isAction: mode.type === "action",
445
376
  forceAwait: mode.type === "stale-revalidation",
@@ -462,21 +393,15 @@ export function createPartialUpdater(
462
393
  ])
463
394
  : renderSegments(reconciled.mainSegments, renderOptions));
464
395
 
465
- // Final abort check before committing - another navigation may have started
466
396
  if (signal?.aborted) {
467
397
  debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
468
398
  return;
469
399
  }
470
400
 
471
- // Check if this is an intercept response (any slot is active)
472
401
  const isInterceptResponse = hasActiveInterceptSlots(
473
402
  payload.metadata?.slots,
474
403
  );
475
404
 
476
- // Track intercept context (only on navigation, not actions or stale revalidation)
477
- // Use the authoritative source from mode/history state when restoring an
478
- // intercept via popstate cache miss; fall back to the current URL for fresh
479
- // intercept navigations.
480
405
  const effectiveInterceptSource =
481
406
  interceptSourceUrl || segmentState.currentUrl;
482
407
  if (mode.type !== "action" && mode.type !== "stale-revalidation") {
@@ -487,9 +412,6 @@ export function createPartialUpdater(
487
412
  }
488
413
  }
489
414
 
490
- // Commit navigation - use server's matched as the authoritative segment ID list.
491
- // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
492
- // but the server's matched always includes all expected segment IDs.
493
415
  const allSegmentIds = matchedIds;
494
416
  const serverLocationState = payload.metadata?.locationState;
495
417
  const overrides: CommitOverrides | undefined = isInterceptResponse
@@ -508,7 +430,6 @@ export function createPartialUpdater(
508
430
  overrides,
509
431
  );
510
432
 
511
- // For stale revalidation: verify history key hasn't changed before updating UI
512
433
  if (mode.type === "stale-revalidation") {
513
434
  const historyKeyNow = store.getHistoryKey();
514
435
  if (historyKeyNow !== historyKeyAtStart) {
@@ -521,8 +442,6 @@ export function createPartialUpdater(
521
442
 
522
443
  debugLog("[partial-update] updating document");
523
444
 
524
- // Emit update to trigger React render.
525
- // Scroll info is included so NavigationProvider applies it after React commits.
526
445
  const hasTransition = shouldStartViewTransition(reconciled.segments);
527
446
  const scrollPayload = toScrollPayload(navScroll);
528
447
 
@@ -559,7 +478,6 @@ export function createPartialUpdater(
559
478
  debugLog("[Browser] Navigation complete");
560
479
  return;
561
480
  } else {
562
- // Full update (fallback)
563
481
  console.warn(`[Browser] Full update (fallback)`);
564
482
 
565
483
  const segments = payload.metadata?.segments || [];
@@ -31,6 +31,12 @@
31
31
  *
32
32
  * Replaces the previous browser HTTP cache approach which was unreliable
33
33
  * due to response draining race conditions and browser inconsistencies.
34
+ *
35
+ * State here lives in module-level singletons (cache, inflight, generation,
36
+ * cacheTTL, etc.) rather than a per-instance factory. This is correct because
37
+ * exactly one router is live per document — an SPA navigation crossing a
38
+ * host-router boundary forces a full document reload — so the singletons are
39
+ * effectively per-document. Unit tests reset them via clearPrefetchCache().
34
40
  */
35
41
 
36
42
  import { abortAllPrefetches } from "./queue.js";
@@ -61,9 +67,6 @@ export interface DecodedPrefetch {
61
67
  scope: "source" | "wildcard";
62
68
  }
63
69
 
64
- // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
65
- // the server-configured prefetchCacheTTL from router options.
66
- // 0 disables the in-memory cache entirely.
67
70
  let cacheTTL = 300_000;
68
71
 
69
72
  /**
@@ -92,41 +95,12 @@ interface PrefetchCacheEntry {
92
95
  const cache = new Map<string, PrefetchCacheEntry>();
93
96
  const inflight = new Set<string>();
94
97
 
95
- /**
96
- * In-flight promise map. When a prefetch fetch+decode is in progress, its
97
- * Promise<DecodedPrefetch | null> is stored here so navigation can await it
98
- * instead of starting a duplicate request. Resolves to null when the prefetch
99
- * failed, was aborted, or carried a control header (reload/redirect) that the
100
- * navigation must re-fetch to act on.
101
- */
102
98
  const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
103
99
 
104
- /**
105
- * Alias map for in-flight promises registered under multiple keys (see
106
- * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
107
- * that consuming or clearing any one key atomically removes every alias —
108
- * guaranteeing a single consumer for the shared decode.
109
- */
110
100
  const inflightAliases = new Map<string, string[]>();
111
101
 
112
- /**
113
- * Keys whose in-flight prefetch promise was adopted by a navigation (via
114
- * `consumeInflightPrefetch`). A `DecodedPrefetch` carries a single-use
115
- * `metadata.handles` async generator; the adopter drains it. The same entry is
116
- * also published to the `cache` map by `storePrefetch` when the fetch resolves
117
- * — which runs AFTER adoption (adoption only succeeds while the fetch is still
118
- * in flight, so the entry is not yet cached). Without this guard the adopted,
119
- * now-drained entry would be left in the cache and served to a later navigation
120
- * whose handle generator yields nothing, silently dropping that route's
121
- * breadcrumbs. Recording the adopted keys lets `storePrefetch` skip publishing
122
- * them, keeping the existing one-time-consumption contract (a consumed prefetch
123
- * is gone; the next navigation re-fetches).
124
- */
125
102
  const adoptedKeys = new Set<string>();
126
103
 
127
- // Generation counter incremented on each clearPrefetchCache(). Fetches that
128
- // started before a clear carry a stale generation and must not store their
129
- // response (the data may be stale due to a server action invalidation).
130
104
  let generation = 0;
131
105
 
132
106
  /**
@@ -306,9 +280,6 @@ export function markPrefetchInflight(key: string): void {
306
280
  inflight.add(key);
307
281
  }
308
282
 
309
- /**
310
- * Store the in-flight Promise for a prefetch so navigation can reuse it.
311
- */
312
283
  export function setInflightPromise(
313
284
  key: string,
314
285
  promise: Promise<DecodedPrefetch | null>,
@@ -337,20 +308,10 @@ export function clearPrefetchInflight(key: string): void {
337
308
  inflight.delete(k);
338
309
  inflightPromises.delete(k);
339
310
  inflightAliases.delete(k);
340
- // Clear any adopted marker too, so a fetch that failed before storePrefetch
341
- // (the marker's normal consumer) does not strand it across the next prefetch.
342
311
  adoptedKeys.delete(k);
343
312
  });
344
313
  }
345
314
 
346
- /**
347
- * Invalidate all prefetch state. Called when server actions mutate data.
348
- * Clears the in-memory cache, cancels in-flight prefetches, and rotates
349
- * the Rango state key so CDN-cached responses are also invalidated.
350
- *
351
- * Uses abortAllPrefetches (hard cancel) because in-flight responses
352
- * may contain stale data after a mutation.
353
- */
354
315
  export function clearPrefetchCache(): void {
355
316
  generation++;
356
317
  inflight.clear();
@@ -27,6 +27,7 @@ import {
27
27
  type DecodedPrefetch,
28
28
  } from "./cache.js";
29
29
  import { getRangoState } from "../rango-state.js";
30
+ import { isActionFenceActive } from "../action-fence.js";
30
31
  import { enqueuePrefetch } from "./queue.js";
31
32
  import { shouldPrefetch } from "./policy.js";
32
33
  import { debugLog } from "../logging.js";
@@ -150,6 +151,12 @@ function executePrefetchFetch(
150
151
 
151
152
  const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
152
153
  priority: "low" as RequestPriority,
154
+ // During an action's flight the state is not rotated, so the old
155
+ // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass it so
156
+ // a prefetch fetches fresh rather than warming the map with stale bytes (the
157
+ // fence's HTTP-cache-bypass requirement applies to prefetch as well as
158
+ // navigation fetches).
159
+ ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
153
160
  signal,
154
161
  headers: {
155
162
  "X-Rango-State": getRangoState(),
@@ -71,10 +71,13 @@ function scheduleDrain(): void {
71
71
  Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
72
  )
73
73
  .then(() => {
74
- drainScheduled = false;
75
- // Stale drain: a cancel/abort happened while we were waiting.
76
- // A fresh scheduleDrain will be called by whatever enqueues next.
74
+ // Stale drain: a cancel/abort happened while we were waiting, and a fresh
75
+ // scheduleDrain may already own drainScheduled for the new generation.
76
+ // Bail WITHOUT clearing the flag so we don't clobber the live wait's
77
+ // single-in-flight-drain coalescing (clearing it here would let the next
78
+ // enqueue start a third overlapping wait).
77
79
  if (gen !== drainGeneration) return;
80
+ drainScheduled = false;
78
81
  if (queue.length > 0) drain();
79
82
  });
80
83
  }