@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
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
- import { debugLog } from "./logging.js";
2
+ import { debugLog, IS_BROWSER_DEBUG } from "./logging.js";
3
3
 
4
4
  /**
5
5
  * Merge partial loader data from server with cached loader data.
@@ -19,9 +19,11 @@ export function mergeSegmentLoaders(
19
19
  const serverLoaderIds = fromServer.loaderIds || [];
20
20
  const cachedLoaderIds = fromCache.loaderIds || [];
21
21
 
22
- debugLog(
23
- `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`,
24
- );
22
+ if (IS_BROWSER_DEBUG) {
23
+ debugLog(
24
+ `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`,
25
+ );
26
+ }
25
27
 
26
28
  return {
27
29
  ...fromCache,
@@ -32,6 +32,7 @@ import { isInterceptOnlyCache } from "./intercept-utils.js";
32
32
  import {
33
33
  toNetworkError,
34
34
  emitNetworkError,
35
+ emitNavigationError,
35
36
  isBackgroundSuppressible,
36
37
  } from "./network-error-handler.js";
37
38
  import { debugLog } from "./logging.js";
@@ -215,17 +216,20 @@ export function createNavigationBridge(
215
216
  store.setInterceptSourceUrl(null);
216
217
  }
217
218
 
218
- // Before navigating away, update the source page's cache with the latest handleData.
219
- // This ensures the cache has correct handleData even if handles were streaming.
219
+ // Before navigating away, update the source page's cache with the latest
220
+ // handleData. This ensures the cache has correct handleData even if handles
221
+ // were streaming. Use updateCacheHandleData (not cacheSegmentsForHistory):
222
+ // the source page's segments are unchanged, so this is a handleData refresh,
223
+ // not a commit. Critically it PRESERVES the entry's stale flag — when the
224
+ // source page has a deferred Meta still pending, its entry was marked stale
225
+ // (invalidate-on-pending) so a popstate return revalidates; re-committing it
226
+ // here would reset stale to false and serve the carried (pre-resolution)
227
+ // title as fresh. It also leaves the nav-instance token intact.
220
228
  const sourceHistoryKey = store.getHistoryKey();
221
229
  const sourceCached = store.getCachedSegments(sourceHistoryKey);
222
230
  if (sourceCached?.segments && sourceCached.segments.length > 0) {
223
231
  const currentHandleData = eventController.getHandleState().data;
224
- store.cacheSegmentsForHistory(
225
- sourceHistoryKey,
226
- sourceCached.segments,
227
- currentHandleData,
228
- );
232
+ store.updateCacheHandleData(sourceHistoryKey, currentHandleData);
229
233
  }
230
234
 
231
235
  // Check if we have cached segments for target URL
@@ -325,8 +329,15 @@ export function createNavigationBridge(
325
329
  } as NavigateOptionsInternal);
326
330
  }
327
331
 
328
- if (error instanceof DOMException && error.name === "AbortError") {
329
- debugLog("[Browser] Navigation aborted by newer navigation");
332
+ // Aborted, or superseded by a newer navigation. A superseded nav may
333
+ // reject with a non-AbortError (e.g. a Flight decode that fails after its
334
+ // signal was aborted), so check the signal too -- otherwise we would
335
+ // render a boundary that clobbers the newer navigation's content.
336
+ if (
337
+ (error instanceof DOMException && error.name === "AbortError") ||
338
+ tx.handle.signal.aborted
339
+ ) {
340
+ debugLog("[Browser] Navigation aborted or superseded");
330
341
  return;
331
342
  }
332
343
 
@@ -343,7 +354,13 @@ export function createNavigationBridge(
343
354
  return;
344
355
  }
345
356
 
346
- throw error;
357
+ // A response we could not process (undecodable Flight body, or an
358
+ // unanticipated failure building the response). Surface the route's
359
+ // error boundary rather than let the rejection abort the navigation
360
+ // silently. Prefetched responses funnel here too: a failed warm-prefetch
361
+ // payload rejects on consumption and propagates to this catch.
362
+ console.error("[Browser] Unprocessable navigation response:", error);
363
+ emitNavigationError(onUpdate, error, url);
347
364
  } finally {
348
365
  tx[Symbol.dispose]();
349
366
  }
@@ -372,6 +389,14 @@ export function createNavigationBridge(
372
389
  tx.with({ url: window.location.href, replace: true, scroll: false }),
373
390
  );
374
391
  } catch (error) {
392
+ // Aborted or superseded: bail without rendering a boundary (see navigate()).
393
+ if (
394
+ (error instanceof DOMException && error.name === "AbortError") ||
395
+ tx.handle.signal.aborted
396
+ ) {
397
+ return;
398
+ }
399
+
375
400
  const networkError = toNetworkError(error, {
376
401
  url: window.location.href,
377
402
  operation: "revalidation",
@@ -384,7 +409,12 @@ export function createNavigationBridge(
384
409
  emitNetworkError(onUpdate, networkError, window.location.href);
385
410
  return;
386
411
  }
387
- throw error;
412
+
413
+ // refresh() shares the fetchPartialUpdate chokepoint with navigate()/
414
+ // popstate, so an unprocessable response must surface the error boundary
415
+ // here too rather than become an uncaught rejection.
416
+ console.error("[Browser] Unprocessable refresh response:", error);
417
+ emitNavigationError(onUpdate, error, window.location.href);
388
418
  } finally {
389
419
  tx[Symbol.dispose]();
390
420
  }
@@ -527,8 +557,19 @@ export function createNavigationBridge(
527
557
  // SWR: If stale, trigger background revalidation
528
558
  if (isStale) {
529
559
  debugLog("[Browser] Cache is stale, background revalidating...");
530
- // Background revalidation - don't await, just fire and forget
531
- const segmentIds = cachedSegments.map((s) => s.id);
560
+ // Background revalidation - don't await, just fire and forget.
561
+ // When the entry's handles are incomplete (a deferred Meta was still
562
+ // pending when the user navigated away — see handlesPending), send NO
563
+ // segment IDs so the server returns a FULL re-render with the handle
564
+ // stream. A normal stale revalidation sends the cached IDs and the
565
+ // server returns a diff-only payload that omits unchanged segments'
566
+ // handles, so a deferred Meta would never re-stream and the title
567
+ // would stay the pre-resolution carry. handlesPending is set only for
568
+ // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
569
+ // cheap diff path.
570
+ const segmentIds = cached?.handlesPending
571
+ ? []
572
+ : cachedSegments.map((s) => s.id);
532
573
 
533
574
  const tx = createNavigationTransaction(
534
575
  store,
@@ -602,8 +643,13 @@ export function createNavigationBridge(
602
643
  // Restore scroll position after fetch completes
603
644
  handleNavigationEnd({ restore: true, isStreaming });
604
645
  } catch (error) {
605
- if (error instanceof DOMException && error.name === "AbortError") {
606
- debugLog("[Browser] Popstate navigation aborted");
646
+ // Aborted or superseded by a newer navigation: bail without clobbering
647
+ // content with a boundary (see navigate()).
648
+ if (
649
+ (error instanceof DOMException && error.name === "AbortError") ||
650
+ tx.handle.signal.aborted
651
+ ) {
652
+ debugLog("[Browser] Popstate navigation aborted or superseded");
607
653
  return;
608
654
  }
609
655
 
@@ -620,7 +666,10 @@ export function createNavigationBridge(
620
666
  return;
621
667
  }
622
668
 
623
- throw error;
669
+ // Unprocessable response on a back/forward navigation: surface the
670
+ // error boundary instead of an uncaught rejection (see navigate()).
671
+ console.error("[Browser] Unprocessable popstate response:", error);
672
+ emitNavigationError(onUpdate, error, url);
624
673
  } finally {
625
674
  tx[Symbol.dispose]();
626
675
  }
@@ -8,6 +8,7 @@ import type {
8
8
  import { NetworkError, ServerRedirect, isNetworkError } from "../errors.js";
9
9
  import {
10
10
  browserDebugLog,
11
+ debugLog,
11
12
  isBrowserDebugEnabled,
12
13
  startBrowserTransaction,
13
14
  } from "./logging.js";
@@ -222,7 +223,11 @@ export function createNavigationClient(
222
223
  ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
223
224
  headers: {
224
225
  "X-RSC-Router-Client-Path": previousUrl,
225
- "X-Rango-State": getRangoState(),
226
+ // Reuse the single per-operation read (see rangoState above): the
227
+ // cache-key lookup and this header must agree on one value, and the
228
+ // cookie read has side effects (external-rotation notify) we do not
229
+ // want to fire twice per navigation.
230
+ "X-Rango-State": rangoState,
226
231
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
227
232
  ...(interceptSourceUrl && {
228
233
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -260,6 +265,10 @@ export function createNavigationClient(
260
265
 
261
266
  let payloadPromise: Promise<RscPayload>;
262
267
  let streamCompletePromise: Promise<void>;
268
+ // True only for a prefetch-cache hit whose stream had already fully drained
269
+ // (complete === true). A still-streaming hit and the fresh path stay false,
270
+ // so only a fully-warmed prefetch commits in a transition (no fallback flash).
271
+ let fullyPrefetched = false;
263
272
 
264
273
  if (cachedEntry) {
265
274
  if (tx) {
@@ -270,6 +279,9 @@ export function createNavigationClient(
270
279
  }
271
280
  payloadPromise = cachedEntry.payload;
272
281
  streamCompletePromise = cachedEntry.streamComplete;
282
+ // Only a hit whose stream already fully drained is "fully prefetched";
283
+ // a still-streaming hit must keep streaming its fallbacks like a cold load.
284
+ fullyPrefetched = cachedEntry.complete;
273
285
  } else if (inflightEntryPromise) {
274
286
  if (tx) {
275
287
  browserDebugLog(tx, "reusing inflight prefetch", {
@@ -299,6 +311,9 @@ export function createNavigationClient(
299
311
  } else {
300
312
  payloadPromise = entry.payload;
301
313
  streamCompletePromise = entry.streamComplete;
314
+ // Adopted inflight is normally still streaming (false), but read the
315
+ // flag in case it completed between publish and adoption.
316
+ fullyPrefetched = entry.complete;
302
317
  }
303
318
  } else {
304
319
  ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
@@ -306,7 +321,18 @@ export function createNavigationClient(
306
321
  }
307
322
 
308
323
  try {
324
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Times how long the RSC
325
+ // payload ROOT takes to resolve: ~full-stream duration means the root
326
+ // model is not flushed early (server/runtime buffering, e.g. wrangler
327
+ // dev gzip); fast means the block, if any, is downstream in render.
328
+ const vtDebugStart = isBrowserDebugEnabled() ? performance.now() : 0;
309
329
  const payload = await payloadPromise;
330
+ if (isBrowserDebugEnabled()) {
331
+ debugLog("[VT-DIAG] payloadResolved", {
332
+ ms: Math.round(performance.now() - vtDebugStart),
333
+ isPartial: payload.metadata?.isPartial,
334
+ });
335
+ }
310
336
 
311
337
  if (tx) {
312
338
  browserDebugLog(tx, "response received", {
@@ -315,7 +341,11 @@ export function createNavigationClient(
315
341
  diffCount: payload.metadata?.diff?.length ?? 0,
316
342
  });
317
343
  }
318
- return { payload, streamComplete: streamCompletePromise };
344
+ return {
345
+ payload,
346
+ streamComplete: streamCompletePromise,
347
+ fullyPrefetched,
348
+ };
319
349
  } catch (error) {
320
350
  // Convert network-level errors to NetworkError for proper handling
321
351
  if (isNetworkError(error)) {
@@ -28,29 +28,49 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
28
28
  // Maximum number of history entries to cache (URLs visited)
29
29
  const HISTORY_CACHE_SIZE = 20;
30
30
 
31
- // Cache entry: [url-key, segments, stale, handleData?, routerId?]
32
- // stale=true means the data may be outdated and should be revalidated on access
31
+ // Cache entry:
32
+ // [url-key, segments, stale, handleData?, routerId?, navInstance?, handlesPending?]
33
+ // stale=true means the data may be outdated and should be revalidated on access.
34
+ // navInstance is the monotonic nav-instance token (see navInstance below): it
35
+ // identifies the per-commit visit that owns this entry. generateHistoryKey is
36
+ // URL-only, so A->B->A reuses the same key; the token lets a late async
37
+ // resolution tell its own visit's entry apart from a newer same-URL visit's, so
38
+ // a stale nav can never clobber a fresher one.
39
+ // handlesPending=true means the entry's handle data is INCOMPLETE (a deferred
40
+ // Meta was still pending when the user navigated away, so it never streamed). A
41
+ // popstate return must REVALIDATE WITH A FULL RE-RENDER (no client segment IDs)
42
+ // to re-stream the handles — a diff-only revalidation omits unchanged segments'
43
+ // handles, so the deferred Meta would never land. Cleared once the deferred Meta
44
+ // resolves while the entry is still owned.
33
45
  type HistoryCacheEntry = [
34
46
  string,
35
47
  ResolvedSegment[],
36
48
  boolean,
37
49
  HandleData?,
38
50
  string?,
51
+ number?,
52
+ boolean?,
39
53
  ];
40
54
 
41
55
  /**
42
- * Shallow clone handleData to avoid reference sharing between cache entries.
43
- * Only clones the structure (objects and arrays), not the data items themselves,
44
- * since mutations happen at the array level, not on individual data objects.
45
- * This preserves any non-serializable types (React elements, functions, etc.)
56
+ * Clone the handleData CONTAINERS (the handle-name map and each segment map) so
57
+ * a cache entry is decoupled from the live map that eventController mutates — it
58
+ * adds/deletes segment keys and REPLACES bucket arrays in place. The bucket
59
+ * arrays themselves are shared by reference, NOT copied: a bucket array is only
60
+ * ever replaced wholesale (eventController.setHandleData reassigns it,
61
+ * resolveDeferredHandleValues builds a fresh one) and collect functions read it
62
+ * without mutating, so sharing is safe and skips an O(elements) copy on every
63
+ * cache write — the per-yield streaming hot path. This also preserves any
64
+ * non-serializable bucket contents (React elements, functions, etc.).
46
65
  */
47
- function cloneHandleData(handleData: HandleData): HandleData {
66
+ export function cloneHandleData(handleData: HandleData): HandleData {
48
67
  const cloned: HandleData = {};
49
68
  for (const [handleKey, segmentMap] of Object.entries(handleData)) {
50
- cloned[handleKey] = {};
69
+ const clonedMap: Record<string, unknown[]> = {};
51
70
  for (const [segmentId, dataArray] of Object.entries(segmentMap)) {
52
- cloned[handleKey][segmentId] = [...dataArray];
71
+ clonedMap[segmentId] = dataArray;
53
72
  }
73
+ cloned[handleKey] = clonedMap;
54
74
  }
55
75
  return cloned;
56
76
  }
@@ -239,6 +259,14 @@ export function createNavigationStore(
239
259
  // Oldest entries (at front) are removed when over cacheSize limit
240
260
  const historyCache: HistoryCacheEntry[] = [];
241
261
 
262
+ // Monotonic nav-instance token. Bumped each time a cache entry is created or
263
+ // replaced in cacheSegmentsForHistory (i.e. once per commit). Because
264
+ // generateHistoryKey is URL-only, two visits to the same URL share a key; this
265
+ // token gives each visit a distinct identity so a late async handle resolution
266
+ // can tell whether it still owns the live page / the target cache entry, and
267
+ // never overwrite a newer same-URL visit's state.
268
+ let navInstance = 0;
269
+
242
270
  // Current history key (set on navigation, stored in history.state)
243
271
  let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
244
272
 
@@ -248,6 +276,10 @@ export function createNavigationStore(
248
276
  config.initialHistoryKey,
249
277
  config.initialSegments,
250
278
  false,
279
+ undefined,
280
+ undefined,
281
+ ++navInstance,
282
+ false,
251
283
  ]);
252
284
  }
253
285
 
@@ -565,6 +597,29 @@ export function createNavigationStore(
565
597
  currentHistoryKey = key;
566
598
  },
567
599
 
600
+ /**
601
+ * Current nav-instance token: the instance of the most recently committed
602
+ * navigation (the value last written by cacheSegmentsForHistory). A late
603
+ * async handle resolution captures this at the start of its own nav and
604
+ * compares it back here to detect whether a NEWER navigation has since
605
+ * committed (token advanced), guarding against a stale nav writing a fresher
606
+ * nav's live state.
607
+ */
608
+ getNavInstance(): number {
609
+ return navInstance;
610
+ },
611
+
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
+
568
623
  /**
569
624
  * Store segments for a history entry
570
625
  * Updates existing entry if key exists, otherwise adds new entry
@@ -583,6 +638,11 @@ export function createNavigationStore(
583
638
  ? cloneHandleData(handleData)
584
639
  : undefined;
585
640
 
641
+ // Each commit (create or replace) is a new nav instance. The bump happens
642
+ // here, exactly once per cacheSegmentsForHistory call, so getNavInstance()
643
+ // reflects the visit whose entry this is.
644
+ const instance = ++navInstance;
645
+
586
646
  // Check if entry already exists and update it
587
647
  const existingIndex = historyCache.findIndex(
588
648
  ([key]) => key === historyKey,
@@ -594,6 +654,8 @@ export function createNavigationStore(
594
654
  false,
595
655
  clonedHandleData,
596
656
  currentRouterId,
657
+ instance,
658
+ false, // fresh commit: handles complete unless a deferred apply marks it
597
659
  ];
598
660
  } else {
599
661
  // Add new entry at the end (not stale)
@@ -603,6 +665,8 @@ export function createNavigationStore(
603
665
  false,
604
666
  clonedHandleData,
605
667
  currentRouterId,
668
+ instance,
669
+ false,
606
670
  ]);
607
671
  // Remove oldest entries if over limit
608
672
  while (historyCache.length > cacheSize) {
@@ -621,6 +685,7 @@ export function createNavigationStore(
621
685
  stale: boolean;
622
686
  handleData?: HandleData;
623
687
  routerId?: string;
688
+ handlesPending?: boolean;
624
689
  }
625
690
  | undefined {
626
691
  const entry = historyCache.find(([key]) => key === historyKey);
@@ -630,6 +695,7 @@ export function createNavigationStore(
630
695
  stale: entry[2],
631
696
  handleData: entry[3],
632
697
  routerId: entry[4],
698
+ handlesPending: entry[6],
633
699
  };
634
700
  },
635
701
 
@@ -641,11 +707,23 @@ export function createNavigationStore(
641
707
  },
642
708
 
643
709
  /**
644
- * Update only the handleData for an existing cache entry
645
- * Does nothing if the cache entry doesn't exist
646
- * This is used to fix stale handleData after async handles processing
710
+ * Update only the handleData (and optionally the stale flag) for an existing
711
+ * cache entry. Does nothing if the cache entry doesn't exist.
712
+ *
713
+ * Used to fix stale handleData after async handles processing AND to flip an
714
+ * entry's stale / handlesPending bits for the deferred-Meta
715
+ * invalidate+revalidate path: while a nav's Meta is deferred-pending its
716
+ * entry is marked stale + handlesPending (a popstate return then revalidates
717
+ * with a full re-render instead of serving the carry/seed as fresh), and once
718
+ * the deferred Meta resolves both are cleared. When a flag is omitted the
719
+ * entry's current value is preserved.
647
720
  */
648
- updateCacheHandleData(historyKey: string, handleData: HandleData): void {
721
+ updateCacheHandleData(
722
+ historyKey: string,
723
+ handleData: HandleData,
724
+ stale?: boolean,
725
+ handlesPending?: boolean,
726
+ ): void {
649
727
  const existingIndex = historyCache.findIndex(
650
728
  ([key]) => key === historyKey,
651
729
  );
@@ -656,13 +734,49 @@ export function createNavigationStore(
656
734
  historyCache[existingIndex] = [
657
735
  entry[0],
658
736
  entry[1],
659
- entry[2],
737
+ stale ?? entry[2], // set stale when provided, else preserve current
660
738
  clonedHandleData,
661
739
  entry[4], // preserve routerId
740
+ entry[5], // preserve navInstance (entry ownership identity)
741
+ handlesPending ?? entry[6], // set when provided, else preserve current
662
742
  ];
663
743
  }
664
744
  },
665
745
 
746
+ /**
747
+ * Owner-guarded handle-data write: locate the entry, and write ONLY when it
748
+ * is still owned by `ownerInstance` (the nav-instance token that seeded it).
749
+ * Folds the streaming hot path's separate getCacheEntryInstance() ownership
750
+ * probe and updateCacheHandleData() write into a SINGLE historyCache scan
751
+ * (processHandles calls this per yield). Semantics otherwise match
752
+ * updateCacheHandleData: no-op on a missing entry, clone the handleData
753
+ * containers, and preserve stale / handlesPending when the flag is omitted.
754
+ */
755
+ updateCacheHandleDataIfOwned(
756
+ historyKey: string,
757
+ handleData: HandleData,
758
+ ownerInstance: number,
759
+ stale?: boolean,
760
+ handlesPending?: boolean,
761
+ ): void {
762
+ const existingIndex = historyCache.findIndex(
763
+ ([key]) => key === historyKey,
764
+ );
765
+ if (existingIndex === -1) return;
766
+ const entry = historyCache[existingIndex];
767
+ if (entry[5] !== ownerInstance) return;
768
+ const clonedHandleData = cloneHandleData(handleData);
769
+ historyCache[existingIndex] = [
770
+ entry[0],
771
+ entry[1],
772
+ stale ?? entry[2],
773
+ clonedHandleData,
774
+ entry[4],
775
+ entry[5],
776
+ handlesPending ?? entry[6],
777
+ ];
778
+ },
779
+
666
780
  /**
667
781
  * Mark all cache entries as stale
668
782
  * Called after server actions to indicate data may be outdated
@@ -1,5 +1,5 @@
1
1
  import { NetworkError, isNetworkError } from "../errors.js";
2
- import { NetworkErrorThrower } from "../network-error-thrower.js";
2
+ import { RenderErrorThrower } from "../render-error-thrower.js";
3
3
  import type { UpdateSubscriber } from "./types.js";
4
4
  import { createElement, startTransition } from "react";
5
5
 
@@ -24,18 +24,18 @@ export function toNetworkError(
24
24
  }
25
25
 
26
26
  /**
27
- * Emit a NetworkError to the UI via the onUpdate subscriber.
28
- * Wraps in startTransition and renders a NetworkErrorThrower component
29
- * that throws during render to trigger the nearest error boundary.
27
+ * Render an error into the segment tree via the onUpdate subscriber so the
28
+ * nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
29
+ * throws during render (async rejections do not reach boundaries on their own).
30
30
  */
31
- export function emitNetworkError(
31
+ function emitErrorToBoundary(
32
32
  onUpdate: UpdateSubscriber,
33
- error: NetworkError,
33
+ error: unknown,
34
34
  pathname: string,
35
35
  ): void {
36
36
  startTransition(() => {
37
37
  onUpdate({
38
- root: createElement(NetworkErrorThrower, { error }),
38
+ root: createElement(RenderErrorThrower, { error }),
39
39
  metadata: {
40
40
  pathname,
41
41
  segments: [],
@@ -45,6 +45,33 @@ export function emitNetworkError(
45
45
  });
46
46
  }
47
47
 
48
+ /**
49
+ * Emit a NetworkError to the nearest error boundary (offline, failed fetch).
50
+ */
51
+ export function emitNetworkError(
52
+ onUpdate: UpdateSubscriber,
53
+ error: NetworkError,
54
+ pathname: string,
55
+ ): void {
56
+ emitErrorToBoundary(onUpdate, error, pathname);
57
+ }
58
+
59
+ /**
60
+ * Emit a navigation processing error to the nearest error boundary. Used when a
61
+ * navigation response cannot be processed (an undecodable Flight body, or any
62
+ * unanticipated failure while building the response) -- for both fresh and
63
+ * prefetched responses, since both funnel through the navigation catch. Without
64
+ * this, such a failure becomes an uncaught rejection that silently aborts the
65
+ * navigation instead of surfacing the route's error boundary.
66
+ */
67
+ export function emitNavigationError(
68
+ onUpdate: UpdateSubscriber,
69
+ error: unknown,
70
+ pathname: string,
71
+ ): void {
72
+ emitErrorToBoundary(onUpdate, error, pathname);
73
+ }
74
+
48
75
  /**
49
76
  * Check if an error is safe to suppress in background operations.
50
77
  *