@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -20,7 +20,11 @@ import {
20
20
  } from "./intercept-utils.js";
21
21
  import type { BoundTransaction } from "./navigation-transaction.js";
22
22
  import { ServerRedirect } from "../errors.js";
23
- import { debugLog } from "./logging.js";
23
+ import {
24
+ debugLog,
25
+ isBrowserDebugEnabled,
26
+ IS_BROWSER_DEBUG,
27
+ } from "./logging.js";
24
28
  import {
25
29
  validateRedirectOrigin,
26
30
  validateExternalRedirect,
@@ -147,9 +151,11 @@ export function createPartialUpdater(
147
151
  currentCached.filter(isInterceptSegment).map((s) => s.id),
148
152
  );
149
153
  segments = currentSegments.filter((id) => !interceptIds.has(id));
150
- debugLog(
151
- `[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
152
- );
154
+ if (IS_BROWSER_DEBUG) {
155
+ debugLog(
156
+ `[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
157
+ );
158
+ }
153
159
  } else {
154
160
  segments = segmentIds ?? segmentState.currentSegmentIds;
155
161
  }
@@ -159,12 +165,14 @@ export function createPartialUpdater(
159
165
  ? segmentState.currentUrl || tx.currentUrl
160
166
  : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
161
167
 
162
- debugLog(`\n[Browser] >>> NAVIGATION`);
163
- debugLog(`[Browser] From: ${previousUrl}`);
164
- debugLog(`[Browser] To: ${url}`);
165
- debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
166
- if (interceptSourceUrl) {
167
- debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
168
+ if (IS_BROWSER_DEBUG) {
169
+ debugLog(`\n[Browser] >>> NAVIGATION`);
170
+ debugLog(`[Browser] From: ${previousUrl}`);
171
+ debugLog(`[Browser] To: ${url}`);
172
+ debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
173
+ if (interceptSourceUrl) {
174
+ debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
175
+ }
168
176
  }
169
177
 
170
178
  const targetCache =
@@ -173,9 +181,11 @@ export function createPartialUpdater(
173
181
  : undefined;
174
182
  const cachedSegs = targetCache ?? getCurrentCachedSegments();
175
183
  const cachedSegsSource = targetCache ? "history-cache" : "current-page";
176
- debugLog(
177
- `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
178
- );
184
+ if (IS_BROWSER_DEBUG) {
185
+ debugLog(
186
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
187
+ );
188
+ }
179
189
 
180
190
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
181
191
  fetchResult = await client.fetchPartial({
@@ -188,7 +198,11 @@ export function createPartialUpdater(
188
198
  routerId: store.getRouterId?.(),
189
199
  });
190
200
  const streamingToken = tx.startStreaming();
191
- const { payload, streamComplete: rawStreamComplete } = fetchResult;
201
+ const {
202
+ payload,
203
+ streamComplete: rawStreamComplete,
204
+ fullyPrefetched,
205
+ } = fetchResult;
192
206
  debugLog("payload.metadata", payload.metadata);
193
207
 
194
208
  // Side effect only: end the streaming token once the stream settles.
@@ -254,8 +268,10 @@ export function createPartialUpdater(
254
268
  return;
255
269
  }
256
270
 
257
- debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
258
- debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
271
+ if (IS_BROWSER_DEBUG) {
272
+ debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
273
+ debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
274
+ }
259
275
 
260
276
  if (!diff || diff.length === 0) {
261
277
  const matchedIds = matched || [];
@@ -379,6 +395,13 @@ export function createPartialUpdater(
379
395
  return;
380
396
  }
381
397
  if (mode.type === "action") {
398
+ // An action refetch that lands on missing segments (navigated away /
399
+ // consolidation / HMR) drops rather than refetch-all: the action flow
400
+ // is storeOnly / skipLoadingState, so a full refetch here would fight
401
+ // it. Keep the stale-but-consistent tree; log so the drop is visible.
402
+ debugLog(
403
+ `[Browser] Action refetch: ${missingCount} segments missing; dropping (stale-but-consistent tree kept).`,
404
+ );
382
405
  return;
383
406
  }
384
407
  console.warn(
@@ -395,7 +418,14 @@ export function createPartialUpdater(
395
418
 
396
419
  const renderOptions = {
397
420
  isAction: mode.type === "action",
398
- forceAwait: mode.type === "stale-revalidation",
421
+ // forceAwait unwraps the ROUTER loader promises during render so they
422
+ // land without a loading()/fallback frame. A fully-prefetched nav has
423
+ // its router data already resolved (the prefetch stream drained), so
424
+ // awaiting it here is free and lets us commit NORMALLY (not in a
425
+ // transition) below — a normal commit still shows fallbacks for any
426
+ // CLIENT component that suspends on mount, which a transition would
427
+ // wrongly suppress by holding the old UI until that suspense settles.
428
+ forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
399
429
  interceptSegments:
400
430
  reconciled.interceptSegments.length > 0
401
431
  ? reconciled.interceptSegments
@@ -480,6 +510,21 @@ export function createPartialUpdater(
480
510
  debugLog("[partial-update] updating document");
481
511
 
482
512
  const hasTransition = shouldStartViewTransition(reconciled.segments);
513
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
514
+ // segment still carries a transition after the server-side when-gate, and
515
+ // whether the commit will be held in a startTransition. If `withTransition`
516
+ // lists an ancestor (layout/root) id rather than the gated leaf, an ungated
517
+ // ancestor transition is holding the subtree (missing loading() fallback).
518
+ if (isBrowserDebugEnabled()) {
519
+ debugLog("[VT-DIAG] commit", {
520
+ mode: mode.type,
521
+ hasTransition,
522
+ withTransition: reconciled.segments
523
+ .filter((s) => s.transition)
524
+ .map((s) => s.id),
525
+ all: reconciled.segments.map((s) => s.id),
526
+ });
527
+ }
483
528
  const scrollPayload = toScrollPayload(navScroll);
484
529
 
485
530
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -505,6 +550,20 @@ export function createPartialUpdater(
505
550
  });
506
551
  });
507
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).
508
567
  onUpdate({
509
568
  root: newTree,
510
569
  metadata: payload.metadata!,
@@ -65,17 +65,36 @@ export interface DecodedPrefetch {
65
65
  * when it adopted an inflight entry through the wildcard key.
66
66
  */
67
67
  scope: "source" | "wildcard";
68
+ /**
69
+ * Synchronously-readable flag, flipped to true when `streamComplete` resolves
70
+ * (the entire RSC stream has drained). Navigation reads this at click time to
71
+ * tell a FULLY warmed prefetch (payload commits without suspending → safe to
72
+ * commit in a startTransition, no fallback flash) from a partially-warmed one
73
+ * (still streaming → stream its fallbacks like a cold load). Starts false.
74
+ */
75
+ complete: boolean;
68
76
  }
69
77
 
70
78
  let cacheTTL = 300_000;
71
79
 
80
+ // Max stored entries before FIFO eviction. Mirrors DEFAULT_PREFETCH_CACHE_SIZE
81
+ // (router/prefetch-limits.ts); kept as a local literal so the client bundle
82
+ // doesn't pull in router-layer code, matching the cacheTTL default above.
83
+ // Overridden at startup by initPrefetchCache from server metadata.
84
+ let maxPrefetchCacheSize = 100;
85
+
72
86
  /**
73
- * Initialize the prefetch cache with the configured TTL.
74
- * Called once at app startup with the value from server metadata.
75
- * A TTL of 0 disables the in-memory cache and all prefetching.
87
+ * Initialize the prefetch cache with the configured TTL and max size.
88
+ * Called once at app startup with the values from server metadata. Each
89
+ * argument is applied only when provided, so a caller can set just the TTL.
90
+ * A TTL of 0 disables the in-memory cache and all prefetching. A size below 1
91
+ * is ignored (the default is kept) — disabling prefetch is the TTL's job.
76
92
  */
77
- export function initPrefetchCache(ttlMs: number): void {
78
- cacheTTL = ttlMs;
93
+ export function initPrefetchCache(ttlMs?: number, maxSize?: number): void {
94
+ if (ttlMs !== undefined) cacheTTL = ttlMs;
95
+ if (maxSize !== undefined && Number.isFinite(maxSize) && maxSize >= 1) {
96
+ maxPrefetchCacheSize = Math.floor(maxSize);
97
+ }
79
98
  }
80
99
 
81
100
  /**
@@ -85,7 +104,6 @@ export function initPrefetchCache(ttlMs: number): void {
85
104
  export function isPrefetchCacheDisabled(): boolean {
86
105
  return cacheTTL <= 0;
87
106
  }
88
- const MAX_PREFETCH_CACHE_SIZE = 50;
89
107
 
90
108
  interface PrefetchCacheEntry {
91
109
  entry: DecodedPrefetch;
@@ -95,6 +113,16 @@ interface PrefetchCacheEntry {
95
113
  const cache = new Map<string, PrefetchCacheEntry>();
96
114
  const inflight = new Set<string>();
97
115
 
116
+ // Watermark: the smallest entry timestamp currently in `cache`, i.e. a lower
117
+ // bound on when the oldest entry expires (timestamp + cacheTTL). storePrefetch's
118
+ // eager expiry sweep is skipped while `now - earliestTimestamp <= cacheTTL` — the
119
+ // oldest entry cannot be expired yet, so nothing would be evicted. Deletions
120
+ // (consume/has/remove) may leave it stale-LOW, which only forces a harmless extra
121
+ // sweep that recomputes it exactly; it is never stale-high, so a skipped sweep
122
+ // can never retain an expired entry. consume/has still TTL-check lazily, so a
123
+ // skipped sweep never serves a stale entry. Infinity when the cache is empty.
124
+ let earliestTimestamp = Infinity;
125
+
98
126
  const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
99
127
 
100
128
  const inflightAliases = new Map<string, string[]>();
@@ -251,21 +279,32 @@ export function storePrefetch(
251
279
  return;
252
280
  }
253
281
 
254
- // Evict expired entries
282
+ // Evict expired entries — but only when the watermark says the oldest entry
283
+ // could have expired, so a burst of stores no longer walks the whole map each
284
+ // time. The sweep recomputes the exact watermark from the survivors.
255
285
  const now = Date.now();
256
- for (const [k, cached] of cache) {
257
- if (now - cached.timestamp > cacheTTL) {
258
- cache.delete(k);
286
+ if (now - earliestTimestamp > cacheTTL) {
287
+ let min = Infinity;
288
+ for (const [k, cached] of cache) {
289
+ if (now - cached.timestamp > cacheTTL) {
290
+ cache.delete(k);
291
+ } else if (cached.timestamp < min) {
292
+ min = cached.timestamp;
293
+ }
259
294
  }
295
+ earliestTimestamp = min;
260
296
  }
261
297
 
262
298
  // FIFO eviction if at capacity
263
- if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
299
+ if (cache.size >= maxPrefetchCacheSize) {
264
300
  const oldest = cache.keys().next().value;
265
301
  if (oldest) cache.delete(oldest);
266
302
  }
267
303
 
268
304
  cache.set(key, { entry, timestamp: now });
305
+ // The new entry is the newest, so it only moves the watermark when the cache
306
+ // was empty (Infinity); otherwise the older watermark stands.
307
+ if (now < earliestTimestamp) earliestTimestamp = now;
269
308
  }
270
309
 
271
310
  /**
@@ -336,6 +375,7 @@ export function clearPrefetchCache(): void {
336
375
  inflightAliases.clear();
337
376
  adoptedKeys.clear();
338
377
  cache.clear();
378
+ earliestTimestamp = Infinity;
339
379
  abortAllPrefetches();
340
380
  invalidateRangoState();
341
381
  }
@@ -31,7 +31,7 @@ import { getRangoState } from "../rango-state.js";
31
31
  import { isActionFenceActive } from "../action-fence.js";
32
32
  import { enqueuePrefetch } from "./queue.js";
33
33
  import { shouldPrefetch } from "./policy.js";
34
- import { debugLog } from "../logging.js";
34
+ import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
35
35
  import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
36
36
  import type { RscPayload } from "../types.js";
37
37
 
@@ -154,6 +154,8 @@ function executePrefetchFetch(
154
154
  sourceKey: string,
155
155
  fetchUrl: string,
156
156
  forceSourceScope: boolean,
157
+ /** Rango state captured once by the caller (keys + header share one read). */
158
+ rangoState: string,
157
159
  expectedRouterId?: string,
158
160
  signal?: AbortSignal,
159
161
  ): Promise<DecodedPrefetch | null> {
@@ -214,7 +216,7 @@ function executePrefetchFetch(
214
216
  ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
215
217
  signal: effectiveSignal,
216
218
  headers: {
217
- "X-Rango-State": getRangoState(),
219
+ "X-Rango-State": rangoState,
218
220
  "X-RSC-Router-Client-Path": window.location.href,
219
221
  "X-Rango-Prefetch": "1",
220
222
  },
@@ -244,14 +246,21 @@ function executePrefetchFetch(
244
246
  const storageKey = scope === "source" ? sourceKey : wildcardKey;
245
247
 
246
248
  // Track stream completion off a tee so navigation's scroll/revalidation
247
- // gating matches the fresh-fetch path; decode the other branch.
249
+ // gating matches the fresh-fetch path; decode the other branch. The
250
+ // completion callback reports whether the stream ended on a clean EOF
251
+ // (true) or was aborted/errored (false) — only a clean end can mark the
252
+ // entry complete (see below).
248
253
  let resolveStreamComplete!: () => void;
254
+ let endedCleanly = false;
249
255
  const streamComplete = new Promise<void>((resolve) => {
250
256
  resolveStreamComplete = resolve;
251
257
  });
252
258
  const tracked = teeWithCompletion(
253
259
  response,
254
- () => resolveStreamComplete(),
260
+ (clean) => {
261
+ endedCleanly = clean;
262
+ resolveStreamComplete();
263
+ },
255
264
  effectiveSignal,
256
265
  // Speculative prefetch: a never-consumed/aborted stream error is benign.
257
266
  true,
@@ -264,7 +273,12 @@ function executePrefetchFetch(
264
273
  // error is still surfaced to navigation if it consumes the entry.
265
274
  payload.catch(() => {});
266
275
 
267
- const entry: DecodedPrefetch = { payload, streamComplete, scope };
276
+ const entry: DecodedPrefetch = {
277
+ payload,
278
+ streamComplete,
279
+ scope,
280
+ complete: false,
281
+ };
268
282
  storePrefetch(storageKey, entry, gen);
269
283
  // The stall timeout now owns the body stream: arm eviction (publishedKey)
270
284
  // and clear the timer once the stream completes. The tee's finally resolves
@@ -272,7 +286,23 @@ function executePrefetchFetch(
272
286
  // no lingering timer while a stalled one is evicted when the timer fires.
273
287
  publishedKey = storageKey;
274
288
  publishedEntry = entry;
275
- streamComplete.then(() => clearTimeout(timeoutId));
289
+ // Evict a broken prefetch IMMEDIATELY on the earliest failure signal — do not
290
+ // wait for both branches to settle. A decode that rejects while the tracking
291
+ // stream is still draining (or hung) would otherwise leave the rejected payload
292
+ // consumable (navigation reads entry.payload regardless of `complete`) until EOF
293
+ // or the stall timeout. removePrefetch is identity-guarded, so a fresh entry
294
+ // republished under the same key is never dropped, and a double call is a no-op.
295
+ payload.catch(() => removePrefetch(storageKey, entry));
296
+ streamComplete.then(() => {
297
+ if (!endedCleanly) removePrefetch(storageKey, entry);
298
+ });
299
+ // Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
300
+ Promise.allSettled([payload, streamComplete]).then(([decode]) => {
301
+ if (decode.status === "fulfilled" && endedCleanly) {
302
+ entry.complete = true;
303
+ }
304
+ clearTimeout(timeoutId);
305
+ });
276
306
  return entry;
277
307
  })
278
308
  .catch(() => null)
@@ -343,26 +373,31 @@ export function prefetchDirect(
343
373
  const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
344
374
  const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
345
375
  if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
346
- debugLog("[prefetch] direct dedup (key already exists)", {
376
+ if (IS_BROWSER_DEBUG) {
377
+ debugLog("[prefetch] direct dedup (key already exists)", {
378
+ url,
379
+ wildcardKey,
380
+ sourceKey,
381
+ forceSourceScope,
382
+ });
383
+ }
384
+ return;
385
+ }
386
+ if (IS_BROWSER_DEBUG) {
387
+ debugLog("[prefetch] direct fetch", {
347
388
  url,
348
389
  wildcardKey,
349
390
  sourceKey,
391
+ source: sourceHref,
350
392
  forceSourceScope,
351
393
  });
352
- return;
353
394
  }
354
- debugLog("[prefetch] direct fetch", {
355
- url,
356
- wildcardKey,
357
- sourceKey,
358
- source: sourceHref,
359
- forceSourceScope,
360
- });
361
395
  executePrefetchFetch(
362
396
  wildcardKey,
363
397
  sourceKey,
364
398
  targetUrl.toString(),
365
399
  forceSourceScope,
400
+ rangoState,
366
401
  routerId,
367
402
  );
368
403
  }
@@ -393,12 +428,14 @@ export function prefetchQueued(
393
428
  const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
394
429
  const queueKey = forceSourceScope ? sourceKey : wildcardKey;
395
430
  if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
396
- debugLog("[prefetch] queued dedup (key already exists)", {
397
- url,
398
- wildcardKey,
399
- sourceKey,
400
- forceSourceScope,
401
- });
431
+ if (IS_BROWSER_DEBUG) {
432
+ debugLog("[prefetch] queued dedup (key already exists)", {
433
+ url,
434
+ wildcardKey,
435
+ sourceKey,
436
+ forceSourceScope,
437
+ });
438
+ }
402
439
  return queueKey;
403
440
  }
404
441
  const fetchUrlStr = targetUrl.toString();
@@ -416,6 +453,7 @@ export function prefetchQueued(
416
453
  sourceKey,
417
454
  fetchUrlStr,
418
455
  forceSourceScope,
456
+ rangoState,
419
457
  routerId,
420
458
  signal,
421
459
  ).then(() => {});
@@ -16,9 +16,24 @@
16
16
 
17
17
  import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
18
 
19
- const MAX_CONCURRENT = 2;
19
+ // Max prefetches executing at once. Mirrors DEFAULT_PREFETCH_CONCURRENCY
20
+ // (router/prefetch-limits.ts); kept as a local literal so the client bundle
21
+ // doesn't pull in router-layer code. Overridden at startup by
22
+ // setPrefetchConcurrency from server metadata.
23
+ let maxConcurrent = 2;
20
24
  const IMAGE_WAIT_TIMEOUT = 2000;
21
25
 
26
+ /**
27
+ * Set the max number of concurrently-executing speculative prefetches.
28
+ * Called once at app startup with the value from server metadata. A value
29
+ * below 1 (or non-finite) is ignored, keeping the default.
30
+ */
31
+ export function setPrefetchConcurrency(n: number): void {
32
+ if (Number.isFinite(n) && n >= 1) {
33
+ maxConcurrent = Math.floor(n);
34
+ }
35
+ }
36
+
22
37
  let active = 0;
23
38
  const queue: Array<{
24
39
  key: string;
@@ -42,7 +57,7 @@ function startExecution(
42
57
  abortControllers.delete(key);
43
58
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
44
59
  // Without this guard, cancelled tasks' .finally() would underflow active
45
- // below zero, breaking the MAX_CONCURRENT guarantee.
60
+ // below zero, breaking the maxConcurrent guarantee.
46
61
  if (executing.delete(key)) {
47
62
  active--;
48
63
  }
@@ -63,7 +78,7 @@ function startExecution(
63
78
  */
64
79
  function scheduleDrain(): void {
65
80
  if (drainScheduled) return;
66
- if (active >= MAX_CONCURRENT || queue.length === 0) return;
81
+ if (active >= maxConcurrent || queue.length === 0) return;
67
82
  drainScheduled = true;
68
83
  const gen = drainGeneration;
69
84
  waitForIdle()
@@ -83,7 +98,7 @@ function scheduleDrain(): void {
83
98
  }
84
99
 
85
100
  function drain(): void {
86
- while (active < MAX_CONCURRENT && queue.length > 0) {
101
+ while (active < maxConcurrent && queue.length > 0) {
87
102
  const item = queue.shift()!;
88
103
  queued.delete(item.key);
89
104
  startExecution(item.key, item.execute);
@@ -39,9 +39,16 @@ import {
39
39
  unobserveForPrefetch,
40
40
  } from "../prefetch/observer.js";
41
41
 
42
+ // The (hover: none) MediaQueryList, created lazily on first client read and
43
+ // reused across every Link render. matchMedia allocates and registers a live
44
+ // query object; a fresh one per render (Link renders can be very frequent) is
45
+ // wasteful when the same object's `.matches` is already live. Left null on the
46
+ // server (no window).
47
+ let hoverNoneQuery: MediaQueryList | null = null;
48
+
42
49
  /**
43
- * Read current touch/no-hover capability. Evaluated at the point of use (per
44
- * render) rather than once at module load, so `prefetch="adaptive"` reacts to
50
+ * Read current touch/no-hover capability from the cached MediaQueryList. The
51
+ * `.matches` read is live, so `prefetch="adaptive"` still reacts to
45
52
  * input-capability changes on hybrid devices (touch laptops, tablets gaining or
46
53
  * losing a pointer) and after SSR -> hydrate. The SSR guard returns a stable
47
54
  * `false` (pointer/hover default) so the resolved strategy doesn't drift on the
@@ -49,7 +56,10 @@ import {
49
56
  */
50
57
  function isTouchDevice(): boolean {
51
58
  if (typeof window === "undefined") return false;
52
- return window.matchMedia("(hover: none)").matches;
59
+ if (!hoverNoneQuery) {
60
+ hoverNoneQuery = window.matchMedia("(hover: none)");
61
+ }
62
+ return hoverNoneQuery.matches;
53
63
  }
54
64
 
55
65
  /**
@@ -31,6 +31,11 @@ import { handleNavigationEnd } from "../scroll-restoration.js";
31
31
  import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
32
  import { startConnectionWarmup } from "../connection-warmup.js";
33
33
  import { debugLog } from "../logging.js";
34
+ import { cloneHandleData } from "../navigation-store.js";
35
+ import {
36
+ deferredHandleNames,
37
+ resolveDeferredHandleValues,
38
+ } from "../../handles/deferred-resolution.js";
34
39
 
35
40
  /**
36
41
  * Process handles from an async generator, updating the event controller
@@ -66,6 +71,19 @@ async function processHandles(
66
71
  historyKey,
67
72
  } = opts;
68
73
 
74
+ // This nav's instance token, captured before any await — processHandles runs
75
+ // right after its own commit, so this is that commit's token. generateHistoryKey
76
+ // is URL-only, so an A->B->A revisit reuses the key; the token lets a late
77
+ // resolution tell its own visit apart from a newer same-URL visit, so a stale
78
+ // nav can never clobber a fresher one's live state or cache (P1).
79
+ const myInstance = store.getNavInstance();
80
+
81
+ // True while this nav still owns the live page: same history key AND the most
82
+ // recent commit is still ours (no newer nav has committed since).
83
+ const stillLive = (): boolean =>
84
+ historyKey === store.getHistoryKey() &&
85
+ myInstance === store.getNavInstance();
86
+
69
87
  let yieldCount = 0;
70
88
  for await (const handleData of handlesGenerator) {
71
89
  // Check if user navigated away before each update.
@@ -79,7 +97,91 @@ async function processHandles(
79
97
  }
80
98
 
81
99
  yieldCount++;
82
- eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
100
+
101
+ // Resolve-by-default: hold the previous resolved value until this yield's
102
+ // deferred (Promise) handle values settle, then apply the fully-resolved
103
+ // snapshot. The hold needs NO extra state — we simply do not touch the store
104
+ // until the values resolve, so useHandle keeps reading (and showing) the
105
+ // previous data. A yield with no deferred value applies synchronously.
106
+ const hasDeferred = deferredHandleNames(handleData).size > 0;
107
+
108
+ if (!hasDeferred) {
109
+ eventController.setHandleData(
110
+ handleData,
111
+ matched,
112
+ isPartial,
113
+ resolvedIds,
114
+ );
115
+ // Keep the cache fresh. The token guard stops a stale same-URL nav writing
116
+ // a newer entry; the owned-write folds probe + write into one scan.
117
+ store.updateCacheHandleDataIfOwned(
118
+ historyKey,
119
+ eventController.getHandleState().data,
120
+ myInstance,
121
+ false,
122
+ );
123
+ continue;
124
+ }
125
+
126
+ // The PREVIOUS (held) snapshot — captured before the await so the cache and
127
+ // the navigate-away merge below reflect what useHandle is still showing.
128
+ const previousSnapshot = cloneHandleData(
129
+ eventController.getHandleState().data,
130
+ );
131
+
132
+ // The route HAS changed even though the handle data is held, so update
133
+ // `routeSegmentIds` (what useSegments reads) now. This leaves `data` /
134
+ // `segmentOrder` (what useHandle collects over) untouched, so useHandle keeps
135
+ // holding its previous value while useSegments reflects the new route.
136
+ eventController.setRouteSegmentIds(matched ?? []);
137
+
138
+ // Deferred-pending: the new values are not applied yet (the previous value is
139
+ // held), so the cache entry must NOT be served as fresh on a popstate return.
140
+ // Mark it STALE + handlesPending (token-guarded), storing the PREVIOUS (held)
141
+ // snapshot. P1 fix: a deferred value is a SERVER-side promise streamed via
142
+ // Flight, so a navigate-away ABORTS the stream and the resolve below never
143
+ // settles. stale makes a popstate return revalidate; handlesPending makes that
144
+ // revalidation a FULL re-render (no client segment IDs) so the server
145
+ // re-streams the handles — a diff-only revalidation would omit the unchanged
146
+ // segments' handles and the deferred value would never land (see the
147
+ // segmentIds branch in navigation-bridge.ts).
148
+ store.updateCacheHandleDataIfOwned(
149
+ historyKey,
150
+ previousSnapshot,
151
+ myInstance,
152
+ true,
153
+ true,
154
+ );
155
+
156
+ // Resolve every deferred value (allSettled; rejected + nullish dropped, sync
157
+ // values pass through). Each stream yield is a full cumulative snapshot.
158
+ const resolved = await resolveDeferredHandleValues(handleData);
159
+
160
+ if (!stillLive()) {
161
+ // Navigated away (or a same-URL nav superseded us) while resolving. We do
162
+ // NOT write `resolved` into the entry. It is THIS yield's snapshot only (on a
163
+ // partial nav, just the re-resolved segments' buckets), and a correct write
164
+ // needs setHandleData's nested per-segment merge + matched/resolvedIds
165
+ // cleanup: HandleData is handleName -> segmentId -> entries[], so a
166
+ // handle-name-level spread would drop a shared layout bucket (e.g. a
167
+ // Breadcrumbs layout crumb under L0 when the route pushed under R0) and would
168
+ // mark stale previous-route buckets fresh. We cannot run that merge here
169
+ // without touching the now-different live page. Instead leave the entry as it
170
+ // was marked before the await — stale + handlesPending — so a popstate return
171
+ // revalidates with a full re-render and re-streams the handles. A newer nav
172
+ // owning the entry has already overwritten it; nothing to do either way.
173
+ continue;
174
+ }
175
+
176
+ // Still live: apply the fully-resolved snapshot and refresh the cache fresh.
177
+ eventController.setHandleData(resolved, matched, isPartial, resolvedIds);
178
+ store.updateCacheHandleDataIfOwned(
179
+ historyKey,
180
+ eventController.getHandleState().data,
181
+ myInstance,
182
+ false,
183
+ false,
184
+ );
83
185
  }
84
186
 
85
187
  // Check again before final updates
@@ -97,8 +199,9 @@ async function processHandles(
97
199
  // After handles processing completes, update the cache's handleData.
98
200
  // This fixes a race condition where commit() caches stale handleData before
99
201
  // the async handles processing completes.
100
- // Only update if we're still on the same page (historyKey matches).
101
- if (historyKey === store.getHistoryKey()) {
202
+ // Only update if we're still on the same page AND this is still the live nav
203
+ // (the token guard stops a stale same-URL nav writing a newer nav's state).
204
+ if (stillLive()) {
102
205
  const finalHandleData = eventController.getHandleState().data;
103
206
  store.updateCacheHandleData(historyKey, finalHandleData);
104
207
  }
@@ -362,7 +465,8 @@ export function NavigationProvider({
362
465
  payload.root instanceof Promise ? use(payload.root) : payload.root;
363
466
 
364
467
  // Wrap content in RootErrorBoundary to catch:
365
- // 1. Errors from NetworkErrorThrower (rendered during network failures)
468
+ // 1. Errors from RenderErrorThrower (network failures and unprocessable
469
+ // navigation responses, routed here by the navigation bridge)
366
470
  // 2. Client component errors that occur before/outside the segment tree's error boundary
367
471
  // 3. Errors during promise resolution or navigation state updates
368
472
  // This acts as a safety net - the segment tree has its own RootErrorBoundary that