@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bd1b239

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 (152) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +76 -18
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +45 -4
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/intercept/SKILL.md +20 -0
  10. package/skills/layout/SKILL.md +22 -0
  11. package/skills/links/SKILL.md +3 -1
  12. package/skills/loader/SKILL.md +53 -43
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +560 -0
  15. package/skills/migrate-react-router/SKILL.md +764 -0
  16. package/skills/parallel/SKILL.md +185 -0
  17. package/skills/prerender/SKILL.md +110 -68
  18. package/skills/rango/SKILL.md +24 -22
  19. package/skills/route/SKILL.md +55 -0
  20. package/skills/router-setup/SKILL.md +87 -2
  21. package/skills/typesafety/SKILL.md +10 -0
  22. package/src/__internal.ts +1 -1
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/navigation-bridge.ts +40 -15
  26. package/src/browser/navigation-client.ts +142 -57
  27. package/src/browser/navigation-store.ts +43 -8
  28. package/src/browser/navigation-transaction.ts +11 -9
  29. package/src/browser/partial-update.ts +94 -17
  30. package/src/browser/prefetch/cache.ts +82 -12
  31. package/src/browser/prefetch/fetch.ts +98 -27
  32. package/src/browser/prefetch/policy.ts +6 -0
  33. package/src/browser/prefetch/queue.ts +92 -20
  34. package/src/browser/prefetch/resource-ready.ts +77 -0
  35. package/src/browser/react/Link.tsx +76 -9
  36. package/src/browser/react/NavigationProvider.tsx +40 -4
  37. package/src/browser/react/context.ts +7 -2
  38. package/src/browser/react/use-handle.ts +9 -58
  39. package/src/browser/react/use-navigation.ts +11 -10
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +134 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +36 -9
  44. package/src/browser/server-action-bridge.ts +8 -6
  45. package/src/browser/types.ts +36 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +3 -0
  48. package/src/build/route-trie.ts +50 -24
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +223 -74
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.tsx +84 -230
  60. package/src/context-var.ts +72 -2
  61. package/src/debug.ts +2 -2
  62. package/src/handle.ts +40 -0
  63. package/src/index.rsc.ts +3 -1
  64. package/src/index.ts +46 -6
  65. package/src/prerender/store.ts +5 -4
  66. package/src/prerender.ts +138 -77
  67. package/src/reverse.ts +25 -1
  68. package/src/route-definition/dsl-helpers.ts +224 -37
  69. package/src/route-definition/helpers-types.ts +67 -19
  70. package/src/route-definition/index.ts +3 -0
  71. package/src/route-definition/redirect.ts +11 -3
  72. package/src/route-definition/resolve-handler-use.ts +149 -0
  73. package/src/route-map-builder.ts +7 -1
  74. package/src/route-types.ts +11 -0
  75. package/src/router/content-negotiation.ts +100 -1
  76. package/src/router/find-match.ts +4 -2
  77. package/src/router/handler-context.ts +82 -23
  78. package/src/router/intercept-resolution.ts +11 -4
  79. package/src/router/lazy-includes.ts +4 -1
  80. package/src/router/loader-resolution.ts +156 -21
  81. package/src/router/logging.ts +5 -2
  82. package/src/router/manifest.ts +9 -3
  83. package/src/router/match-api.ts +124 -189
  84. package/src/router/match-middleware/background-revalidation.ts +30 -2
  85. package/src/router/match-middleware/cache-lookup.ts +94 -17
  86. package/src/router/match-middleware/cache-store.ts +53 -10
  87. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  88. package/src/router/match-middleware/segment-resolution.ts +61 -5
  89. package/src/router/match-result.ts +104 -10
  90. package/src/router/metrics.ts +6 -1
  91. package/src/router/middleware-types.ts +6 -8
  92. package/src/router/middleware.ts +4 -6
  93. package/src/router/navigation-snapshot.ts +182 -0
  94. package/src/router/prerender-match.ts +110 -10
  95. package/src/router/preview-match.ts +30 -102
  96. package/src/router/request-classification.ts +310 -0
  97. package/src/router/route-snapshot.ts +245 -0
  98. package/src/router/router-context.ts +6 -1
  99. package/src/router/router-interfaces.ts +36 -4
  100. package/src/router/router-options.ts +37 -11
  101. package/src/router/segment-resolution/fresh.ts +198 -20
  102. package/src/router/segment-resolution/helpers.ts +29 -24
  103. package/src/router/segment-resolution/loader-cache.ts +1 -0
  104. package/src/router/segment-resolution/revalidation.ts +438 -300
  105. package/src/router/segment-wrappers.ts +2 -0
  106. package/src/router/types.ts +1 -0
  107. package/src/router.ts +59 -6
  108. package/src/rsc/handler.ts +472 -372
  109. package/src/rsc/loader-fetch.ts +23 -3
  110. package/src/rsc/manifest-init.ts +5 -1
  111. package/src/rsc/progressive-enhancement.ts +14 -2
  112. package/src/rsc/rsc-rendering.ts +12 -1
  113. package/src/rsc/server-action.ts +8 -0
  114. package/src/rsc/ssr-setup.ts +2 -2
  115. package/src/rsc/types.ts +9 -1
  116. package/src/segment-content-promise.ts +67 -0
  117. package/src/segment-loader-promise.ts +122 -0
  118. package/src/segment-system.tsx +109 -23
  119. package/src/server/context.ts +140 -14
  120. package/src/server/handle-store.ts +19 -0
  121. package/src/server/loader-registry.ts +9 -8
  122. package/src/server/request-context.ts +185 -19
  123. package/src/ssr/index.tsx +4 -0
  124. package/src/static-handler.ts +18 -6
  125. package/src/types/cache-types.ts +4 -4
  126. package/src/types/handler-context.ts +137 -33
  127. package/src/types/loader-types.ts +36 -9
  128. package/src/types/route-entry.ts +8 -1
  129. package/src/types/segments.ts +2 -0
  130. package/src/urls/path-helper-types.ts +39 -6
  131. package/src/urls/path-helper.ts +48 -13
  132. package/src/urls/pattern-types.ts +12 -0
  133. package/src/urls/response-types.ts +16 -6
  134. package/src/use-loader.tsx +77 -5
  135. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  136. package/src/vite/discovery/discover-routers.ts +5 -1
  137. package/src/vite/discovery/prerender-collection.ts +128 -74
  138. package/src/vite/discovery/state.ts +13 -6
  139. package/src/vite/index.ts +4 -0
  140. package/src/vite/plugin-types.ts +51 -79
  141. package/src/vite/plugins/expose-action-id.ts +1 -3
  142. package/src/vite/plugins/expose-id-utils.ts +12 -0
  143. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  144. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  145. package/src/vite/plugins/performance-tracks.ts +88 -0
  146. package/src/vite/plugins/refresh-cmd.ts +88 -26
  147. package/src/vite/plugins/version-plugin.ts +13 -1
  148. package/src/vite/rango.ts +163 -211
  149. package/src/vite/router-discovery.ts +178 -45
  150. package/src/vite/utils/banner.ts +3 -3
  151. package/src/vite/utils/prerender-utils.ts +37 -5
  152. package/src/vite/utils/shared-utils.ts +3 -2
@@ -181,6 +181,37 @@ String keys still work (`ctx.set("key", value)` / `ctx.get("key")`), but
181
181
  Only route handlers and middleware can call `ctx.set()`. Layouts, parallels,
182
182
  and intercepts can only read via `ctx.get()`.
183
183
 
184
+ #### Non-cacheable context variables
185
+
186
+ Mark a var as non-cacheable when it holds inherently request-specific data
187
+ (sessions, auth tokens, per-request IDs). There are two ways:
188
+
189
+ ```typescript
190
+ // Var-level: every value written to this var is non-cacheable
191
+ const Session = createVar<SessionData>({ cache: false });
192
+
193
+ // Write-level: escalate a normally-cacheable var for this specific write
194
+ const Theme = createVar<string>();
195
+ ctx.set(Theme, userTheme, { cache: false });
196
+ ```
197
+
198
+ "Least cacheable wins" — if either the var definition or the write site says
199
+ `cache: false`, the value is non-cacheable.
200
+
201
+ Reading a non-cacheable var inside `cache()` or `"use cache"` throws at
202
+ runtime. This prevents request-specific data from leaking into cached output:
203
+
204
+ ```typescript
205
+ // This throws — Session is non-cacheable
206
+ async function CachedWidget(ctx) {
207
+ "use cache";
208
+ const session = ctx.get(Session); // Error: non-cacheable var read inside cache scope
209
+ return <Widget />;
210
+ }
211
+ ```
212
+
213
+ Cacheable vars (the default) can be read freely inside cache scopes.
214
+
184
215
  ### Revalidation Contracts for Handler Data
185
216
 
186
217
  Handler-first guarantees apply within a single full render pass. For partial
@@ -352,6 +383,30 @@ urls(({ path, layout }) => [
352
383
  ])
353
384
  ```
354
385
 
386
+ ## Handler-attached `.use`
387
+
388
+ Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
389
+
390
+ ```typescript
391
+ const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
392
+ const product = await ctx.use(ProductLoader);
393
+ return <ProductView product={product} />;
394
+ };
395
+ ProductPage.use = () => [
396
+ loader(ProductLoader),
397
+ loading(<ProductSkeleton />),
398
+ middleware(async (ctx, next) => {
399
+ await next();
400
+ ctx.header("Cache-Control", "private, max-age=60");
401
+ }),
402
+ ];
403
+
404
+ // Mount site has no per-page wiring — defaults travel with the handler.
405
+ path("/product/:slug", ProductPage, { name: "product" });
406
+ ```
407
+
408
+ Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
409
+
355
410
  ## Complete Example
356
411
 
357
412
  ```typescript
@@ -78,16 +78,21 @@ interface RSCRouterOptions<TEnv> {
78
78
  // Document component wrapping entire app
79
79
  document?: ComponentType<{ children: ReactNode }>;
80
80
 
81
+ // URL prefix for sub-path deployments (e.g. "/admin")
82
+ // All routes, reverse(), href(), Link, redirect(), and router.use()
83
+ // patterns are automatically prefixed. Route names stay unprefixed.
84
+ basename?: string;
85
+
81
86
  // Enable per-request performance timeline (console waterfall + Server-Timing header)
82
87
  debugPerformance?: boolean;
83
88
 
84
89
  // Default error boundary
85
90
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
91
 
87
- // Default not-found boundary
92
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
93
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
94
 
90
- // Component for 404 routes
95
+ // Component for 404 (no route match, or notFound() without a boundary)
91
96
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
97
 
93
98
  // Error logging callback
@@ -124,6 +129,36 @@ interface RSCRouterOptions<TEnv> {
124
129
  }
125
130
  ```
126
131
 
132
+ ## Basename (Sub-Path Deployment)
133
+
134
+ When your app is served under a sub-path (e.g. `/admin` or `/v2`), set `basename`:
135
+
136
+ ```typescript
137
+ const router = createRouter({
138
+ basename: "/admin",
139
+ document: Document,
140
+ }).routes(({ path, include }) => [
141
+ path("/", Dashboard, { name: "home" }), // matches /admin
142
+ path("/users", Users, { name: "users" }), // matches /admin/users
143
+ include("/api", apiPatterns, { name: "api" }), // matches /admin/api/*
144
+ ]);
145
+
146
+ router.reverse("home"); // "/admin"
147
+ router.reverse("users"); // "/admin/users"
148
+ ```
149
+
150
+ Router-owned APIs are basename-aware:
151
+
152
+ - `reverse()` returns prefixed paths
153
+ - `<Link to="/users">` renders `<a href="/admin/users">`
154
+ - `redirect("/login")` redirects to `"/admin/login"`
155
+ - `router.use("/users/*", mw)` matches `/admin/users/*`
156
+ - `useRouter().push("/users")` navigates to `/admin/users`
157
+ - Route names stay unprefixed (`"home"`, not `"admin.home"`)
158
+
159
+ Note: `href()` is a raw path helper and does **not** auto-prefix with basename.
160
+ Use `reverse()` or `<Link>` for basename-aware URLs.
161
+
127
162
  ## Using the Request Handler
128
163
 
129
164
  The router provides a `fetch` method to handle RSC requests:
@@ -290,6 +325,56 @@ const router = createRouter({
290
325
  export default router;
291
326
  ```
292
327
 
328
+ ## Not Found Handling
329
+
330
+ Two distinct 404 scenarios:
331
+
332
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
333
+
334
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
335
+
336
+ ```typescript
337
+ import { notFound } from "@rangojs/router";
338
+
339
+ // In a handler or loader
340
+ path("/product/:slug", async (ctx) => {
341
+ const product = await db.getProduct(ctx.params.slug);
342
+ if (!product) notFound("Product not found");
343
+ return <ProductPage product={product} />;
344
+ });
345
+ ```
346
+
347
+ ### Fallback chain for `notFound()`
348
+
349
+ When `notFound()` is thrown, the router looks for a fallback in this order:
350
+
351
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
352
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
353
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
354
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
355
+
356
+ All cases set HTTP 404 status.
357
+
358
+ ### notFoundBoundary
359
+
360
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
361
+
362
+ ```typescript
363
+ urls(({ path, layout }) => [
364
+ layout(ShopLayout, () => [
365
+ notFoundBoundary(({ notFound: info }) => (
366
+ <div>
367
+ <h1>Not Found</h1>
368
+ <p>{info.message}</p>
369
+ </div>
370
+ )),
371
+ path("/product/:slug", ProductPage),
372
+ ]),
373
+ ]);
374
+ ```
375
+
376
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
377
+
293
378
  ## Including Sub-patterns
294
379
 
295
380
  ```typescript
@@ -369,8 +369,18 @@ interface PaginationData {
369
369
  perPage: number;
370
370
  }
371
371
  export const Pagination = createVar<PaginationData>();
372
+
373
+ // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
374
+ const Session = createVar<SessionData>({ cache: false });
372
375
  ```
373
376
 
377
+ `createVar` accepts an optional options object. The `cache` option (default
378
+ `true`) controls whether the var's values can be read inside cache scopes.
379
+ Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
380
+ marks a specific write as non-cacheable even if the var itself is cacheable.
381
+ "Least cacheable wins" — if either says `cache: false`, the value throws on
382
+ read inside `cache()` or `"use cache"`.
383
+
374
384
  ### Producer (handler or middleware)
375
385
 
376
386
  ```typescript
package/src/__internal.ts CHANGED
@@ -225,7 +225,7 @@ export type {
225
225
  * @internal
226
226
  * Type guard for prerender handler definitions.
227
227
  */
228
- export { isPrerenderHandler } from "./prerender.js";
228
+ export { isPrerenderHandler, isPassthroughHandler } from "./prerender.js";
229
229
 
230
230
  /**
231
231
  * @internal
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mutable app version — updated after HMR revalidation.
3
+ * Read by prefetch, navigation, and context code.
4
+ */
5
+
6
+ let currentVersion: string | undefined;
7
+
8
+ export function getAppVersion(): string | undefined {
9
+ return currentVersion;
10
+ }
11
+
12
+ export function setAppVersion(version: string | undefined): void {
13
+ currentVersion = version;
14
+ }
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
79
79
  state: "idle" | "loading";
80
80
  /** Whether any operation is streaming */
81
81
  isStreaming: boolean;
82
+ /** Whether a navigation is active (fetching or streaming, before commit) */
83
+ isNavigating: boolean;
82
84
  /** Current committed location */
83
85
  location: NavigationLocation;
84
86
  /** URL being navigated to (null if idle) */
@@ -389,6 +391,9 @@ export function createEventController(
389
391
  return {
390
392
  state,
391
393
  isStreaming,
394
+ // True when a navigation is active (fetching or streaming, before
395
+ // commit). Broader than pendingUrl which clears during streaming.
396
+ isNavigating: currentNavigation !== null,
392
397
  location,
393
398
  // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
399
  // Background revalidations (skipLoadingState) don't expose a pending URL.
@@ -4,6 +4,7 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
7
8
  import * as React from "react";
8
9
  import { startTransition } from "react";
9
10
  import {
@@ -40,11 +41,6 @@ if (typeof Symbol.dispose === "undefined") {
40
41
  (Symbol as any).dispose = Symbol("Symbol.dispose");
41
42
  }
42
43
 
43
- /** Get IDs of non-loader segments (layouts, routes, parallels). */
44
- function getNonLoaderSegmentIds(segments: ResolvedSegment[]): string[] {
45
- return segments.filter((s) => s.type !== "loader").map((s) => s.id);
46
- }
47
-
48
44
  export { createNavigationTransaction };
49
45
 
50
46
  /**
@@ -72,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
72
68
  export function createNavigationBridge(
73
69
  config: NavigationBridgeConfigWithController,
74
70
  ): NavigationBridge {
75
- const { store, client, eventController, onUpdate, renderSegments, version } =
76
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
77
73
 
78
74
  // Create shared partial updater
79
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -81,7 +77,7 @@ export function createNavigationBridge(
81
77
  client,
82
78
  onUpdate,
83
79
  renderSegments,
84
- version,
80
+ getVersion: () => version,
85
81
  });
86
82
 
87
83
  return {
@@ -265,18 +261,24 @@ export function createNavigationBridge(
265
261
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
266
262
  // 3. when leaving intercept - we need fresh non-intercept segments from server
267
263
  // 4. redirect-with-state - force re-render so hooks read fresh state
264
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
268
265
  const hasUsableCache =
269
266
  cachedSegments &&
270
267
  cachedSegments.length > 0 &&
271
268
  !isInterceptOnlyCache(cachedSegments) &&
272
269
  !hasInterceptCache &&
273
270
  !isLeavingIntercept &&
271
+ !cached?.stale &&
274
272
  !options?._skipCache;
275
273
 
274
+ // Forward navigations always await fetchPartialUpdate before rendering,
275
+ // so useNavigation should always report "loading". skipLoadingState is
276
+ // only used for popstate background revalidation (line ~526) where
277
+ // cached content renders instantly without a network wait.
276
278
  const tx = createNavigationTransaction(store, eventController, url, {
277
279
  ...options,
278
280
  state: resolvedState,
279
- skipLoadingState: hasUsableCache,
281
+ skipLoadingState: false,
280
282
  });
281
283
 
282
284
  // REVALIDATE: Fetch fresh data from server
@@ -284,7 +286,7 @@ export function createNavigationBridge(
284
286
  await fetchPartialUpdate(
285
287
  url,
286
288
  hasUsableCache
287
- ? getNonLoaderSegmentIds(cachedSegments!)
289
+ ? cachedSegments!.map((s) => s.id)
288
290
  : options?._skipCache
289
291
  ? [] // Action redirect: send no segments so server renders everything fresh
290
292
  : undefined,
@@ -416,6 +418,15 @@ export function createNavigationBridge(
416
418
  eventController.abortAllActions();
417
419
  }
418
420
 
421
+ // Popstate that exits an intercept to a non-intercept destination. The
422
+ // fallback fetch path below needs `leave-intercept` mode so it filters
423
+ // the cached @modal segment from the request and forces a re-render —
424
+ // otherwise a cache-miss popstate whose server response has an empty
425
+ // diff hits the "no changes" branch in partial-update and the modal
426
+ // stays on screen.
427
+ const isLeavingIntercept =
428
+ !isIntercept && currentInterceptSource !== null;
429
+
419
430
  // Compute history key from URL (with intercept suffix if applicable)
420
431
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
421
432
 
@@ -452,6 +463,12 @@ export function createNavigationBridge(
452
463
  store.setCurrentUrl(url);
453
464
  store.setPath(new URL(url).pathname);
454
465
 
466
+ // Restore router identity from cache so subsequent navigations
467
+ // don't falsely detect an app switch.
468
+ if (cached?.routerId) {
469
+ store.setRouterId?.(cached.routerId);
470
+ }
471
+
455
472
  // Render from cache - force await to skip loading fallbacks
456
473
  try {
457
474
  const root = await renderSegments(cachedSegments, {
@@ -477,6 +494,7 @@ export function createNavigationBridge(
477
494
  cachedHandleData,
478
495
  params: cachedParams,
479
496
  },
497
+ scroll: { restore: true, isStreaming },
480
498
  };
481
499
  const hasTransition = cachedSegments.some((s) => s.transition);
482
500
  if (hasTransition) {
@@ -490,14 +508,11 @@ export function createNavigationBridge(
490
508
  onUpdate(popstateUpdate);
491
509
  }
492
510
 
493
- // Restore scroll position for back/forward navigation
494
- handleNavigationEnd({ restore: true, isStreaming });
495
-
496
511
  // SWR: If stale, trigger background revalidation
497
512
  if (isStale) {
498
513
  debugLog("[Browser] Cache is stale, background revalidating...");
499
514
  // Background revalidation - don't await, just fire and forget
500
- const segmentIds = getNonLoaderSegmentIds(cachedSegments);
515
+ const segmentIds = cachedSegments.map((s) => s.id);
501
516
 
502
517
  const tx = createNavigationTransaction(
503
518
  store,
@@ -562,7 +577,11 @@ export function createNavigationBridge(
562
577
  intercept: isIntercept,
563
578
  interceptSourceUrl,
564
579
  }),
565
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
580
+ isIntercept
581
+ ? { type: "navigate", interceptSourceUrl }
582
+ : isLeavingIntercept
583
+ ? { type: "leave-intercept" }
584
+ : undefined,
566
585
  );
567
586
  // Restore scroll position after fetch completes
568
587
  handleNavigationEnd({ restore: true, isStreaming });
@@ -639,6 +658,12 @@ export function createNavigationBridge(
639
658
  window.removeEventListener("pageshow", handlePageShow);
640
659
  };
641
660
  },
661
+
662
+ updateVersion(newVersion: string): void {
663
+ version = newVersion;
664
+ setAppVersion(newVersion);
665
+ store.clearHistoryCache();
666
+ },
642
667
  };
643
668
  }
644
669
 
@@ -17,7 +17,11 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
- import { buildPrefetchKey, consumePrefetch } from "./prefetch/cache.js";
20
+ import {
21
+ buildPrefetchKey,
22
+ consumeInflightPrefetch,
23
+ consumePrefetch,
24
+ } from "./prefetch/cache.js";
21
25
 
22
26
  /**
23
27
  * Create a navigation client for fetching RSC payloads
@@ -57,6 +61,7 @@ export function createNavigationClient(
57
61
  staleRevalidation,
58
62
  interceptSourceUrl,
59
63
  version,
64
+ routerId,
60
65
  hmr,
61
66
  } = options;
62
67
 
@@ -84,50 +89,107 @@ export function createNavigationClient(
84
89
  if (version) {
85
90
  fetchUrl.searchParams.set("_rsc_v", version);
86
91
  }
92
+ if (routerId) {
93
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
94
+ }
87
95
 
88
- // Check in-memory prefetch cache before making a network request.
96
+ // Check completed in-memory prefetch cache before making a network request.
89
97
  // The cache key includes the source URL (previousUrl) because the
90
98
  // server's diff response depends on the source page context.
91
99
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
92
100
  // fresh modules), and intercept contexts (source-dependent responses).
101
+ //
102
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
93
103
  const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
94
- const cachedResponse =
95
- !staleRevalidation && !hmr && !interceptSourceUrl
96
- ? consumePrefetch(cacheKey)
97
- : null;
104
+ // Wildcard key matches prefetch entries stored with a custom prefetchKey
105
+ // (Link's prefetchKey prop stores under "*" instead of the source URL).
106
+ const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
107
+
108
+ let cachedResponse: Response | null = null;
109
+ let hitKey: string | null = null;
110
+ if (canUsePrefetch) {
111
+ cachedResponse = consumePrefetch(cacheKey);
112
+ if (cachedResponse) {
113
+ hitKey = cacheKey;
114
+ } else {
115
+ cachedResponse = consumePrefetch(wildcardKey);
116
+ if (cachedResponse) hitKey = wildcardKey;
117
+ }
118
+ }
98
119
 
120
+ let inflightResponsePromise: Promise<Response | null> | null = null;
121
+ if (canUsePrefetch && !cachedResponse) {
122
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
123
+ if (inflightResponsePromise) {
124
+ hitKey = cacheKey;
125
+ } else {
126
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
127
+ if (inflightResponsePromise) hitKey = wildcardKey;
128
+ }
129
+ }
99
130
  // Track when the stream completes
100
131
  let resolveStreamComplete: () => void;
101
132
  const streamComplete = new Promise<void>((resolve) => {
102
133
  resolveStreamComplete = resolve;
103
134
  });
104
135
 
105
- let responsePromise: Promise<Response>;
136
+ /**
137
+ * Validate RSC control headers on any response (fresh, cached, or
138
+ * in-flight). Handles version-mismatch reloads and server redirects.
139
+ * Returns the response unchanged when no control header is present.
140
+ */
141
+ const validateRscHeaders = (
142
+ response: Response,
143
+ source: string,
144
+ ): Response | Promise<Response> => {
145
+ // Version mismatch — server wants a full page reload
146
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
147
+ if (reload === "blocked") {
148
+ resolveStreamComplete();
149
+ return emptyResponse();
150
+ }
151
+ if (reload) {
152
+ if (tx) {
153
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
154
+ reloadUrl: reload.url,
155
+ });
156
+ }
157
+ window.location.href = reload.url;
158
+ // Block further processing — page is reloading
159
+ return new Promise<Response>(() => {});
160
+ }
106
161
 
107
- if (cachedResponse) {
108
- if (tx) {
109
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
162
+ // Server-side redirect without state: the server returned 204 with
163
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
164
+ // to a URL rendering full HTML). Throw ServerRedirect so the
165
+ // navigation bridge catches it and re-navigates with _skipCache.
166
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
167
+ if (redirect === "blocked") {
168
+ resolveStreamComplete();
169
+ return emptyResponse();
110
170
  }
111
- // Cached response body is already fully buffered (arrayBuffer),
112
- // so stream completion is immediate.
113
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
114
- return teeWithCompletion(
115
- response,
116
- () => {
117
- if (tx) browserDebugLog(tx, "stream complete (from cache)");
118
- resolveStreamComplete();
119
- },
120
- signal,
121
- );
122
- });
123
- } else {
171
+ if (redirect) {
172
+ if (tx) {
173
+ browserDebugLog(tx, `server redirect (${source})`, {
174
+ redirectUrl: redirect.url,
175
+ });
176
+ }
177
+ resolveStreamComplete();
178
+ throw new ServerRedirect(redirect.url, undefined);
179
+ }
180
+
181
+ return response;
182
+ };
183
+
184
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
185
+ const doFreshFetch = (): Promise<Response> => {
124
186
  if (tx) {
125
187
  browserDebugLog(tx, "fetching", {
126
188
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
127
189
  });
128
190
  }
129
191
 
130
- responsePromise = fetch(fetchUrl, {
192
+ return fetch(fetchUrl, {
131
193
  headers: {
132
194
  "X-RSC-Router-Client-Path": previousUrl,
133
195
  "X-Rango-State": getRangoState(),
@@ -139,55 +201,78 @@ export function createNavigationClient(
139
201
  },
140
202
  signal,
141
203
  }).then((response) => {
142
- // Check for version mismatch - server wants us to reload
143
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
144
- if (reload === "blocked") {
145
- resolveStreamComplete();
146
- return emptyResponse();
147
- }
148
- if (reload) {
149
- if (tx) {
150
- browserDebugLog(tx, "version mismatch, reloading", {
151
- reloadUrl: reload.url,
152
- });
153
- }
154
- window.location.href = reload.url;
155
- return new Promise<Response>(() => {});
156
- }
204
+ const validated = validateRscHeaders(response, "fetch");
205
+ if (validated instanceof Promise) return validated;
157
206
 
158
- // Server-side redirect without state: the server returned 204 with
159
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
160
- // to a URL rendering full HTML). Throw ServerRedirect so the
161
- // navigation bridge catches it and re-navigates with _skipCache.
162
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
163
- if (redirect === "blocked") {
164
- resolveStreamComplete();
165
- return emptyResponse();
166
- }
167
- if (redirect) {
207
+ return teeWithCompletion(
208
+ validated,
209
+ () => {
210
+ if (tx) browserDebugLog(tx, "stream complete");
211
+ resolveStreamComplete();
212
+ },
213
+ signal,
214
+ );
215
+ });
216
+ };
217
+
218
+ let responsePromise: Promise<Response>;
219
+
220
+ if (cachedResponse) {
221
+ if (tx) {
222
+ browserDebugLog(tx, "prefetch cache hit", {
223
+ key: hitKey,
224
+ wildcard: hitKey === wildcardKey,
225
+ });
226
+ }
227
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
228
+ const validated = validateRscHeaders(response, "prefetch cache");
229
+ if (validated instanceof Promise) return validated;
230
+
231
+ return teeWithCompletion(
232
+ validated,
233
+ () => {
234
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
235
+ resolveStreamComplete();
236
+ },
237
+ signal,
238
+ );
239
+ });
240
+ } else if (inflightResponsePromise) {
241
+ if (tx) {
242
+ browserDebugLog(tx, "reusing inflight prefetch", {
243
+ key: hitKey,
244
+ wildcard: hitKey === wildcardKey,
245
+ });
246
+ }
247
+ responsePromise = inflightResponsePromise.then(async (response) => {
248
+ if (!response) {
168
249
  if (tx) {
169
- browserDebugLog(tx, "server redirect", {
170
- redirectUrl: redirect.url,
171
- });
250
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
172
251
  }
173
- resolveStreamComplete();
174
- throw new ServerRedirect(redirect.url, undefined);
252
+ return doFreshFetch();
175
253
  }
176
254
 
255
+ const validated = validateRscHeaders(response, "inflight prefetch");
256
+ if (validated instanceof Promise) return validated;
257
+
177
258
  return teeWithCompletion(
178
- response,
259
+ validated,
179
260
  () => {
180
- if (tx) browserDebugLog(tx, "stream complete");
261
+ if (tx) {
262
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
263
+ }
181
264
  resolveStreamComplete();
182
265
  },
183
266
  signal,
184
267
  );
185
268
  });
269
+ } else {
270
+ responsePromise = doFreshFetch();
186
271
  }
187
272
 
188
273
  try {
189
- // Deserialize RSC payload
190
274
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
275
+
191
276
  if (tx) {
192
277
  browserDebugLog(tx, "response received", {
193
278
  isPartial: payload.metadata?.isPartial,