@rangojs/router 0.0.0-experimental.ea6d5eec → 0.0.0-experimental.ede38110

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 (142) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +719 -240
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +32 -0
  6. package/skills/caching/SKILL.md +8 -0
  7. package/skills/handler-use/SKILL.md +362 -0
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +3 -1
  11. package/skills/loader/SKILL.md +53 -43
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +764 -0
  15. package/skills/parallel/SKILL.md +185 -0
  16. package/skills/prerender/SKILL.md +110 -68
  17. package/skills/rango/SKILL.md +24 -22
  18. package/skills/route/SKILL.md +55 -0
  19. package/skills/router-setup/SKILL.md +87 -2
  20. package/skills/typesafety/SKILL.md +10 -0
  21. package/src/__internal.ts +1 -1
  22. package/src/browser/app-version.ts +14 -0
  23. package/src/browser/event-controller.ts +5 -0
  24. package/src/browser/navigation-bridge.ts +37 -5
  25. package/src/browser/navigation-client.ts +107 -75
  26. package/src/browser/navigation-store.ts +43 -8
  27. package/src/browser/partial-update.ts +51 -6
  28. package/src/browser/prefetch/cache.ts +22 -12
  29. package/src/browser/prefetch/fetch.ts +81 -20
  30. package/src/browser/prefetch/queue.ts +61 -29
  31. package/src/browser/prefetch/resource-ready.ts +77 -0
  32. package/src/browser/react/Link.tsx +67 -8
  33. package/src/browser/react/NavigationProvider.tsx +13 -4
  34. package/src/browser/react/context.ts +7 -2
  35. package/src/browser/react/use-handle.ts +9 -58
  36. package/src/browser/react/use-navigation.ts +11 -10
  37. package/src/browser/react/use-router.ts +21 -8
  38. package/src/browser/rsc-router.tsx +45 -3
  39. package/src/browser/scroll-restoration.ts +10 -8
  40. package/src/browser/segment-reconciler.ts +36 -9
  41. package/src/browser/server-action-bridge.ts +8 -6
  42. package/src/browser/types.ts +27 -5
  43. package/src/build/generate-manifest.ts +6 -6
  44. package/src/build/generate-route-types.ts +3 -0
  45. package/src/build/route-trie.ts +50 -24
  46. package/src/build/route-types/include-resolution.ts +8 -1
  47. package/src/build/route-types/router-processing.ts +211 -72
  48. package/src/build/route-types/scan-filter.ts +8 -1
  49. package/src/cache/cache-runtime.ts +15 -11
  50. package/src/cache/cache-scope.ts +46 -5
  51. package/src/cache/document-cache.ts +17 -7
  52. package/src/cache/taint.ts +55 -0
  53. package/src/client.tsx +84 -230
  54. package/src/context-var.ts +72 -2
  55. package/src/debug.ts +2 -2
  56. package/src/handle.ts +40 -0
  57. package/src/index.rsc.ts +3 -1
  58. package/src/index.ts +46 -6
  59. package/src/prerender/store.ts +5 -4
  60. package/src/prerender.ts +138 -77
  61. package/src/reverse.ts +25 -1
  62. package/src/route-definition/dsl-helpers.ts +224 -37
  63. package/src/route-definition/helpers-types.ts +67 -19
  64. package/src/route-definition/index.ts +3 -0
  65. package/src/route-definition/redirect.ts +9 -1
  66. package/src/route-definition/resolve-handler-use.ts +149 -0
  67. package/src/route-types.ts +18 -0
  68. package/src/router/content-negotiation.ts +100 -1
  69. package/src/router/handler-context.ts +82 -23
  70. package/src/router/intercept-resolution.ts +9 -4
  71. package/src/router/lazy-includes.ts +7 -6
  72. package/src/router/loader-resolution.ts +156 -21
  73. package/src/router/logging.ts +1 -1
  74. package/src/router/manifest.ts +28 -15
  75. package/src/router/match-api.ts +124 -189
  76. package/src/router/match-middleware/background-revalidation.ts +30 -2
  77. package/src/router/match-middleware/cache-lookup.ts +94 -17
  78. package/src/router/match-middleware/cache-store.ts +53 -10
  79. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  80. package/src/router/match-middleware/segment-resolution.ts +60 -5
  81. package/src/router/match-result.ts +104 -10
  82. package/src/router/metrics.ts +6 -1
  83. package/src/router/middleware-types.ts +6 -8
  84. package/src/router/middleware.ts +2 -5
  85. package/src/router/navigation-snapshot.ts +182 -0
  86. package/src/router/prerender-match.ts +110 -10
  87. package/src/router/preview-match.ts +30 -102
  88. package/src/router/request-classification.ts +310 -0
  89. package/src/router/route-snapshot.ts +245 -0
  90. package/src/router/router-context.ts +1 -0
  91. package/src/router/router-interfaces.ts +36 -4
  92. package/src/router/router-options.ts +37 -11
  93. package/src/router/segment-resolution/fresh.ts +198 -20
  94. package/src/router/segment-resolution/helpers.ts +29 -24
  95. package/src/router/segment-resolution/loader-cache.ts +1 -0
  96. package/src/router/segment-resolution/revalidation.ts +433 -296
  97. package/src/router/types.ts +1 -0
  98. package/src/router.ts +55 -6
  99. package/src/rsc/handler.ts +472 -372
  100. package/src/rsc/loader-fetch.ts +23 -3
  101. package/src/rsc/manifest-init.ts +5 -1
  102. package/src/rsc/progressive-enhancement.ts +14 -2
  103. package/src/rsc/rsc-rendering.ts +10 -1
  104. package/src/rsc/server-action.ts +8 -0
  105. package/src/rsc/ssr-setup.ts +2 -2
  106. package/src/rsc/types.ts +9 -1
  107. package/src/segment-content-promise.ts +67 -0
  108. package/src/segment-loader-promise.ts +122 -0
  109. package/src/segment-system.tsx +109 -23
  110. package/src/server/context.ts +166 -17
  111. package/src/server/handle-store.ts +19 -0
  112. package/src/server/loader-registry.ts +9 -8
  113. package/src/server/request-context.ts +175 -15
  114. package/src/ssr/index.tsx +4 -0
  115. package/src/static-handler.ts +18 -6
  116. package/src/types/cache-types.ts +4 -4
  117. package/src/types/handler-context.ts +137 -33
  118. package/src/types/loader-types.ts +36 -9
  119. package/src/types/route-entry.ts +12 -1
  120. package/src/types/segments.ts +2 -0
  121. package/src/urls/include-helper.ts +24 -14
  122. package/src/urls/path-helper-types.ts +39 -6
  123. package/src/urls/path-helper.ts +48 -13
  124. package/src/urls/pattern-types.ts +12 -0
  125. package/src/urls/response-types.ts +16 -6
  126. package/src/use-loader.tsx +77 -5
  127. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  128. package/src/vite/discovery/discover-routers.ts +5 -1
  129. package/src/vite/discovery/prerender-collection.ts +128 -74
  130. package/src/vite/discovery/state.ts +13 -4
  131. package/src/vite/index.ts +4 -0
  132. package/src/vite/plugin-types.ts +60 -5
  133. package/src/vite/plugins/expose-id-utils.ts +12 -0
  134. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  135. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  136. package/src/vite/plugins/performance-tracks.ts +88 -0
  137. package/src/vite/plugins/refresh-cmd.ts +88 -26
  138. package/src/vite/rango.ts +19 -2
  139. package/src/vite/router-discovery.ts +178 -37
  140. package/src/vite/utils/banner.ts +3 -3
  141. package/src/vite/utils/prerender-utils.ts +37 -5
  142. 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 {
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
68
  export function createNavigationBridge(
68
69
  config: NavigationBridgeConfigWithController,
69
70
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
72
73
 
73
74
  // Create shared partial updater
74
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +77,7 @@ export function createNavigationBridge(
76
77
  client,
77
78
  onUpdate,
78
79
  renderSegments,
79
- version,
80
+ getVersion: () => version,
80
81
  });
81
82
 
82
83
  return {
@@ -260,18 +261,24 @@ export function createNavigationBridge(
260
261
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
261
262
  // 3. when leaving intercept - we need fresh non-intercept segments from server
262
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
263
265
  const hasUsableCache =
264
266
  cachedSegments &&
265
267
  cachedSegments.length > 0 &&
266
268
  !isInterceptOnlyCache(cachedSegments) &&
267
269
  !hasInterceptCache &&
268
270
  !isLeavingIntercept &&
271
+ !cached?.stale &&
269
272
  !options?._skipCache;
270
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.
271
278
  const tx = createNavigationTransaction(store, eventController, url, {
272
279
  ...options,
273
280
  state: resolvedState,
274
- skipLoadingState: hasUsableCache,
281
+ skipLoadingState: false,
275
282
  });
276
283
 
277
284
  // REVALIDATE: Fetch fresh data from server
@@ -411,6 +418,15 @@ export function createNavigationBridge(
411
418
  eventController.abortAllActions();
412
419
  }
413
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
+
414
430
  // Compute history key from URL (with intercept suffix if applicable)
415
431
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
416
432
 
@@ -447,6 +463,12 @@ export function createNavigationBridge(
447
463
  store.setCurrentUrl(url);
448
464
  store.setPath(new URL(url).pathname);
449
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
+
450
472
  // Render from cache - force await to skip loading fallbacks
451
473
  try {
452
474
  const root = await renderSegments(cachedSegments, {
@@ -555,7 +577,11 @@ export function createNavigationBridge(
555
577
  intercept: isIntercept,
556
578
  interceptSourceUrl,
557
579
  }),
558
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
580
+ isIntercept
581
+ ? { type: "navigate", interceptSourceUrl }
582
+ : isLeavingIntercept
583
+ ? { type: "leave-intercept" }
584
+ : undefined,
559
585
  );
560
586
  // Restore scroll position after fetch completes
561
587
  handleNavigationEnd({ restore: true, isStreaming });
@@ -632,6 +658,12 @@ export function createNavigationBridge(
632
658
  window.removeEventListener("pageshow", handlePageShow);
633
659
  };
634
660
  },
661
+
662
+ updateVersion(newVersion: string): void {
663
+ version = newVersion;
664
+ setAppVersion(newVersion);
665
+ store.clearHistoryCache();
666
+ },
635
667
  };
636
668
  }
637
669
 
@@ -19,8 +19,8 @@ import {
19
19
  } from "./response-adapter.js";
20
20
  import {
21
21
  buildPrefetchKey,
22
- consumePrefetch,
23
22
  consumeInflightPrefetch,
23
+ consumePrefetch,
24
24
  } from "./prefetch/cache.js";
25
25
 
26
26
  /**
@@ -61,6 +61,7 @@ export function createNavigationClient(
61
61
  staleRevalidation,
62
62
  interceptSourceUrl,
63
63
  version,
64
+ routerId,
64
65
  hmr,
65
66
  } = options;
66
67
 
@@ -88,29 +89,98 @@ export function createNavigationClient(
88
89
  if (version) {
89
90
  fetchUrl.searchParams.set("_rsc_v", version);
90
91
  }
92
+ if (routerId) {
93
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
94
+ }
91
95
 
92
- // Check in-memory prefetch cache before making a network request.
96
+ // Check completed in-memory prefetch cache before making a network request.
93
97
  // The cache key includes the source URL (previousUrl) because the
94
98
  // server's diff response depends on the source page context.
95
99
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
96
100
  // fresh modules), and intercept contexts (source-dependent responses).
101
+ //
97
102
  const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
98
103
  const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
99
- const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
100
- // If no completed cache entry, check for in-flight prefetch.
101
- // This reuses a prefetch that is still downloading rather than
102
- // starting a duplicate request from scratch.
103
- const inflightPrefetch =
104
- !cachedResponse && canUsePrefetch
105
- ? consumeInflightPrefetch(cacheKey)
106
- : 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
+ }
107
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
+ }
108
130
  // Track when the stream completes
109
131
  let resolveStreamComplete: () => void;
110
132
  const streamComplete = new Promise<void>((resolve) => {
111
133
  resolveStreamComplete = resolve;
112
134
  });
113
135
 
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
+ }
161
+
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();
170
+ }
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
+
114
184
  /** Start a fresh navigation fetch (no cache / inflight hit). */
115
185
  const doFreshFetch = (): Promise<Response> => {
116
186
  if (tx) {
@@ -131,50 +201,11 @@ export function createNavigationClient(
131
201
  },
132
202
  signal,
133
203
  }).then((response) => {
134
- // Check for version mismatch - server wants us to reload
135
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
136
- if (reload === "blocked") {
137
- resolveStreamComplete();
138
- return emptyResponse();
139
- }
140
- if (reload) {
141
- if (tx) {
142
- browserDebugLog(tx, "version mismatch, reloading", {
143
- reloadUrl: reload.url,
144
- });
145
- }
146
- window.location.href = reload.url;
147
- return new Promise<Response>(() => {});
148
- }
149
-
150
- // Server-side redirect without state: the server returned 204 with
151
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
152
- // to a URL rendering full HTML). Throw ServerRedirect so the
153
- // navigation bridge catches it and re-navigates with _skipCache.
154
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
155
- if (redirect === "blocked") {
156
- resolveStreamComplete();
157
- return emptyResponse();
158
- }
159
- if (redirect) {
160
- if (tx) {
161
- browserDebugLog(tx, "server redirect", {
162
- redirectUrl: redirect.url,
163
- });
164
- }
165
- resolveStreamComplete();
166
- throw new ServerRedirect(redirect.url, undefined);
167
- }
168
-
169
- if (!response.ok) {
170
- resolveStreamComplete();
171
- throw new Error(
172
- `Partial RSC fetch failed: ${response.status} ${response.statusText}`,
173
- );
174
- }
204
+ const validated = validateRscHeaders(response, "fetch");
205
+ if (validated instanceof Promise) return validated;
175
206
 
176
207
  return teeWithCompletion(
177
- response,
208
+ validated,
178
209
  () => {
179
210
  if (tx) browserDebugLog(tx, "stream complete");
180
211
  resolveStreamComplete();
@@ -188,13 +219,17 @@ export function createNavigationClient(
188
219
 
189
220
  if (cachedResponse) {
190
221
  if (tx) {
191
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
222
+ browserDebugLog(tx, "prefetch cache hit", {
223
+ key: hitKey,
224
+ wildcard: hitKey === wildcardKey,
225
+ });
192
226
  }
193
- // Cached response body is already fully buffered (arrayBuffer),
194
- // so stream completion is immediate.
195
227
  responsePromise = Promise.resolve(cachedResponse).then((response) => {
228
+ const validated = validateRscHeaders(response, "prefetch cache");
229
+ if (validated instanceof Promise) return validated;
230
+
196
231
  return teeWithCompletion(
197
- response,
232
+ validated,
198
233
  () => {
199
234
  if (tx) browserDebugLog(tx, "stream complete (from cache)");
200
235
  resolveStreamComplete();
@@ -202,33 +237,30 @@ export function createNavigationClient(
202
237
  signal,
203
238
  );
204
239
  });
205
- } else if (inflightPrefetch) {
240
+ } else if (inflightResponsePromise) {
206
241
  if (tx) {
207
- browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
242
+ browserDebugLog(tx, "reusing inflight prefetch", {
243
+ key: hitKey,
244
+ wildcard: hitKey === wildcardKey,
245
+ });
208
246
  }
209
- // Await the in-flight prefetch. If it resolves with a Response,
210
- // use it like a cache hit. If it fails (null), fall back to
211
- // a fresh navigation fetch.
212
- responsePromise = inflightPrefetch.then((prefetchResponse) => {
213
- if (!prefetchResponse) {
247
+ responsePromise = inflightResponsePromise.then(async (response) => {
248
+ if (!response) {
214
249
  if (tx) {
215
- browserDebugLog(
216
- tx,
217
- "inflight prefetch failed, falling back to fetch",
218
- );
250
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
219
251
  }
220
252
  return doFreshFetch();
221
253
  }
222
- if (tx) {
223
- browserDebugLog(tx, "inflight prefetch resolved", {
224
- key: cacheKey,
225
- });
226
- }
254
+
255
+ const validated = validateRscHeaders(response, "inflight prefetch");
256
+ if (validated instanceof Promise) return validated;
257
+
227
258
  return teeWithCompletion(
228
- prefetchResponse,
259
+ validated,
229
260
  () => {
230
- if (tx)
261
+ if (tx) {
231
262
  browserDebugLog(tx, "stream complete (from inflight prefetch)");
263
+ }
232
264
  resolveStreamComplete();
233
265
  },
234
266
  signal,
@@ -239,8 +271,8 @@ export function createNavigationClient(
239
271
  }
240
272
 
241
273
  try {
242
- // Deserialize RSC payload
243
274
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
275
+
244
276
  if (tx) {
245
277
  browserDebugLog(tx, "response received", {
246
278
  isPartial: payload.metadata?.isPartial,