@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135

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 (71) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +41 -27
  3. package/package.json +23 -24
  4. package/skills/composability/SKILL.md +0 -1
  5. package/skills/handler-use/SKILL.md +7 -7
  6. package/skills/intercept/SKILL.md +38 -13
  7. package/skills/loader/SKILL.md +10 -0
  8. package/skills/migrate-nextjs/SKILL.md +3 -3
  9. package/skills/migrate-react-router/SKILL.md +144 -1
  10. package/skills/prerender/SKILL.md +20 -17
  11. package/skills/router-setup/SKILL.md +1 -2
  12. package/skills/testing/SKILL.md +1 -0
  13. package/skills/testing/render-handler.md +15 -14
  14. package/skills/use-cache/SKILL.md +11 -0
  15. package/skills/view-transitions/SKILL.md +43 -0
  16. package/src/browser/navigation-bridge.ts +65 -16
  17. package/src/browser/navigation-client.ts +27 -1
  18. package/src/browser/navigation-store.ts +82 -8
  19. package/src/browser/network-error-handler.ts +34 -7
  20. package/src/browser/partial-update.ts +43 -3
  21. package/src/browser/prefetch/cache.ts +8 -0
  22. package/src/browser/prefetch/fetch.ts +32 -4
  23. package/src/browser/react/NavigationProvider.tsx +195 -4
  24. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  25. package/src/browser/response-adapter.ts +38 -9
  26. package/src/browser/types.ts +32 -1
  27. package/src/cache/cache-runtime.ts +26 -5
  28. package/src/cache/document-cache.ts +17 -1
  29. package/src/cache/profile-registry.ts +15 -0
  30. package/src/cache/read-through-swr.ts +15 -1
  31. package/src/handles/MetaTags.tsx +6 -0
  32. package/src/index.rsc.ts +6 -1
  33. package/src/index.ts +6 -4
  34. package/src/internal-debug.ts +11 -8
  35. package/src/render-error-thrower.tsx +20 -0
  36. package/src/route-content-wrapper.tsx +12 -5
  37. package/src/route-definition/dsl-helpers.ts +21 -32
  38. package/src/route-definition/helper-factories.ts +0 -2
  39. package/src/route-definition/helpers-types.ts +38 -39
  40. package/src/route-definition/index.ts +1 -2
  41. package/src/route-definition/resolve-handler-use.ts +0 -1
  42. package/src/route-definition/use-item-types.ts +3 -6
  43. package/src/route-types.ts +0 -5
  44. package/src/router/match-api.ts +5 -1
  45. package/src/router/match-middleware/background-revalidation.ts +40 -23
  46. package/src/router/match-middleware/cache-store.ts +39 -24
  47. package/src/router/segment-resolution/fresh.ts +4 -0
  48. package/src/router/segment-resolution/loader-cache.ts +14 -2
  49. package/src/router/segment-resolution/revalidation.ts +3 -0
  50. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  51. package/src/rsc/progressive-enhancement.ts +56 -2
  52. package/src/rsc/rsc-rendering.ts +7 -2
  53. package/src/rsc/server-action.ts +25 -2
  54. package/src/rsc/transition-gate.ts +89 -0
  55. package/src/segment-system.tsx +59 -8
  56. package/src/server/context.ts +13 -0
  57. package/src/server/loader-registry.ts +13 -1
  58. package/src/server/request-context.ts +52 -3
  59. package/src/testing/index.ts +6 -0
  60. package/src/testing/render-handler.ts +14 -0
  61. package/src/testing/run-transition-when.ts +164 -0
  62. package/src/types/handler-context.ts +1 -1
  63. package/src/types/index.ts +2 -0
  64. package/src/types/segments.ts +100 -0
  65. package/src/urls/path-helper-types.ts +10 -7
  66. package/src/urls/urls-function.ts +0 -1
  67. package/src/vite/inject-client-debug.ts +36 -0
  68. package/src/vite/plugins/version-injector.ts +22 -7
  69. package/src/vite/plugins/virtual-entries.ts +28 -9
  70. package/src/vite/router-discovery.ts +8 -13
  71. package/src/network-error-thrower.tsx +0 -18
@@ -88,6 +88,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
88
88
  | a loader's cookie / header / redirect output (auth-loader pattern) | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
89
89
  | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
90
90
  | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
91
+ | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`) | `@rangojs/router/testing` |
91
92
  | a handle's `collect`/accumulator, or a seeded handle read | unit | [`collectHandle` / seeded `handles`](./handles.md) | `@rangojs/router/testing` |
92
93
  | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
93
94
  | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
@@ -8,20 +8,21 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
8
8
 
9
9
  ### Options — `RenderHandlerOptions`
10
10
 
11
- | Field | Type | Meaning |
12
- | ------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
13
- | `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
14
- | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
15
- | `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
16
- | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
17
- | `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
18
- | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
19
- | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
20
- | `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs. |
21
- | `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name. |
22
- | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a handler calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production rather than no-opping; override `prefix`/`routerId` to match your `createRouter({ stateCookiePrefix, id })`, or `version` (the value is `{version}:{timestamp}`, default `"0"`). |
23
- | `cacheStore` | `SegmentCacheStore` | Segment cache store backing a `"use cache"` function the handler invokes (e.g. `new MemorySegmentCacheStore()`). WITHOUT it, `registerCachedFunction` takes the uncached bypass and the cached path is NOT exercised (the runtime emits a one-time warning under the test runner). Pair with `cacheProfiles`. |
24
- | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape, required for `"use cache: profileName"` resolution once a `cacheStore` is wired. |
11
+ | Field | Type | Meaning |
12
+ | ---------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
13
+ | `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
14
+ | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
15
+ | `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
16
+ | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
17
+ | `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
18
+ | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
19
+ | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
20
+ | `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs. |
21
+ | `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name. |
22
+ | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a handler calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production rather than no-opping; override `prefix`/`routerId` to match your `createRouter({ stateCookiePrefix, id })`, or `version` (the value is `{version}:{timestamp}`, default `"0"`). |
23
+ | `cacheStore` | `SegmentCacheStore` | Segment cache store backing a `"use cache"` function the handler invokes (e.g. `new MemorySegmentCacheStore()`). WITHOUT it, `registerCachedFunction` takes the uncached bypass and the cached path is NOT exercised (the runtime emits a one-time warning under the test runner). Pair with `cacheProfiles`. |
24
+ | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape, required for `"use cache: profileName"` resolution once a `cacheStore` is wired. |
25
+ | `inActionRevalidation` | `boolean` | Render as if inside a server action's revalidation render (production sets this in `revalidateAfterAction`). A stale `"use cache"` entry whose profile sets `foregroundOnAction: true` then re-executes in the FOREGROUND (fresh result in this render) instead of SWR. Pair with `cacheStore` + `cacheProfiles` to exercise the opt-in. |
25
26
 
26
27
  ### Context — `HandlerContext` (what your handler receives)
27
28
 
@@ -63,12 +63,23 @@ createRouter({
63
63
  short: { ttl: 60, swr: 120 },
64
64
  long: { ttl: 3600, swr: 7200 },
65
65
  products: { ttl: 300, swr: 600, tags: ["products"] },
66
+ // Opt-in: a stale entry re-executes in the foreground during a server
67
+ // action's revalidation render (fresh action response), instead of SWR.
68
+ cms: { ttl: 300, swr: 600, foregroundOnAction: true },
66
69
  },
67
70
  });
68
71
  ```
69
72
 
70
73
  - `"use cache"` (no name) resolves to `default`.
71
74
  - `"use cache: short"` resolves to the `short` profile.
75
+ - `foregroundOnAction: true` (default false): a stale entry serves stale +
76
+ revalidates in the background on a plain navigation (SWR), but re-executes in
77
+ the FOREGROUND during a server action's revalidation render so the action
78
+ response reflects a fresh value (only the store write is deferred). Use it for
79
+ mutation-related cached data; incidental TTL staleness on an ordinary action
80
+ stays SWR so the action is not turned into a synchronous cache-refresh barrier.
81
+ For strong read-your-own-writes after a mutation, prefer `updateTag()` (a hard
82
+ purge, so the action's own re-render is a fresh foreground miss).
72
83
  - Unknown profile names throw at runtime, on the first invocation of the cached
73
84
  function (the Vite transform does not validate names at build/boot). The error
74
85
  is actionable -- it names the missing profile and shows the `createRouter({
@@ -209,6 +209,10 @@ 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.
215
+ when?: (ctx: TransitionWhenContext) => boolean;
212
216
  }
213
217
  ```
214
218
 
@@ -217,6 +221,45 @@ interface TransitionConfig {
217
221
  - `name` lets you participate in cross-page morphs by name (advanced; you usually don't need this on a layout/route-level wrap).
218
222
  - `viewTransition` toggles whether rango places its own `<ViewTransition>` boundary. `"auto"` (default) wraps as described above; `false` opts out — see the next section.
219
223
 
224
+ ## Conditional transitions (`when`)
225
+
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).
227
+
228
+ Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for post-handler reads:
229
+
230
+ ```ts
231
+ import type { TransitionWhenContext } from "@rangojs/router";
232
+
233
+ // Hold only when the handler marked this request (handler sets, gate reads):
234
+ transition({ when: (ctx) => ctx.get(KeepScroll) === true });
235
+
236
+ // Hold only when arriving from a specific page (the navigation SOURCE):
237
+ transition({
238
+ when: ({ currentUrl }) => currentUrl?.pathname.startsWith("/list") === true,
239
+ });
240
+ transition({ when: ({ fromRouteName }) => fromRouteName === "products.list" });
241
+
242
+ // Hold only after a specific action revalidated the route:
243
+ transition({
244
+ when: ({ actionId }) => actionId === "src/actions/cart.ts#addToCart",
245
+ });
246
+ ```
247
+
248
+ | field | meaning | populated |
249
+ | ------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------- |
250
+ | `currentUrl` / `currentParams` / `fromRouteName` | navigation **source** | soft nav + action-success; `undefined` on initial full load and action/PE error paths |
251
+ | `nextUrl` / `nextParams` | navigation **target** | always |
252
+ | `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
253
+ | `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
254
+ | `method` | `"GET"` (nav) / `"POST"` (action) | always |
255
+ | `get` / `env` | read handler/middleware vars + app env | always |
256
+
257
+ A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
258
+
259
+ **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
+
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.
262
+
220
263
  ## Opting out of the router boundary (place your own `<ViewTransition>`)
221
264
 
222
265
  By default a `transition()` segment gets a rango-placed `<ViewTransition>` boundary — a cross-fade of the whole outlet/route. If you'd rather animate specific elements yourself (place `<ViewTransition name="...">` in your components), set `viewTransition: false`. The router then contributes **no boundary of its own** but still:
@@ -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";
@@ -260,6 +261,10 @@ export function createNavigationClient(
260
261
 
261
262
  let payloadPromise: Promise<RscPayload>;
262
263
  let streamCompletePromise: Promise<void>;
264
+ // True only for a prefetch-cache hit whose stream had already fully drained
265
+ // (complete === true). A still-streaming hit and the fresh path stay false,
266
+ // so only a fully-warmed prefetch commits in a transition (no fallback flash).
267
+ let fullyPrefetched = false;
263
268
 
264
269
  if (cachedEntry) {
265
270
  if (tx) {
@@ -270,6 +275,9 @@ export function createNavigationClient(
270
275
  }
271
276
  payloadPromise = cachedEntry.payload;
272
277
  streamCompletePromise = cachedEntry.streamComplete;
278
+ // Only a hit whose stream already fully drained is "fully prefetched";
279
+ // a still-streaming hit must keep streaming its fallbacks like a cold load.
280
+ fullyPrefetched = cachedEntry.complete;
273
281
  } else if (inflightEntryPromise) {
274
282
  if (tx) {
275
283
  browserDebugLog(tx, "reusing inflight prefetch", {
@@ -299,6 +307,9 @@ export function createNavigationClient(
299
307
  } else {
300
308
  payloadPromise = entry.payload;
301
309
  streamCompletePromise = entry.streamComplete;
310
+ // Adopted inflight is normally still streaming (false), but read the
311
+ // flag in case it completed between publish and adoption.
312
+ fullyPrefetched = entry.complete;
302
313
  }
303
314
  } else {
304
315
  ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
@@ -306,7 +317,18 @@ export function createNavigationClient(
306
317
  }
307
318
 
308
319
  try {
320
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Times how long the RSC
321
+ // payload ROOT takes to resolve: ~full-stream duration means the root
322
+ // model is not flushed early (server/runtime buffering, e.g. wrangler
323
+ // dev gzip); fast means the block, if any, is downstream in render.
324
+ const vtDebugStart = isBrowserDebugEnabled() ? performance.now() : 0;
309
325
  const payload = await payloadPromise;
326
+ if (isBrowserDebugEnabled()) {
327
+ debugLog("[VT-DIAG] payloadResolved", {
328
+ ms: Math.round(performance.now() - vtDebugStart),
329
+ isPartial: payload.metadata?.isPartial,
330
+ });
331
+ }
310
332
 
311
333
  if (tx) {
312
334
  browserDebugLog(tx, "response received", {
@@ -315,7 +337,11 @@ export function createNavigationClient(
315
337
  diffCount: payload.metadata?.diff?.length ?? 0,
316
338
  });
317
339
  }
318
- return { payload, streamComplete: streamCompletePromise };
340
+ return {
341
+ payload,
342
+ streamComplete: streamCompletePromise,
343
+ fullyPrefetched,
344
+ };
319
345
  } catch (error) {
320
346
  // Convert network-level errors to NetworkError for proper handling
321
347
  if (isNetworkError(error)) {
@@ -28,14 +28,28 @@ 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
  /**
@@ -44,7 +58,7 @@ type HistoryCacheEntry = [
44
58
  * since mutations happen at the array level, not on individual data objects.
45
59
  * This preserves any non-serializable types (React elements, functions, etc.)
46
60
  */
47
- function cloneHandleData(handleData: HandleData): HandleData {
61
+ export function cloneHandleData(handleData: HandleData): HandleData {
48
62
  const cloned: HandleData = {};
49
63
  for (const [handleKey, segmentMap] of Object.entries(handleData)) {
50
64
  cloned[handleKey] = {};
@@ -239,6 +253,14 @@ export function createNavigationStore(
239
253
  // Oldest entries (at front) are removed when over cacheSize limit
240
254
  const historyCache: HistoryCacheEntry[] = [];
241
255
 
256
+ // Monotonic nav-instance token. Bumped each time a cache entry is created or
257
+ // replaced in cacheSegmentsForHistory (i.e. once per commit). Because
258
+ // generateHistoryKey is URL-only, two visits to the same URL share a key; this
259
+ // token gives each visit a distinct identity so a late async handle resolution
260
+ // can tell whether it still owns the live page / the target cache entry, and
261
+ // never overwrite a newer same-URL visit's state.
262
+ let navInstance = 0;
263
+
242
264
  // Current history key (set on navigation, stored in history.state)
243
265
  let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
244
266
 
@@ -248,6 +270,10 @@ export function createNavigationStore(
248
270
  config.initialHistoryKey,
249
271
  config.initialSegments,
250
272
  false,
273
+ undefined,
274
+ undefined,
275
+ ++navInstance,
276
+ false,
251
277
  ]);
252
278
  }
253
279
 
@@ -565,6 +591,29 @@ export function createNavigationStore(
565
591
  currentHistoryKey = key;
566
592
  },
567
593
 
594
+ /**
595
+ * Current nav-instance token: the instance of the most recently committed
596
+ * navigation (the value last written by cacheSegmentsForHistory). A late
597
+ * async handle resolution captures this at the start of its own nav and
598
+ * compares it back here to detect whether a NEWER navigation has since
599
+ * committed (token advanced), guarding against a stale nav writing a fresher
600
+ * nav's live state.
601
+ */
602
+ getNavInstance(): number {
603
+ return navInstance;
604
+ },
605
+
606
+ /**
607
+ * The nav-instance token recorded on a specific cache entry, or undefined if
608
+ * no entry exists for that key. Because the history key is URL-only, this is
609
+ * how a late resolution tells "the entry I seeded is still mine" from "a
610
+ * newer same-URL visit replaced my entry".
611
+ */
612
+ getCacheEntryInstance(historyKey: string): number | undefined {
613
+ const entry = historyCache.find(([key]) => key === historyKey);
614
+ return entry ? entry[5] : undefined;
615
+ },
616
+
568
617
  /**
569
618
  * Store segments for a history entry
570
619
  * Updates existing entry if key exists, otherwise adds new entry
@@ -583,6 +632,11 @@ export function createNavigationStore(
583
632
  ? cloneHandleData(handleData)
584
633
  : undefined;
585
634
 
635
+ // Each commit (create or replace) is a new nav instance. The bump happens
636
+ // here, exactly once per cacheSegmentsForHistory call, so getNavInstance()
637
+ // reflects the visit whose entry this is.
638
+ const instance = ++navInstance;
639
+
586
640
  // Check if entry already exists and update it
587
641
  const existingIndex = historyCache.findIndex(
588
642
  ([key]) => key === historyKey,
@@ -594,6 +648,8 @@ export function createNavigationStore(
594
648
  false,
595
649
  clonedHandleData,
596
650
  currentRouterId,
651
+ instance,
652
+ false, // fresh commit: handles complete unless a deferred apply marks it
597
653
  ];
598
654
  } else {
599
655
  // Add new entry at the end (not stale)
@@ -603,6 +659,8 @@ export function createNavigationStore(
603
659
  false,
604
660
  clonedHandleData,
605
661
  currentRouterId,
662
+ instance,
663
+ false,
606
664
  ]);
607
665
  // Remove oldest entries if over limit
608
666
  while (historyCache.length > cacheSize) {
@@ -621,6 +679,7 @@ export function createNavigationStore(
621
679
  stale: boolean;
622
680
  handleData?: HandleData;
623
681
  routerId?: string;
682
+ handlesPending?: boolean;
624
683
  }
625
684
  | undefined {
626
685
  const entry = historyCache.find(([key]) => key === historyKey);
@@ -630,6 +689,7 @@ export function createNavigationStore(
630
689
  stale: entry[2],
631
690
  handleData: entry[3],
632
691
  routerId: entry[4],
692
+ handlesPending: entry[6],
633
693
  };
634
694
  },
635
695
 
@@ -641,11 +701,23 @@ export function createNavigationStore(
641
701
  },
642
702
 
643
703
  /**
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
704
+ * Update only the handleData (and optionally the stale flag) for an existing
705
+ * cache entry. Does nothing if the cache entry doesn't exist.
706
+ *
707
+ * Used to fix stale handleData after async handles processing AND to flip an
708
+ * entry's stale / handlesPending bits for the deferred-Meta
709
+ * invalidate+revalidate path: while a nav's Meta is deferred-pending its
710
+ * entry is marked stale + handlesPending (a popstate return then revalidates
711
+ * with a full re-render instead of serving the carry/seed as fresh), and once
712
+ * the deferred Meta resolves both are cleared. When a flag is omitted the
713
+ * entry's current value is preserved.
647
714
  */
648
- updateCacheHandleData(historyKey: string, handleData: HandleData): void {
715
+ updateCacheHandleData(
716
+ historyKey: string,
717
+ handleData: HandleData,
718
+ stale?: boolean,
719
+ handlesPending?: boolean,
720
+ ): void {
649
721
  const existingIndex = historyCache.findIndex(
650
722
  ([key]) => key === historyKey,
651
723
  );
@@ -656,9 +728,11 @@ export function createNavigationStore(
656
728
  historyCache[existingIndex] = [
657
729
  entry[0],
658
730
  entry[1],
659
- entry[2],
731
+ stale ?? entry[2], // set stale when provided, else preserve current
660
732
  clonedHandleData,
661
733
  entry[4], // preserve routerId
734
+ entry[5], // preserve navInstance (entry ownership identity)
735
+ handlesPending ?? entry[6], // set when provided, else preserve current
662
736
  ];
663
737
  }
664
738
  },
@@ -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
  *