@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
@@ -237,10 +237,6 @@ export interface EventController {
237
237
  hadAnyConcurrentActions(): boolean;
238
238
  }
239
239
 
240
- // ============================================================================
241
- // Default States
242
- // ============================================================================
243
-
244
240
  const DEFAULT_ACTION_STATE: TrackedActionState = {
245
241
  state: "idle",
246
242
  actionId: null,
@@ -261,16 +257,12 @@ function matchesActionId(
261
257
  entryActionId: string,
262
258
  ): boolean {
263
259
  if (subscriptionId.includes("#")) {
264
- // Full ID: exact match
265
260
  return subscriptionId === entryActionId;
266
261
  }
267
- // Action name only: suffix match (matches "anything#actionName")
268
262
  return entryActionId.endsWith(`#${subscriptionId}`);
269
263
  }
270
264
 
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.
265
+ // Batch rapid notifications into one microtask to prevent render storms
274
266
  function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
275
267
  let timeout: ReturnType<typeof setTimeout> | null = null;
276
268
  return () => {
@@ -282,13 +274,6 @@ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
282
274
  };
283
275
  }
284
276
 
285
- // ============================================================================
286
- // Implementation
287
- // ============================================================================
288
-
289
- /**
290
- * Configuration for creating an event controller
291
- */
292
277
  export interface EventControllerConfig {
293
278
  initialLocation?: NavigationLocation;
294
279
  }
@@ -306,51 +291,34 @@ export interface EventControllerConfig {
306
291
  export function createEventController(
307
292
  config?: EventControllerConfig,
308
293
  ): EventController {
309
- // ========================================================================
310
- // Source of Truth
311
- // ========================================================================
312
-
313
- // Current navigation in progress (null = idle)
314
294
  let currentNavigation: NavigationEntry | null = null;
315
295
 
316
- // All in-flight actions (keyed by unique instance ID)
317
296
  const inflightActions = new Map<string, ActionEntry>();
318
297
 
319
- // Committed location (updated when navigation completes)
320
298
  let location: NavigationLocation =
321
299
  config?.initialLocation ??
322
300
  (typeof window !== "undefined"
323
301
  ? new URL(window.location.href)
324
302
  : new URL("/", "http://localhost"));
325
303
 
326
- // Track if any concurrent actions occurred (for consolidation)
327
304
  let hadAnyConcurrentActions = false;
328
305
 
329
- // Track segments revalidated by concurrent actions
330
306
  const concurrentRevalidatedSegments = new Set<string>();
331
307
 
332
- // Active streaming count (independent of navigation/action lifecycle)
333
308
  let activeStreamCount = 0;
334
309
 
335
- // Handle data from RSC payload
336
310
  let handleData: HandleData = {};
337
311
  let handleSegmentOrder: string[] = [];
338
312
  let routeSegmentIds: string[] = [];
339
313
 
340
- // Merged route params from current match
341
314
  let routeParams: Record<string, string> = {};
342
315
 
343
- // ========================================================================
344
- // Listeners
345
- // ========================================================================
346
-
347
316
  const stateListeners = new Set<StateListener>();
348
317
  const actionListeners = new Map<string, Set<ActionStateListener>>();
349
318
  const handleListeners = new Set<HandleListener>();
350
319
 
351
320
  const notify = makeDebouncedNotifier(stateListeners);
352
321
 
353
- // Debounce per-action notifications
354
322
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
355
323
 
356
324
  function notifyAction(actionId: string) {
@@ -362,8 +330,6 @@ export function createEventController(
362
330
  actionId,
363
331
  setTimeout(() => {
364
332
  actionNotifyTimeouts.delete(actionId);
365
- // Notify all listeners whose subscription ID matches this action
366
- // This includes exact matches and suffix matches (e.g., "addToCart" matches "hash#addToCart")
367
333
  for (const [subscriptionId, listeners] of actionListeners) {
368
334
  if (matchesActionId(subscriptionId, actionId)) {
369
335
  const state = getActionState(subscriptionId);
@@ -376,12 +342,7 @@ export function createEventController(
376
342
 
377
343
  const notifyHandles = makeDebouncedNotifier(handleListeners);
378
344
 
379
- // ========================================================================
380
- // Derived State
381
- // ========================================================================
382
-
383
345
  function getState(): DerivedNavigationState {
384
- // Build inflight actions list (for compatibility with existing API)
385
346
  const inflightActionsList: InflightAction[] = [...inflightActions.values()]
386
347
  .filter((a) => a.phase !== "settling")
387
348
  .map((a) => ({
@@ -391,15 +352,12 @@ export function createEventController(
391
352
  startedAt: a.startedAt,
392
353
  }));
393
354
 
394
- // State: loading if navigation OR actions are in progress
395
- // Background revalidations (skipLoadingState) don't affect visible state
396
355
  const hasActiveActions = inflightActionsList.length > 0;
397
356
  const isVisibleNavigation =
398
357
  currentNavigation !== null &&
399
358
  !currentNavigation.options?.skipLoadingState;
400
359
  const state = isVisibleNavigation || hasActiveActions ? "loading" : "idle";
401
360
 
402
- // Streaming: true if any active streams (navigation or action) or loading
403
361
  const isStreaming = activeStreamCount > 0 || state === "loading";
404
362
 
405
363
  return {
@@ -421,8 +379,6 @@ export function createEventController(
421
379
  }
422
380
 
423
381
  function getActionState(actionId: string): TrackedActionState {
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
382
  const entry = [...inflightActions.values()]
427
383
  .filter((a) => matchesActionId(actionId, a.actionId))
428
384
  .reduce<ActionEntry | undefined>((best, a) => {
@@ -437,7 +393,6 @@ export function createEventController(
437
393
  return { ...DEFAULT_ACTION_STATE };
438
394
  }
439
395
 
440
- // Derive state from phase
441
396
  let state: ActionLifecycleState;
442
397
  switch (entry.phase) {
443
398
  case "fetching":
@@ -881,40 +836,3 @@ export function createEventController(
881
836
  hadAnyConcurrentActions: () => hadAnyConcurrentActions,
882
837
  };
883
838
  }
884
-
885
- // ============================================================================
886
- // Singleton
887
- // ============================================================================
888
-
889
- let controllerInstance: EventController | null = null;
890
-
891
- /**
892
- * Initialize the global event controller
893
- */
894
- export function initEventController(
895
- config?: EventControllerConfig,
896
- ): EventController {
897
- if (!controllerInstance) {
898
- controllerInstance = createEventController(config);
899
- }
900
- return controllerInstance;
901
- }
902
-
903
- /**
904
- * Get the global event controller
905
- */
906
- export function getEventController(): EventController {
907
- if (!controllerInstance) {
908
- throw new Error(
909
- "Event controller not initialized. Call initEventController first.",
910
- );
911
- }
912
- return controllerInstance;
913
- }
914
-
915
- /**
916
- * Reset the controller instance (for testing)
917
- */
918
- export function resetEventController(): void {
919
- controllerInstance = null;
920
- }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Client seat of `invalidateClientCache()` (the `default` export condition).
3
+ *
4
+ * Makes the current client behave as if a server action had just completed:
5
+ * the history cache is marked stale (SWR), the prefetch map is flushed, the
6
+ * state rotates, and sibling tabs are broadcast to — the same
7
+ * `markCacheAsStaleAndBroadcast()` path the server-action bridge uses. This is
8
+ * the gentler mark-stale (not hard-clear) behavior, so Back renders the cached
9
+ * entry instantly and revalidates.
10
+ */
11
+
12
+ import { getRegisteredStore } from "./navigation-store-handle.js";
13
+ import { clearPrefetchCache } from "./prefetch/cache.js";
14
+
15
+ export function invalidateClientCache(): void {
16
+ if (typeof document === "undefined") {
17
+ // SSR pass of a client component also resolves the default condition. A
18
+ // render-time call must not take down the page; no-op with a dev warning.
19
+ if (process.env.NODE_ENV !== "production") {
20
+ console.warn(
21
+ "[rango] invalidateClientCache() was called during a server render; " +
22
+ "it is a no-op outside the browser.",
23
+ );
24
+ }
25
+ return;
26
+ }
27
+
28
+ const store = getRegisteredStore();
29
+ if (store) {
30
+ store.markCacheAsStaleAndBroadcast();
31
+ return;
32
+ }
33
+
34
+ // Pre-boot: no store registered yet. clearPrefetchCache() (which rotates the
35
+ // state) is complete at this point — there is no history cache to mark and no
36
+ // sibling state worth broadcasting.
37
+ clearPrefetchCache();
38
+ }
39
+
40
+ /**
41
+ * Client no-op for `keepClientCache()`. It is a server action directive (the
42
+ * `react-server` condition sets a response header the action bridge reads);
43
+ * there is nothing to suppress from the client side.
44
+ */
45
+ export function keepClientCache(): void {
46
+ if (process.env.NODE_ENV !== "production") {
47
+ console.warn(
48
+ "[rango] keepClientCache() has no effect on the client; it is a server " +
49
+ "action directive. Call it from inside a server action.",
50
+ );
51
+ }
52
+ }
@@ -5,6 +5,8 @@ import type {
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
7
  import { setAppVersion } from "./app-version.js";
8
+ import { isActionFenceActive } from "./action-fence.js";
9
+ import { getRangoState } from "./rango-state.js";
8
10
  import * as React from "react";
9
11
  import { startTransition } from "react";
10
12
  import {
@@ -446,11 +448,22 @@ export function createNavigationBridge(
446
448
  // Helper to check if streaming is in progress
447
449
  const isStreaming = () => eventController.getState().isStreaming;
448
450
 
451
+ // Surface any external rotation of the rango state cookie (a server
452
+ // Set-Cookie, a sibling tab, a cookie clear) BEFORE reading the stale bit.
453
+ // The divergence observer only runs inside getRangoState() — fetch-time —
454
+ // so a popstate-first interaction would otherwise serve a pre-mutation
455
+ // page as fresh and never fetch to trigger the observer. Reading here lets
456
+ // the observer mark the history cache stale so getCachedSegments sees it.
457
+ getRangoState();
458
+
449
459
  // Check if we can restore from history cache
450
460
  const cached = store.getCachedSegments(historyKey);
451
461
  const cachedSegments = cached?.segments;
452
462
  const cachedHandleData = cached?.handleData;
453
- const isStale = cached?.stale ?? false;
463
+ // While an action is in flight the fence persists no stale flag, so OR it
464
+ // in here: a popstate during the flight serves the cached entry AND
465
+ // revalidates (SWR) instead of serving it as fresh.
466
+ const isStale = (cached?.stale ?? false) || isActionFenceActive();
454
467
 
455
468
  if (cachedSegments && cachedSegments.length > 0) {
456
469
  // Update store to point to this history entry
@@ -12,6 +12,7 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import { isActionFenceActive } from "./action-fence.js";
15
16
  import {
16
17
  extractRscHeaderUrl,
17
18
  emptyResponse,
@@ -108,7 +109,14 @@ export function createNavigationClient(
108
109
  // server-action invalidation) auto-invalidates both scopes.
109
110
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
110
111
  // fresh modules), and intercept contexts (source-dependent responses).
111
- const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
112
+ // Suspend prefetch consumption while an action is in flight: a queued
113
+ // prefetch holds pre-mutation data and must not be served until the
114
+ // action's response decides whether anything changed.
115
+ const canUsePrefetch =
116
+ !staleRevalidation &&
117
+ !hmr &&
118
+ !interceptSourceUrl &&
119
+ !isActionFenceActive();
112
120
  const rangoState = getRangoState();
113
121
  const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
114
122
  const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
@@ -207,6 +215,11 @@ export function createNavigationClient(
207
215
  }
208
216
 
209
217
  return fetch(fetchUrl, {
218
+ // During an action's flight the state is not rotated, so the old
219
+ // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
220
+ // it so a genuine mid-action navigation fetches fresh instead of being
221
+ // served the stale prefetched bytes.
222
+ ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
210
223
  headers: {
211
224
  "X-RSC-Router-Client-Path": previousUrl,
212
225
  "X-Rango-State": getRangoState(),
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A module-level handle to the active navigation store.
3
+ *
4
+ * The boot path (`rsc-router.tsx`) calls `createNavigationStore()` directly;
5
+ * there is no global store singleton. This handle is the live reference for
6
+ * code that needs the store but does not
7
+ * receive it by argument: the jar-divergence observer (below) and the client
8
+ * seat of `invalidateClientCache()` (added later).
9
+ *
10
+ * Dependency-light on purpose: it imports only `setRangoStateObserver` and the
11
+ * store type, so pulling it into the default root entry does not drag the
12
+ * navigation store into bundles that previously lacked it.
13
+ */
14
+
15
+ import { setRangoStateObserver } from "./rango-state.js";
16
+ import type { NavigationStore } from "./types.js";
17
+
18
+ let registeredStore: NavigationStore | null = null;
19
+
20
+ /**
21
+ * Register the active navigation store at boot, and wire the jar-divergence
22
+ * observer: when a per-request cookie read detects an EXTERNAL rotation (a
23
+ * sibling tab, a server `Set-Cookie`, or a cookie clear), mark this tab's
24
+ * history cache stale. The history cache is not state-keyed, so the value
25
+ * rotation alone does not reach it. No broadcast, no prefetch clear, no
26
+ * re-rotation — the value already changed externally.
27
+ */
28
+ export function registerNavigationStore(store: NavigationStore): void {
29
+ registeredStore = store;
30
+ setRangoStateObserver(() => {
31
+ registeredStore?.markHistoryCacheStale();
32
+ });
33
+ }
34
+
35
+ /** The active navigation store, or null before boot has registered it. */
36
+ export function getRegisteredStore(): NavigationStore | null {
37
+ return registeredStore;
38
+ }
@@ -130,14 +130,14 @@ export interface NavigationStoreConfig {
130
130
 
131
131
  /**
132
132
  * Enable cross-tab cache invalidation via BroadcastChannel (default: true)
133
- * When cache is cleared (via server actions or useClientCache().clear()),
133
+ * When cache is cleared (via server actions or invalidateClientCache()),
134
134
  * other tabs will also clear their cache
135
135
  */
136
136
  crossTabSync?: boolean;
137
137
 
138
138
  /**
139
139
  * Auto-refresh when another tab mutates data on the same path (default: true)
140
- * Triggered when cache is cleared via server actions or useClientCache().clear()
140
+ * Triggered when cache is cleared via server actions or invalidateClientCache()
141
141
  * Requires crossTabSync to be enabled
142
142
  */
143
143
  crossTabAutoRefresh?: boolean;
@@ -335,12 +335,24 @@ export function createNavigationStore(
335
335
  }
336
336
 
337
337
  /**
338
- * Mark all cache entries as stale (internal - does not broadcast)
338
+ * Mark every history entry stale WITHOUT touching the prefetch caches or the
339
+ * rango state. Used by the jar-divergence observer: an external rotation has
340
+ * already changed the state value (so prefetch/HTTP entries strand under the
341
+ * retired key), and this tab must NOT re-rotate — only the history cache,
342
+ * which is not state-keyed, needs marking.
339
343
  */
340
- function markCacheAsStaleInternal(): void {
344
+ function markHistoryStale(): void {
341
345
  for (let i = 0; i < historyCache.length; i++) {
342
346
  historyCache[i][2] = true;
343
347
  }
348
+ }
349
+
350
+ /**
351
+ * Mark all cache entries as stale (internal - does not broadcast). Also
352
+ * clears the prefetch caches, which rotates the rango state.
353
+ */
354
+ function markCacheAsStaleInternal(): void {
355
+ markHistoryStale();
344
356
  clearPrefetchCache();
345
357
  }
346
358
 
@@ -659,6 +671,16 @@ export function createNavigationStore(
659
671
  markCacheAsStaleInternal();
660
672
  },
661
673
 
674
+ /**
675
+ * Mark every history entry stale WITHOUT clearing the prefetch caches or
676
+ * rotating the rango state. The jar-divergence observer calls this after an
677
+ * external rotation has already changed the state value, so re-rotating
678
+ * here would ping-pong with the tab that rotated.
679
+ */
680
+ markHistoryCacheStale(): void {
681
+ markHistoryStale();
682
+ },
683
+
662
684
  /**
663
685
  * Clear the history cache and broadcast to other tabs
664
686
  * Use this for hard invalidation when data is definitely stale
@@ -675,14 +697,6 @@ export function createNavigationStore(
675
697
  markStaleAndBroadcast();
676
698
  },
677
699
 
678
- /**
679
- * Broadcast cache invalidation to other tabs without clearing local cache
680
- * Used after consolidation fetch where local cache has fresh data
681
- */
682
- broadcastCacheInvalidation(): void {
683
- broadcastInvalidation();
684
- },
685
-
686
700
  /**
687
701
  * Set the callback to invoke when cross-tab refresh is triggered
688
702
  * Called by navigation bridge during initialization
@@ -799,42 +813,3 @@ export function createNavigationStore(
799
813
  },
800
814
  };
801
815
  }
802
-
803
- // Singleton store instance
804
- let storeInstance: NavigationStore | null = null;
805
-
806
- /**
807
- * Initialize the global navigation store
808
- *
809
- * Should be called once during app initialization.
810
- * Subsequent calls return the existing instance.
811
- */
812
- export function initNavigationStore(
813
- config?: NavigationStoreConfig,
814
- ): NavigationStore {
815
- if (!storeInstance) {
816
- storeInstance = createNavigationStore(config);
817
- }
818
- return storeInstance;
819
- }
820
-
821
- /**
822
- * Get the global navigation store
823
- *
824
- * Throws if store hasn't been initialized.
825
- */
826
- export function getNavigationStore(): NavigationStore {
827
- if (!storeInstance) {
828
- throw new Error(
829
- "Navigation store not initialized. Call initNavigationStore first.",
830
- );
831
- }
832
- return storeInstance;
833
- }
834
-
835
- /**
836
- * Reset the store instance (for testing)
837
- */
838
- export function resetNavigationStore(): void {
839
- storeInstance = null;
840
- }
@@ -13,7 +13,6 @@ import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
14
  import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
 
16
- // Re-export for consumers that import from navigation-transaction
17
16
  export { resolveNavigationState } from "./history-state.js";
18
17
 
19
18
  /** Check if a history state object contains location state keys. */
@@ -25,7 +24,6 @@ function hasLocationState(state: unknown): boolean {
25
24
  );
26
25
  }
27
26
 
28
- // Polyfill Symbol.dispose for Safari and older browsers
29
27
  if (typeof Symbol.dispose === "undefined") {
30
28
  (Symbol as any).dispose = Symbol("Symbol.dispose");
31
29
  }
@@ -114,7 +112,6 @@ export function createNavigationTransaction(
114
112
  let committed = false;
115
113
  const currentUrl = window.location.href;
116
114
 
117
- // Start navigation in event controller (this sets loading state)
118
115
  const handle = eventController.startNavigation(url, options);
119
116
 
120
117
  /**
@@ -138,72 +135,50 @@ export function createNavigationTransaction(
138
135
 
139
136
  const parsedUrl = new URL(url, window.location.origin);
140
137
 
141
- // Generate history key from URL (with intercept suffix for separate caching)
142
138
  const historyKey = generateHistoryKey(url, { intercept });
143
139
 
144
- // For cache-only commits (stale revalidation), only update cache and return
145
- // Don't touch store state or history - user may have navigated elsewhere
146
140
  if (cacheOnly) {
147
141
  const currentHandleData = eventController.getHandleState().data;
148
142
  store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
149
- // Complete the navigation handle so currentNavigation is cleared.
150
- // Without this, the entry lingers and weakens state-machine invariants.
151
143
  handle.complete(parsedUrl);
152
144
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
145
  return { scroll: false };
154
146
  }
155
147
 
156
- // Save current scroll position before navigating
157
148
  handleNavigationStart();
158
149
 
159
- // Update segment state atomically
160
150
  store.setSegmentIds(segmentIds);
161
151
  store.setCurrentUrl(url);
162
152
  store.setPath(parsedUrl.pathname);
163
153
 
164
154
  store.setHistoryKey(historyKey);
165
155
 
166
- // Cache segments with current handleData for this history entry
167
156
  const currentHandleData = eventController.getHandleState().data;
168
157
  store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
169
158
 
170
- // For server actions, skip URL/history updates but still complete navigation
171
159
  if (storeOnly) {
172
160
  debugLog("[Browser] Store updated (action)");
173
- // Complete navigation to clear loading state
174
161
  handle.complete(parsedUrl);
175
162
  return { scroll: false };
176
163
  }
177
164
 
178
- // Build history state - include user state, intercept info, and server-set state
179
165
  const historyState = buildHistoryState(
180
166
  opts.state,
181
167
  { intercept, sourceUrl: interceptSourceUrl },
182
168
  serverState,
183
169
  );
184
170
 
185
- // Snapshot old state before pushState/replaceState overwrites it.
186
- // Used to detect when location state is being cleared.
187
171
  const oldState = window.history.state;
188
172
 
189
- // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
173
  pushHistoryWithIdx(historyState, url, replace ?? false);
191
- // Ensure new history entry has a scroll restoration key
192
174
  ensureHistoryKey();
193
175
 
194
- // Notify location state hooks when either old or new state carries
195
- // location state. This covers both "set new state" and "clear old state"
196
- // for same-page navigations where components don't remount.
197
176
  if (hasLocationState(oldState) || hasLocationState(historyState)) {
198
177
  window.dispatchEvent(new Event("__rsc_locationstate"));
199
178
  }
200
179
 
201
- // Complete the navigation in event controller (sets idle state, updates location)
202
180
  handle.complete(parsedUrl);
203
181
 
204
- // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
205
- // scroll AFTER onUpdate() so React has the new content before we scroll.
206
-
207
182
  debugLog(
208
183
  "[Browser] Navigation committed, historyKey:",
209
184
  historyKey,
@@ -217,10 +192,6 @@ export function createNavigationTransaction(
217
192
  handle,
218
193
  commit,
219
194
 
220
- /**
221
- * Create a bound transaction with pre-configured URL options
222
- * segmentIds and segments provided at commit time (after they're resolved)
223
- */
224
195
  with(
225
196
  opts: Omit<CommitOptions, "segmentIds" | "segments">,
226
197
  ): BoundTransaction {
@@ -264,13 +235,10 @@ export function createNavigationTransaction(
264
235
  },
265
236
 
266
237
  [Symbol.dispose]() {
267
- // Superseded: another navigation took over.
268
238
  if (handle.signal.aborted) {
269
239
  return;
270
240
  }
271
241
 
272
- // Failed (not committed): keep the target URL -- the error UI owns it.
273
- // Just reset the event controller to idle.
274
242
  if (!committed) {
275
243
  handle[Symbol.dispose]();
276
244
  }