@rangojs/router 0.1.0 → 0.2.0

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 (128) hide show
  1. package/README.md +2 -1
  2. package/dist/bin/rango.js +11 -1
  3. package/dist/types/browser/dev-discovery.d.ts +11 -0
  4. package/dist/types/browser/navigation-store.d.ts +3 -13
  5. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  6. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  7. package/dist/types/browser/types.d.ts +4 -16
  8. package/dist/types/build/generate-manifest.d.ts +6 -0
  9. package/dist/types/cache/cache-scope.d.ts +7 -5
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  11. package/dist/types/cache/document-cache.d.ts +3 -1
  12. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  13. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  14. package/dist/types/cache/types.d.ts +25 -7
  15. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  16. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  17. package/dist/types/prerender/store.d.ts +1 -0
  18. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  19. package/dist/types/route-map-builder.d.ts +4 -29
  20. package/dist/types/router/match-handlers.d.ts +2 -3
  21. package/dist/types/router/prerender-match.d.ts +4 -1
  22. package/dist/types/router/router-interfaces.d.ts +3 -1
  23. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  24. package/dist/types/router/transition-when.d.ts +13 -0
  25. package/dist/types/rsc/handler-context.d.ts +1 -0
  26. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  27. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  28. package/dist/types/rsc/shell-capture.d.ts +4 -8
  29. package/dist/types/rsc/shell-serve.d.ts +2 -0
  30. package/dist/types/rsc/transition-gate.d.ts +10 -14
  31. package/dist/types/rsc/types.d.ts +2 -0
  32. package/dist/types/server/context.d.ts +16 -0
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/server.d.ts +1 -1
  35. package/dist/types/testing/e2e/index.d.ts +1 -1
  36. package/dist/types/testing/index.d.ts +2 -2
  37. package/dist/types/testing/run-transition-when.d.ts +6 -5
  38. package/dist/types/testing/shell-status.d.ts +23 -3
  39. package/dist/types/types/route-entry.d.ts +3 -0
  40. package/dist/types/types/segments.d.ts +27 -22
  41. package/dist/types/urls/path-helper-types.d.ts +4 -3
  42. package/dist/types/vite/discovery/state.d.ts +3 -2
  43. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  44. package/dist/vite/index.js +194 -107
  45. package/package.json +24 -20
  46. package/skills/cache-guide/SKILL.md +6 -3
  47. package/skills/caching/SKILL.md +1 -1
  48. package/skills/catalog.json +7 -1
  49. package/skills/deployment-caching/SKILL.md +176 -0
  50. package/skills/document-cache/SKILL.md +30 -3
  51. package/skills/ppr/SKILL.md +90 -14
  52. package/skills/prerender/SKILL.md +15 -8
  53. package/skills/rango/SKILL.md +20 -17
  54. package/skills/testing/SKILL.md +1 -1
  55. package/skills/testing/cache-prerender.md +5 -1
  56. package/skills/vercel/SKILL.md +22 -1
  57. package/skills/view-transitions/SKILL.md +12 -8
  58. package/src/browser/dev-discovery.ts +66 -0
  59. package/src/browser/navigation-client.ts +6 -0
  60. package/src/browser/navigation-store.ts +4 -271
  61. package/src/browser/partial-update.ts +7 -14
  62. package/src/browser/prefetch/cache.ts +1 -1
  63. package/src/browser/prefetch/loader.ts +110 -0
  64. package/src/browser/prefetch/runtime.ts +7 -0
  65. package/src/browser/react/Link.tsx +7 -10
  66. package/src/browser/react/NavigationProvider.tsx +1 -1
  67. package/src/browser/react/use-router.ts +1 -1
  68. package/src/browser/rsc-router.tsx +4 -2
  69. package/src/browser/types.ts +4 -27
  70. package/src/build/generate-manifest.ts +11 -0
  71. package/src/cache/cache-scope.ts +23 -7
  72. package/src/cache/cf/cf-cache-store.ts +63 -39
  73. package/src/cache/document-cache.ts +4 -2
  74. package/src/cache/memory-segment-store.ts +17 -6
  75. package/src/cache/shell-snapshot.ts +45 -15
  76. package/src/cache/types.ts +24 -6
  77. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  78. package/src/dev-discovery-protocol.ts +11 -0
  79. package/src/prerender/build-shell-capture.ts +7 -1
  80. package/src/prerender/store.ts +5 -0
  81. package/src/route-definition/dsl-helpers.ts +7 -2
  82. package/src/route-definition/helpers-types.ts +6 -6
  83. package/src/route-map-builder.ts +56 -49
  84. package/src/router/handler-context.ts +13 -5
  85. package/src/router/lazy-includes.ts +1 -0
  86. package/src/router/match-api.ts +8 -4
  87. package/src/router/match-handlers.ts +44 -6
  88. package/src/router/match-middleware/cache-lookup.ts +10 -3
  89. package/src/router/prerender-match.ts +19 -2
  90. package/src/router/router-interfaces.ts +4 -0
  91. package/src/router/segment-resolution/static-store.ts +36 -10
  92. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  93. package/src/router/transition-when.ts +76 -0
  94. package/src/router.ts +34 -3
  95. package/src/rsc/handler-context.ts +1 -0
  96. package/src/rsc/handler.ts +3 -1
  97. package/src/rsc/loader-fetch.ts +2 -2
  98. package/src/rsc/manifest-init.ts +4 -11
  99. package/src/rsc/progressive-enhancement.ts +9 -14
  100. package/src/rsc/render-pipeline.ts +10 -2
  101. package/src/rsc/rsc-rendering.ts +319 -147
  102. package/src/rsc/shell-capture.ts +34 -11
  103. package/src/rsc/shell-serve.ts +3 -0
  104. package/src/rsc/transition-gate.ts +37 -40
  105. package/src/rsc/types.ts +2 -0
  106. package/src/server/context.ts +28 -0
  107. package/src/server/request-context.ts +38 -10
  108. package/src/server.ts +0 -2
  109. package/src/testing/dispatch.ts +1 -0
  110. package/src/testing/e2e/index.ts +5 -0
  111. package/src/testing/index.ts +9 -1
  112. package/src/testing/run-transition-when.ts +42 -9
  113. package/src/testing/shell-status.ts +92 -3
  114. package/src/types/route-entry.ts +3 -0
  115. package/src/types/segments.ts +27 -22
  116. package/src/urls/include-helper.ts +1 -0
  117. package/src/urls/path-helper-types.ts +4 -3
  118. package/src/urls/path-helper.ts +8 -4
  119. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  120. package/src/vite/discovery/discover-routers.ts +7 -66
  121. package/src/vite/discovery/prerender-collection.ts +13 -2
  122. package/src/vite/discovery/state.ts +4 -4
  123. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  124. package/src/vite/plugins/version-injector.ts +0 -1
  125. package/src/vite/plugins/virtual-entries.ts +11 -1
  126. package/src/vite/router-discovery.ts +132 -22
  127. package/src/vite/utils/manifest-utils.ts +1 -4
  128. package/src/vite/utils/shared-utils.ts +7 -0
@@ -209,9 +209,8 @@ interface TransitionConfig {
209
209
  default?: string | Record<string, string>; // fallback for any phase
210
210
  name?: string; // explicit view-transition-name
211
211
  viewTransition?: "auto" | false; // boundary opt-out (see below)
212
- // Conditional gate, evaluated server-side AFTER the route handler. Return
213
- // false to drop this transition for the request, so the navigation streams its
214
- // loading() fallback instead of holding. See the gate section below.
212
+ // Conditional server-side gate. PPR routes run it before route handlers and
213
+ // on every replay; other routes run it after handlers on fresh resolution.
215
214
  when?: (ctx: TransitionWhenContext) => boolean;
216
215
  }
217
216
  ```
@@ -223,14 +222,19 @@ interface TransitionConfig {
223
222
 
224
223
  ## Conditional transitions (`when`)
225
224
 
226
- `transition({ when })` gates the hold per request. The predicate runs **server-side, AFTER the route handler** and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
225
+ `transition({ when })` gates the hold per request. The predicate runs server-side and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
227
226
 
228
- Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for post-handler reads:
227
+ Timing follows the route's rendering contract:
228
+
229
+ - On an ordinary route it runs after the route handler during fresh resolution, so `get()` can read handler- and middleware-set context. Cache/prerender hits replay the stored decision.
230
+ - On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers. This is what keeps the handler-free PPR fast path available without a second API.
231
+
232
+ Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for request-context reads:
229
233
 
230
234
  ```ts
231
235
  import type { TransitionWhenContext } from "@rangojs/router";
232
236
 
233
- // Hold only when the handler marked this request (handler sets, gate reads):
237
+ // Ordinary route: hold only when the handler marked this request:
234
238
  transition({ when: (ctx) => ctx.get(KeepScroll) === true });
235
239
 
236
240
  // Hold only when arriving from a specific page (the navigation SOURCE):
@@ -252,13 +256,13 @@ transition({
252
256
  | `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
253
257
  | `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
254
258
  | `method` | `"GET"` (nav) / `"POST"` (action) | always |
255
- | `get` / `env` | read handler/middleware vars + app env | always |
259
+ | `get` / `env` | read request vars + app env | always; PPR timing exposes middleware vars, not handler writes |
256
260
 
257
261
  A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
258
262
 
259
263
  **Same-route content-holds need the transition present on the FIRST render.** The same-route hold works by giving the route a param-agnostic key so a param change reconciles instead of remounting — but that key is established when the route first mounts. A source gate that returns `false` on the initial full load (where `currentUrl`/`currentParams`/`fromRouteName` are undefined) drops the transition before the route mounts, so the route mounts _outside_ a transition scope and **every** later same-route param nav remounts (flashing the skeleton) regardless of what the gate decides on those navs. Write source gates so they hold when there is no source — e.g. `({ currentParams }) => currentParams?.tab !== "raw"` (true on the initial load) rather than `=== "details"` (false on the initial load) — when the same-route content-hold must engage. This only affects same-route param navigations; action-only or cross-route gating is unaffected (no shared param key is in play).
260
264
 
261
- **Prefetch / cache caveat.** The gate runs during resolution, so a **prefetched** navigation decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source and a `cache()`/prerender hit replays the stored transition without re-running the predicate. A source-sensitive gate can therefore be frozen to prefetch/store-time state. This covers ~99% of navigations; if yours must reflect the exact click-time source, source-scope the prefetch (`<Link prefetchKey=":source">`) and don't `cache()` that segment.
265
+ **Prefetch / cache caveat.** A **prefetched** navigation still decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source. Non-PPR `cache()`/prerender hits also replay the stored transition without rerunning the predicate. PPR routes rerun it on the server for each cache/prerender/PPR match, but a completed browser prefetch still carries its earlier Flight decision. If the exact click-time source matters, source-scope the prefetch (`<Link prefetchKey=":source">`).
262
266
 
263
267
  ## Opting out of the router boundary (place your own `<ViewTransition>`)
264
268
 
@@ -0,0 +1,66 @@
1
+ import {
2
+ DEV_DISCOVERY_QUERY_EVENT,
3
+ DEV_DISCOVERY_READY_EVENT,
4
+ isValidDevDiscoveryEpoch,
5
+ } from "../dev-discovery-protocol.js";
6
+
7
+ export interface DevDiscoveryHot {
8
+ on(
9
+ event: typeof DEV_DISCOVERY_READY_EVENT | "vite:ws:connect",
10
+ listener: (payload: unknown) => void,
11
+ ): void;
12
+ send(
13
+ event: typeof DEV_DISCOVERY_QUERY_EVENT,
14
+ payload: { epoch: number },
15
+ ): void;
16
+ }
17
+
18
+ export interface DevDiscoveryHandshakeOptions {
19
+ reload?: () => void;
20
+ }
21
+
22
+ function readEpoch(payload: unknown): number | undefined {
23
+ if (
24
+ typeof payload !== "object" ||
25
+ payload === null ||
26
+ !("epoch" in payload)
27
+ ) {
28
+ return undefined;
29
+ }
30
+
31
+ return isValidDevDiscoveryEpoch(payload.epoch) ? payload.epoch : undefined;
32
+ }
33
+
34
+ export function startDevDiscoveryHandshake(
35
+ documentEpoch: unknown,
36
+ hot: DevDiscoveryHot,
37
+ options: DevDiscoveryHandshakeOptions = {},
38
+ ): void {
39
+ if (!isValidDevDiscoveryEpoch(documentEpoch)) return;
40
+ const currentDocumentEpoch = documentEpoch;
41
+
42
+ const reload = options.reload ?? (() => window.location.reload());
43
+ let reloadRequested = false;
44
+
45
+ function reloadIfNewer(payload: unknown): void {
46
+ const readyEpoch = readEpoch(payload);
47
+ if (
48
+ reloadRequested ||
49
+ readyEpoch === undefined ||
50
+ readyEpoch <= currentDocumentEpoch
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ reloadRequested = true;
56
+ reload();
57
+ }
58
+
59
+ function requestReadyEpoch(): void {
60
+ hot.send(DEV_DISCOVERY_QUERY_EVENT, { epoch: currentDocumentEpoch });
61
+ }
62
+
63
+ hot.on(DEV_DISCOVERY_READY_EVENT, reloadIfNewer);
64
+ hot.on("vite:ws:connect", requestReadyEpoch);
65
+ requestReadyEpoch();
66
+ }
@@ -28,6 +28,7 @@ import {
28
28
  consumePrefetch,
29
29
  type DecodedPrefetch,
30
30
  } from "./prefetch/cache.js";
31
+ import { cancelAllPrefetches } from "./prefetch/loader.js";
31
32
 
32
33
  /**
33
34
  * Create a navigation client for fetching RSC payloads
@@ -101,6 +102,11 @@ export function createNavigationClient(
101
102
  fetchUrl.searchParams.set("_rsc_rid", routerId);
102
103
  }
103
104
 
105
+ // If the lazy prefetch runtime has not loaded yet, prevent a deferred
106
+ // speculative request from starting after this navigation misses the
107
+ // synchronous cache lookup and begins its own fetch.
108
+ cancelAllPrefetches(targetUrl);
109
+
104
110
  // Check completed in-memory prefetch cache before making a network
105
111
  // request. Try the source-scoped key first (populated when the server
106
112
  // tagged the prefetch response as source-sensitive, e.g. intercepts,
@@ -1,30 +1,14 @@
1
1
  import type {
2
- NavigationState,
3
2
  NavigationLocation,
4
3
  SegmentState,
5
4
  NavigationStore,
6
5
  NavigationUpdate,
7
6
  UpdateSubscriber,
8
- StateListener,
9
7
  ResolvedSegment,
10
- InflightAction,
11
- TrackedActionState,
12
- ActionStateListener,
13
8
  HandleData,
14
9
  } from "./types.js";
15
10
  import { clearPrefetchCache } from "./prefetch/cache.js";
16
11
 
17
- /**
18
- * Default action state (idle with no payload)
19
- */
20
- const DEFAULT_ACTION_STATE: TrackedActionState = {
21
- state: "idle",
22
- actionId: null,
23
- payload: null,
24
- error: null,
25
- result: null,
26
- };
27
-
28
12
  // Maximum number of history entries to cache (URLs visited)
29
13
  const HISTORY_CACHE_SIZE = 20;
30
14
 
@@ -177,11 +161,10 @@ function createLocation(loc: { href: string }): NavigationLocation {
177
161
  }
178
162
 
179
163
  /**
180
- * Create a navigation store for managing browser-side navigation state
164
+ * Create a navigation store for browser-side segment and history state.
181
165
  *
182
- * The store manages two types of state:
183
- * - NavigationState: Public state exposed via useNavigation hook
184
- * - SegmentState: Internal segment management for partial RSC updates
166
+ * The public navigation lifecycle lives in EventController; this store owns
167
+ * segment reconciliation, history snapshots, and cross-tab cache invalidation.
185
168
  *
186
169
  * @param config - Initial configuration
187
170
  * @returns NavigationStore instance
@@ -193,15 +176,6 @@ function createLocation(loc: { href: string }): NavigationLocation {
193
176
  * initialSegmentIds: [],
194
177
  * });
195
178
  *
196
- * // Subscribe to state changes (for useNavigation hook)
197
- * const unsubscribe = store.subscribe(() => {
198
- * const state = store.getState();
199
- * console.log('Navigation state:', state);
200
- * });
201
- *
202
- * // Update state
203
- * store.setState({ state: 'loading' });
204
- *
205
179
  * // Subscribe to UI updates (for re-rendering)
206
180
  * store.onUpdate((update) => {
207
181
  * console.log('New root:', update.root);
@@ -217,19 +191,6 @@ export function createNavigationStore(
217
191
  ? createLocation(window.location)
218
192
  : new URL("/", "http://localhost");
219
193
 
220
- // Public navigation state (for useNavigation hook)
221
- // isStreaming starts false to match SSR and avoid hydration mismatch
222
- // After hydration, entry.browser.tsx sets it to true if stream is still open
223
- let navState: NavigationState = {
224
- state: "idle",
225
- isStreaming: false,
226
- location: config?.initialLocation
227
- ? createLocation(config.initialLocation)
228
- : defaultLocation,
229
- pendingUrl: null,
230
- inflightActions: [],
231
- };
232
-
233
194
  // Resolve the initial location for segment state
234
195
  const initialLoc = config?.initialLocation
235
196
  ? createLocation(config.initialLocation)
@@ -251,9 +212,6 @@ export function createNavigationStore(
251
212
  let crossTabRefreshCallback: (() => void) | null =
252
213
  config?.onCrossTabRefresh ?? null;
253
214
 
254
- // Track pending cross-tab refresh to prevent duplicate refreshes
255
- let pendingCrossTabRefresh = false;
256
-
257
215
  // History-based segment cache: array of [url-key, segments] tuples
258
216
  // Each URL gets its own complete snapshot of segments for back/forward and partial merging
259
217
  // Oldest entries (at front) are removed when over cacheSize limit
@@ -283,15 +241,9 @@ export function createNavigationStore(
283
241
  ]);
284
242
  }
285
243
 
286
- // State change listeners (for useNavigation subscriptions)
287
- const stateListeners = new Set<StateListener>();
288
-
289
244
  // UI update subscribers (for re-rendering)
290
245
  const updateSubscribers = new Set<UpdateSubscriber>();
291
246
 
292
- // Internal flag to track if a server action is in progress
293
- let actionInProgress = false;
294
-
295
247
  // Intercept source URL - tracks where the intercept was triggered from
296
248
  // Used to maintain intercept context during action revalidation
297
249
  let interceptSourceUrl: string | null = null;
@@ -301,63 +253,6 @@ export function createNavigationStore(
301
253
  // tree replacement instead of reconciling with stale segments.
302
254
  let currentRouterId: string | undefined;
303
255
 
304
- // Action state tracking (for useAction hook)
305
- // Maps action function ID to its tracked state
306
- const actionStates = new Map<string, TrackedActionState>();
307
-
308
- // Action state listeners (per action ID)
309
- // Maps action function ID to set of listeners
310
- const actionListeners = new Map<string, Set<ActionStateListener>>();
311
-
312
- /**
313
- * Create a debounced function that batches rapid calls
314
- */
315
- // A non-keyed notifier is the keyed one restricted to a single constant key;
316
- // its own keyed instance means the "" key never collides with action keys.
317
- function createDebouncedNotifier<T extends (...args: any[]) => void>(
318
- fn: T,
319
- ms: number = 20,
320
- ): T {
321
- const keyed = createKeyedDebouncedNotifier(
322
- (_key: string, ...args: any[]) => fn(...args),
323
- ms,
324
- );
325
- return ((...args: Parameters<T>) => keyed("", ...args)) as T;
326
- }
327
-
328
- /**
329
- * Create a keyed debounced function (separate timers per key)
330
- */
331
- function createKeyedDebouncedNotifier<
332
- T extends (key: string, ...args: any[]) => void,
333
- >(fn: T, ms: number = 20): T {
334
- const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
335
- return ((key: string, ...args: any[]) => {
336
- const existing = timeouts.get(key);
337
- if (existing !== undefined) clearTimeout(existing);
338
- timeouts.set(
339
- key,
340
- setTimeout(() => {
341
- timeouts.delete(key);
342
- fn(key, ...args);
343
- }, ms),
344
- );
345
- }) as T;
346
- }
347
-
348
- const notifyStateListeners = createDebouncedNotifier(() => {
349
- stateListeners.forEach((listener) => listener());
350
- });
351
-
352
- const notifyActionListeners = createKeyedDebouncedNotifier(
353
- (actionId: string, state: TrackedActionState) => {
354
- const listeners = actionListeners.get(actionId);
355
- if (listeners) {
356
- listeners.forEach((listener) => listener(state));
357
- }
358
- },
359
- );
360
-
361
256
  /**
362
257
  * Clear the history cache (internal - does not broadcast)
363
258
  */
@@ -450,22 +345,7 @@ export function createNavigationStore(
450
345
 
451
346
  // Auto-refresh if enabled and callback is registered
452
347
  if (crossTabAutoRefresh && crossTabRefreshCallback) {
453
- // If idle, refresh immediately. If loading, wait for idle then refresh.
454
- if (navState.state === "idle") {
455
- crossTabRefreshCallback();
456
- } else if (!pendingCrossTabRefresh) {
457
- // Only queue one refresh, ignore subsequent events while loading
458
- pendingCrossTabRefresh = true;
459
- // Subscribe to state changes, refresh when idle
460
- const listener: StateListener = () => {
461
- if (navState.state === "idle") {
462
- stateListeners.delete(listener);
463
- pendingCrossTabRefresh = false;
464
- crossTabRefreshCallback?.();
465
- }
466
- };
467
- stateListeners.add(listener);
468
- }
348
+ crossTabRefreshCallback();
469
349
  }
470
350
  }
471
351
  };
@@ -473,80 +353,6 @@ export function createNavigationStore(
473
353
  }
474
354
 
475
355
  return {
476
- // ========================================================================
477
- // Public State (for useNavigation hook)
478
- // ========================================================================
479
-
480
- /**
481
- * Get current navigation state
482
- */
483
- getState(): NavigationState {
484
- return navState;
485
- },
486
-
487
- /**
488
- * Update navigation state and notify listeners
489
- */
490
- setState(partial: Partial<NavigationState>): void {
491
- navState = { ...navState, ...partial };
492
- notifyStateListeners();
493
- },
494
-
495
- /**
496
- * Subscribe to state changes
497
- * Returns unsubscribe function
498
- */
499
- subscribe(listener: StateListener): () => void {
500
- stateListeners.add(listener);
501
- return () => {
502
- stateListeners.delete(listener);
503
- };
504
- },
505
-
506
- // ========================================================================
507
- // Inflight Action Management
508
- // ========================================================================
509
-
510
- /**
511
- * Add an inflight action to the list
512
- */
513
- addInflightAction(action: InflightAction): void {
514
- navState = {
515
- ...navState,
516
- inflightActions: [...navState.inflightActions, action],
517
- };
518
- notifyStateListeners();
519
- },
520
-
521
- /**
522
- * Remove an inflight action by ID
523
- */
524
- removeInflightAction(id: string): void {
525
- navState = {
526
- ...navState,
527
- inflightActions: navState.inflightActions.filter((a) => a.id !== id),
528
- };
529
- notifyStateListeners();
530
- },
531
-
532
- // ========================================================================
533
- // Action State (for controlling update behavior during server actions)
534
- // ========================================================================
535
-
536
- /**
537
- * Check if a server action is currently in progress
538
- */
539
- isActionInProgress(): boolean {
540
- return actionInProgress;
541
- },
542
-
543
- /**
544
- * Set the action in progress flag
545
- */
546
- setActionInProgress(value: boolean): void {
547
- actionInProgress = value;
548
- },
549
-
550
356
  // ========================================================================
551
357
  // Internal Segment State (for bridges)
552
358
  // ========================================================================
@@ -609,17 +415,6 @@ export function createNavigationStore(
609
415
  return navInstance;
610
416
  },
611
417
 
612
- /**
613
- * The nav-instance token recorded on a specific cache entry, or undefined if
614
- * no entry exists for that key. Because the history key is URL-only, this is
615
- * how a late resolution tells "the entry I seeded is still mine" from "a
616
- * newer same-URL visit replaced my entry".
617
- */
618
- getCacheEntryInstance(historyKey: string): number | undefined {
619
- const entry = historyCache.find(([key]) => key === historyKey);
620
- return entry ? entry[5] : undefined;
621
- },
622
-
623
418
  /**
624
419
  * Store segments for a history entry
625
420
  * Updates existing entry if key exists, otherwise adds new entry
@@ -777,14 +572,6 @@ export function createNavigationStore(
777
572
  ];
778
573
  },
779
574
 
780
- /**
781
- * Mark all cache entries as stale
782
- * Called after server actions to indicate data may be outdated
783
- */
784
- markCacheAsStale(): void {
785
- markCacheAsStaleInternal();
786
- },
787
-
788
575
  /**
789
576
  * Mark every history entry stale WITHOUT clearing the prefetch caches or
790
577
  * rotating the rango state. The jar-divergence observer calls this after an
@@ -871,59 +658,5 @@ export function createNavigationStore(
871
658
  callback(update);
872
659
  });
873
660
  },
874
-
875
- // ========================================================================
876
- // Action State Tracking (for useAction hook)
877
- // ========================================================================
878
-
879
- /**
880
- * Get the current state for a tracked action
881
- * Returns default idle state if action hasn't been tracked
882
- */
883
- getActionState(actionId: string): TrackedActionState {
884
- return actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
885
- },
886
-
887
- /**
888
- * Update the state for a tracked action
889
- * Merges partial state with existing state and notifies listeners
890
- */
891
- setActionState(
892
- actionId: string,
893
- partial: Partial<TrackedActionState>,
894
- ): void {
895
- const current = actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
896
- const updated: TrackedActionState = {
897
- ...current,
898
- ...partial,
899
- actionId, // Always set the actionId
900
- };
901
- actionStates.set(actionId, updated);
902
- notifyActionListeners(actionId, updated);
903
- },
904
-
905
- /**
906
- * Subscribe to state changes for a specific action
907
- * Returns unsubscribe function
908
- */
909
- subscribeToAction(
910
- actionId: string,
911
- listener: ActionStateListener,
912
- ): () => void {
913
- let listeners = actionListeners.get(actionId);
914
- if (!listeners) {
915
- listeners = new Set();
916
- actionListeners.set(actionId, listeners);
917
- }
918
- listeners.add(listener);
919
-
920
- return () => {
921
- listeners!.delete(listener);
922
- // Clean up empty listener sets
923
- if (listeners!.size === 0) {
924
- actionListeners.delete(actionId);
925
- }
926
- };
927
- },
928
661
  };
929
662
  }
@@ -550,20 +550,13 @@ export function createPartialUpdater(
550
550
  });
551
551
  });
552
552
  } else {
553
- // Normal commit (cold/partial nav AND fully-prefetched nav). For a
554
- // fully-prefetched nav, renderOptions.forceAwait (above) unwrapped the
555
- // already-resolved ROUTER loader data AND route content during render, so
556
- // the new tree carries it inline with no loading()/fallback frame — yet we
557
- // still commit NORMALLY here rather than in a transition. A transition
558
- // holds the OLD UI until ALL suspense in the new tree settles, including a
559
- // CLIENT component that starts its own data request only when mounted
560
- // (post-commit) under a persistent boundary; that would retain the
561
- // previous page indefinitely with no feedback. A normal commit lets such
562
- // client-initiated suspense reveal a fallback (correct) while the router
563
- // data — genuinely ready — never flashes. Cold/partial navs
564
- // (fullyPrefetched=false) do not forceAwait, so they stream their
565
- // fallbacks. Explicit transition() routes keep the broader content-hold
566
- // via the hasTransition branch above (the documented opt-in).
553
+ // Normal commit for ALL navs here, never a transition (see the
554
+ // forceAwait comment above for why prefetched router data lands
555
+ // without fallback frames). A transition would hold the OLD UI until
556
+ // every suspense in the new tree settles including a client
557
+ // component that only starts fetching post-mount, retaining the
558
+ // previous page indefinitely. Explicit transition() routes keep the
559
+ // content-hold via the hasTransition branch above (the opt-in).
567
560
  onUpdate({
568
561
  root: newTree,
569
562
  metadata: payload.metadata!,
@@ -39,7 +39,7 @@
39
39
  * effectively per-document. Unit tests reset them via clearPrefetchCache().
40
40
  */
41
41
 
42
- import { abortAllPrefetches } from "./queue.js";
42
+ import { abortAllPrefetches } from "./loader.js";
43
43
  import { invalidateRangoState } from "../rango-state.js";
44
44
  import type { RscPayload } from "../types.js";
45
45
 
@@ -0,0 +1,110 @@
1
+ import type { RscPayload } from "../types.js";
2
+
3
+ type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
4
+
5
+ type PrefetchRuntime = typeof import("./runtime.js");
6
+
7
+ let runtime: PrefetchRuntime | null = null;
8
+ let runtimePromise: Promise<PrefetchRuntime> | null = null;
9
+ let decoder: PrefetchDecoder | null = null;
10
+ let concurrency: number | undefined;
11
+ let requestGeneration = 0;
12
+
13
+ function loadRuntime(): Promise<PrefetchRuntime> {
14
+ if (runtime) return Promise.resolve(runtime);
15
+ if (!runtimePromise) {
16
+ runtimePromise = import("./runtime.js").then(
17
+ (loaded) => {
18
+ runtime = loaded;
19
+ if (decoder) loaded.setPrefetchDecoder(decoder);
20
+ if (concurrency !== undefined) {
21
+ loaded.setPrefetchConcurrency(concurrency);
22
+ }
23
+ return loaded;
24
+ },
25
+ (error: unknown) => {
26
+ runtimePromise = null;
27
+ throw error;
28
+ },
29
+ );
30
+ }
31
+ return runtimePromise;
32
+ }
33
+
34
+ function withRuntime(run: (loaded: PrefetchRuntime) => void): void {
35
+ if (runtime) {
36
+ run(runtime);
37
+ return;
38
+ }
39
+ const generation = requestGeneration;
40
+ void loadRuntime()
41
+ .then((loaded) => {
42
+ if (generation === requestGeneration) run(loaded);
43
+ })
44
+ .catch(() => {});
45
+ }
46
+
47
+ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
48
+ decoder = fn;
49
+ runtime?.setPrefetchDecoder(fn);
50
+ }
51
+
52
+ export function setPrefetchConcurrency(value: number): void {
53
+ concurrency = value;
54
+ runtime?.setPrefetchConcurrency(value);
55
+ }
56
+
57
+ export function prefetchDirect(
58
+ url: string,
59
+ segmentIds: string[],
60
+ version?: string,
61
+ routerId?: string,
62
+ prefetchKey?: ":source",
63
+ ): void {
64
+ withRuntime((loaded) =>
65
+ loaded.prefetchDirect(url, segmentIds, version, routerId, prefetchKey),
66
+ );
67
+ }
68
+
69
+ export function prefetchQueued(
70
+ url: string,
71
+ segmentIds: string[],
72
+ version?: string,
73
+ routerId?: string,
74
+ prefetchKey?: ":source",
75
+ ): void {
76
+ withRuntime((loaded) =>
77
+ loaded.prefetchQueued(url, segmentIds, version, routerId, prefetchKey),
78
+ );
79
+ }
80
+
81
+ export function observeForPrefetch(
82
+ element: Element,
83
+ callback: () => void,
84
+ ): () => void {
85
+ if (typeof IntersectionObserver === "undefined") return () => {};
86
+ let active = true;
87
+ if (runtime) {
88
+ runtime.observeForPrefetch(element, callback);
89
+ } else {
90
+ void loadRuntime()
91
+ .then((loaded) => {
92
+ if (active) loaded.observeForPrefetch(element, callback);
93
+ })
94
+ .catch(() => {});
95
+ }
96
+ return () => {
97
+ active = false;
98
+ runtime?.unobserveForPrefetch(element);
99
+ };
100
+ }
101
+
102
+ export function cancelAllPrefetches(keepUrl?: string | null): void {
103
+ requestGeneration++;
104
+ runtime?.cancelAllPrefetches(keepUrl);
105
+ }
106
+
107
+ export function abortAllPrefetches(): void {
108
+ requestGeneration++;
109
+ runtime?.abortAllPrefetches();
110
+ }
@@ -0,0 +1,7 @@
1
+ export { prefetchDirect, prefetchQueued, setPrefetchDecoder } from "./fetch.js";
2
+ export {
3
+ abortAllPrefetches,
4
+ cancelAllPrefetches,
5
+ setPrefetchConcurrency,
6
+ } from "./queue.js";
7
+ export { observeForPrefetch, unobserveForPrefetch } from "./observer.js";