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

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 (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -7,7 +7,6 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
205
205
  // Complete the navigation in event controller (sets idle state, updates location)
206
206
  handle.complete(parsedUrl);
207
207
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
208
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
209
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
210
 
211
211
  debugLog(
212
212
  "[Browser] Navigation committed, historyKey:",
213
213
  historyKey,
214
214
  intercept ? "(intercept)" : "",
215
215
  );
216
+
217
+ return { scroll };
216
218
  }
217
219
 
218
220
  return {
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
263
265
  overrides?.state !== undefined ? overrides.state : opts.state;
264
266
  // Server-set location state: only from overrides (set by partial-update)
265
267
  const serverState = overrides?.serverState;
266
- commit({
268
+ return commit({
267
269
  ...opts,
268
270
  segmentIds,
269
271
  segments,
@@ -19,6 +19,14 @@ import type { BoundTransaction } from "./navigation-transaction.js";
19
19
  import { ServerRedirect } from "../errors.js";
20
20
  import { debugLog } from "./logging.js";
21
21
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
22
+ import type { NavigationUpdate } from "./types.js";
23
+
24
+ /** Build a scroll payload from the commit's scroll option */
25
+ function toScrollPayload(
26
+ scroll: boolean | undefined,
27
+ ): NonNullable<NavigationUpdate["scroll"]> {
28
+ return { enabled: scroll !== false ? scroll : false };
29
+ }
22
30
 
23
31
  /**
24
32
  * Configuration for creating a partial updater
@@ -31,8 +39,15 @@ export interface PartialUpdateConfig {
31
39
  segments: ResolvedSegment[],
32
40
  options?: RenderSegmentsOptions,
33
41
  ) => Promise<ReactNode> | ReactNode;
34
- /** RSC version received from server (from initial payload metadata) */
35
- version?: string;
42
+ /** RSC version getter returns the current version (may change after HMR) */
43
+ getVersion?: () => string | undefined;
44
+ /**
45
+ * Replace the active app-shell when a cross-app navigation is detected.
46
+ * Called before the full-update tree replacement renders, so the new
47
+ * payload's rootLayout, basename, and version are picked up. Theme,
48
+ * warmup, and prefetch TTL are not part of the shell — see AppShell.
49
+ */
50
+ applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
36
51
  }
37
52
 
38
53
  /**
@@ -96,7 +111,14 @@ export type PartialUpdater = (
96
111
  export function createPartialUpdater(
97
112
  config: PartialUpdateConfig,
98
113
  ): PartialUpdater {
99
- const { store, client, onUpdate, renderSegments, version } = config;
114
+ const {
115
+ store,
116
+ client,
117
+ onUpdate,
118
+ renderSegments,
119
+ getVersion = () => undefined,
120
+ applyAppShell,
121
+ } = config;
100
122
 
101
123
  /**
102
124
  * Get current page's cached segments as an array
@@ -153,9 +175,16 @@ export function createPartialUpdater(
153
175
  segments = segmentIds ?? segmentState.currentSegmentIds;
154
176
  }
155
177
 
156
- // For intercept revalidation, use the intercept source URL as previousUrl
178
+ // For intercept revalidation, use the intercept source URL as previousUrl.
179
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
180
+ // creation, which on popstate is already the destination URL and would
181
+ // tell the server "from == to". segmentState.currentUrl still points at
182
+ // the URL the cached segments render (the intercept URL), which is the
183
+ // correct "from" for the server's diff computation.
157
184
  const previousUrl =
158
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
185
+ mode.type === "leave-intercept"
186
+ ? segmentState.currentUrl || tx.currentUrl
187
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
159
188
 
160
189
  debugLog(`\n[Browser] >>> NAVIGATION`);
161
190
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -174,6 +203,11 @@ export function createPartialUpdater(
174
203
  targetCache && targetCache.length > 0
175
204
  ? targetCache
176
205
  : getCurrentCachedSegments();
206
+ const cachedSegsSource =
207
+ targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
208
+ debugLog(
209
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
210
+ );
177
211
 
178
212
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
179
213
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -185,7 +219,8 @@ export function createPartialUpdater(
185
219
  // (action redirect sends empty segments for a fresh render).
186
220
  staleRevalidation:
187
221
  mode.type === "stale-revalidation" || segments.length === 0,
188
- version,
222
+ version: getVersion(),
223
+ routerId: store.getRouterId?.(),
189
224
  });
190
225
  // Mark navigation as streaming (response received, now parsing RSC).
191
226
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -198,6 +233,32 @@ export function createPartialUpdater(
198
233
  streamingToken.end();
199
234
  });
200
235
 
236
+ // Detect app switch: if routerId changed, the navigation crossed into
237
+ // a different router (e.g., via host router path mount). Downgrade
238
+ // partial to full so the entire tree is replaced without reconciliation
239
+ // against stale segments from the previous app, and replace the app
240
+ // shell (rootLayout, basename, version) so the target app's document
241
+ // and router config take effect instead of remaining captured from the
242
+ // initial load. Theme, warmup, and prefetch TTL are intentionally
243
+ // document-lifetime (see AppShell doc); a new document navigation
244
+ // applies them.
245
+ if (payload.metadata?.routerId) {
246
+ const prevRouterId = store.getRouterId?.();
247
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
248
+ debugLog(
249
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
250
+ );
251
+ payload.metadata.isPartial = false;
252
+ applyAppShell?.({
253
+ routerId: payload.metadata.routerId,
254
+ rootLayout: payload.metadata.rootLayout,
255
+ basename: payload.metadata.basename,
256
+ version: payload.metadata.version,
257
+ });
258
+ }
259
+ store.setRouterId?.(payload.metadata.routerId);
260
+ }
261
+
201
262
  // Handle server-side redirect with state
202
263
  if (payload.metadata?.redirect) {
203
264
  if (signal?.aborted) {
@@ -246,7 +307,21 @@ export function createPartialUpdater(
246
307
  forceAwait: true,
247
308
  });
248
309
 
249
- tx.commit(matchedIds, existingSegments);
310
+ const { scroll: commitScroll } = tx.commit(
311
+ matchedIds,
312
+ existingSegments,
313
+ );
314
+
315
+ // tx.commit() cached the source page's handleData because
316
+ // eventController hasn't been updated yet. Overwrite with the
317
+ // correct cached handleData to prevent cache corruption on
318
+ // subsequent navigations to this same URL.
319
+ if (mode.targetCacheHandleData) {
320
+ store.updateCacheHandleData(
321
+ store.getHistoryKey(),
322
+ mode.targetCacheHandleData,
323
+ );
324
+ }
250
325
 
251
326
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
327
  // breadcrumbs and other handle data from cache.
@@ -260,6 +335,7 @@ export function createPartialUpdater(
260
335
  ...metadataWithoutHandles,
261
336
  cachedHandleData: mode.targetCacheHandleData,
262
337
  },
338
+ scroll: toScrollPayload(commitScroll),
263
339
  };
264
340
 
265
341
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +366,15 @@ export function createPartialUpdater(
290
366
  forceAwait: true,
291
367
  });
292
368
 
293
- tx.commit(matchedIds, existingSegments);
369
+ const { scroll: leaveScroll } = tx.commit(
370
+ matchedIds,
371
+ existingSegments,
372
+ );
294
373
 
295
374
  onUpdate({
296
375
  root: newTree,
297
376
  metadata: payload.metadata,
377
+ scroll: toScrollPayload(leaveScroll),
298
378
  });
299
379
 
300
380
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -411,8 +491,10 @@ export function createPartialUpdater(
411
491
  }
412
492
  }
413
493
 
414
- // Commit navigation - transaction handles all store mutations atomically
415
- const allSegmentIds = reconciled.segments.map((s) => s.id);
494
+ // Commit navigation - use server's matched as the authoritative segment ID list.
495
+ // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
496
+ // but the server's matched always includes all expected segment IDs.
497
+ const allSegmentIds = matchedIds;
416
498
  const serverLocationState = payload.metadata?.locationState;
417
499
  const overrides: CommitOverrides | undefined = isInterceptResponse
418
500
  ? {
@@ -424,7 +506,11 @@ export function createPartialUpdater(
424
506
  : serverLocationState
425
507
  ? { serverState: serverLocationState }
426
508
  : undefined;
427
- tx.commit(allSegmentIds, reconciled.segments, overrides);
509
+ const { scroll: navScroll } = tx.commit(
510
+ allSegmentIds,
511
+ reconciled.segments,
512
+ overrides,
513
+ );
428
514
 
429
515
  // For stale revalidation: verify history key hasn't changed before updating UI
430
516
  if (mode.type === "stale-revalidation") {
@@ -439,8 +525,10 @@ export function createPartialUpdater(
439
525
 
440
526
  debugLog("[partial-update] updating document");
441
527
 
442
- // Emit update to trigger React render
528
+ // Emit update to trigger React render.
529
+ // Scroll info is included so NavigationProvider applies it after React commits.
443
530
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
531
+ const scrollPayload = toScrollPayload(navScroll);
444
532
 
445
533
  if (mode.type === "action" || mode.type === "stale-revalidation") {
446
534
  startTransition(() => {
@@ -450,6 +538,7 @@ export function createPartialUpdater(
450
538
  onUpdate({
451
539
  root: newTree,
452
540
  metadata: payload.metadata!,
541
+ scroll: scrollPayload,
453
542
  });
454
543
  });
455
544
  } else if (hasTransition) {
@@ -460,12 +549,14 @@ export function createPartialUpdater(
460
549
  onUpdate({
461
550
  root: newTree,
462
551
  metadata: payload.metadata!,
552
+ scroll: scrollPayload,
463
553
  });
464
554
  });
465
555
  } else {
466
556
  onUpdate({
467
557
  root: newTree,
468
558
  metadata: payload.metadata!,
559
+ scroll: scrollPayload,
469
560
  });
470
561
  }
471
562
 
@@ -492,15 +583,16 @@ export function createPartialUpdater(
492
583
  }
493
584
 
494
585
  const fullUpdateServerState = payload.metadata?.locationState;
495
- if (fullUpdateServerState) {
496
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
497
- } else {
498
- tx.commit(segmentIds, segments);
499
- }
586
+ const { scroll: fullScroll } = fullUpdateServerState
587
+ ? tx.commit(segmentIds, segments, {
588
+ serverState: fullUpdateServerState,
589
+ })
590
+ : tx.commit(segmentIds, segments);
500
591
 
501
592
  const fullHasTransition = segments.some(
502
593
  (s: ResolvedSegment) => s.transition,
503
594
  );
595
+ const fullScrollPayload = toScrollPayload(fullScroll);
504
596
 
505
597
  if (mode.type === "stale-revalidation") {
506
598
  await rawStreamComplete;
@@ -511,6 +603,7 @@ export function createPartialUpdater(
511
603
  onUpdate({
512
604
  root: newTree,
513
605
  metadata: payload.metadata!,
606
+ scroll: fullScrollPayload,
514
607
  });
515
608
  });
516
609
  } else if (mode.type === "action") {
@@ -521,6 +614,7 @@ export function createPartialUpdater(
521
614
  onUpdate({
522
615
  root: newTree,
523
616
  metadata: payload.metadata!,
617
+ scroll: fullScrollPayload,
524
618
  });
525
619
  });
526
620
  } else if (fullHasTransition) {
@@ -531,12 +625,14 @@ export function createPartialUpdater(
531
625
  onUpdate({
532
626
  root: newTree,
533
627
  metadata: payload.metadata!,
628
+ scroll: fullScrollPayload,
534
629
  });
535
630
  });
536
631
  } else {
537
632
  onUpdate({
538
633
  root: newTree,
539
634
  metadata: payload.metadata!,
635
+ scroll: fullScrollPayload,
540
636
  });
541
637
  }
542
638
 
@@ -1,16 +1,34 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetch Response objects for instant cache hits
5
- * on subsequent navigation. Cache key is source-dependent (includes the
6
- * current page URL) because the server's diff-based response depends on
7
- * where the user navigates from.
4
+ * In-memory cache storing prefetched Response objects for instant cache hits
5
+ * on subsequent navigation. Two key scopes are in play:
6
+ * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)`
7
+ * shape `rangoState\0/target?...`. Shared across all source pages and
8
+ * invalidated automatically when Rango state bumps (deploy or
9
+ * server-action invalidation).
10
+ * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
11
+ * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
12
+ * (so rotation invalidates source-scoped entries too) plus the source
13
+ * href (so each originating page gets its own slot). Populated when the
14
+ * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
15
+ * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
16
+ * both cases so source-sensitive responses cannot bleed into navigations
17
+ * from other pages.
18
+ *
19
+ * Also tracks in-flight prefetch promises. Each promise resolves to the
20
+ * navigation branch of a tee'd Response, allowing navigation to adopt a
21
+ * still-downloading prefetch without reparsing or buffering the body. A
22
+ * single promise can be registered under multiple alias keys (see
23
+ * `setInflightPromiseWithAliases`) so same-source navigations adopt via
24
+ * their source key while cross-source ones fall through to the wildcard
25
+ * alias — with consume/clear atomically removing every alias.
8
26
  *
9
27
  * Replaces the previous browser HTTP cache approach which was unreliable
10
28
  * due to response draining race conditions and browser inconsistencies.
11
29
  */
12
30
 
13
- import { cancelAllPrefetches } from "./queue.js";
31
+ import { abortAllPrefetches } from "./queue.js";
14
32
  import { invalidateRangoState } from "../rango-state.js";
15
33
 
16
34
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
@@ -21,11 +39,19 @@ let cacheTTL = 300_000;
21
39
  /**
22
40
  * Initialize the prefetch cache with the configured TTL.
23
41
  * Called once at app startup with the value from server metadata.
24
- * A TTL of 0 disables the in-memory cache.
42
+ * A TTL of 0 disables the in-memory cache and all prefetching.
25
43
  */
26
44
  export function initPrefetchCache(ttlMs: number): void {
27
45
  cacheTTL = ttlMs;
28
46
  }
47
+
48
+ /**
49
+ * Check if the prefetch cache is disabled (TTL <= 0).
50
+ * When disabled, no prefetch requests should be issued.
51
+ */
52
+ export function isPrefetchCacheDisabled(): boolean {
53
+ return cacheTTL <= 0;
54
+ }
29
55
  const MAX_PREFETCH_CACHE_SIZE = 50;
30
56
 
31
57
  interface PrefetchCacheEntry {
@@ -36,19 +62,78 @@ interface PrefetchCacheEntry {
36
62
  const cache = new Map<string, PrefetchCacheEntry>();
37
63
  const inflight = new Set<string>();
38
64
 
65
+ /**
66
+ * In-flight promise map. When a prefetch fetch is in progress, its
67
+ * Promise<Response | null> is stored here so navigation can await
68
+ * it instead of starting a duplicate request.
69
+ */
70
+ const inflightPromises = new Map<string, Promise<Response | null>>();
71
+
72
+ /**
73
+ * Alias map for in-flight promises registered under multiple keys (see
74
+ * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
75
+ * that consuming or clearing any one key atomically removes every alias —
76
+ * guaranteeing a single consumer for the shared Response stream.
77
+ */
78
+ const inflightAliases = new Map<string, string[]>();
79
+
39
80
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
40
81
  // started before a clear carry a stale generation and must not store their
41
82
  // response (the data may be stale due to a server action invalidation).
42
83
  let generation = 0;
43
84
 
44
85
  /**
45
- * Build a source-dependent cache key.
46
- * Includes the source page href so the same target prefetched from
47
- * different pages gets separate entries the server response varies
48
- * based on the source page context (diff-based rendering).
86
+ * Build a cache key by combining a scope prefix with the target URL.
87
+ *
88
+ * Low-level primitive callers that want a specific scope should use
89
+ * one of:
90
+ * - Wildcard (source-agnostic): prefix is the Rango state value from
91
+ * `getRangoState()`. Shared across all source pages. Invalidated
92
+ * automatically when Rango state bumps (deploy or server-action).
93
+ * Key shape: `rangoState\0/target?...`.
94
+ * - Source-scoped: use `buildSourceKey()`. Key shape:
95
+ * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
96
+ * rotation invalidates source-scoped entries alongside wildcard ones,
97
+ * plus the source page href so the key is unique per originating page.
98
+ * Populated either when the server tags a response with
99
+ * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
100
+ * Link opts in via `prefetchKey=":source"`.
101
+ *
102
+ * The `_rsc_segments` query param that travels in the target URL means
103
+ * clients with different mounted segment trees naturally get different
104
+ * keys — so segment-level diffs remain consistent across both scopes.
105
+ */
106
+ export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
107
+ return prefix + "\0" + targetUrl.pathname + targetUrl.search;
108
+ }
109
+
110
+ /**
111
+ * Build a source-scoped cache key. Key shape:
112
+ * `rangoState\0sourceHref\0/target?...`.
113
+ *
114
+ * - `rangoState` is included so state rotation invalidates source-scoped
115
+ * entries alongside wildcard ones.
116
+ * - `sourceHref` makes the key unique per originating page.
49
117
  */
50
- export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
51
- return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
118
+ export function buildSourceKey(
119
+ rangoState: string,
120
+ sourceHref: string,
121
+ targetUrl: URL,
122
+ ): string {
123
+ return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
124
+ }
125
+
126
+ /**
127
+ * Walk an inflight key plus any sibling aliases registered via
128
+ * `setInflightPromiseWithAliases`, invoking `fn` for each.
129
+ */
130
+ function forEachAlias(key: string, fn: (k: string) => void): void {
131
+ const aliases = inflightAliases.get(key);
132
+ if (aliases) {
133
+ for (const k of aliases) fn(k);
134
+ } else {
135
+ fn(key);
136
+ }
52
137
  }
53
138
 
54
139
  /**
@@ -70,6 +155,9 @@ export function hasPrefetch(key: string): boolean {
70
155
  * Consume a cached prefetch response. Returns null if not found or expired.
71
156
  * One-time consumption: the entry is deleted after retrieval.
72
157
  * Returns null when caching is disabled (TTL <= 0).
158
+ *
159
+ * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
160
+ * for that (returns a Promise instead of a Response).
73
161
  */
74
162
  export function consumePrefetch(key: string): Response | null {
75
163
  if (cacheTTL <= 0) return null;
@@ -83,10 +171,39 @@ export function consumePrefetch(key: string): Response | null {
83
171
  return entry.response;
84
172
  }
85
173
 
174
+ /**
175
+ * Consume an in-flight prefetch promise. Returns null if no prefetch is
176
+ * in-flight for this key. The returned Promise resolves to the buffered
177
+ * Response (or null if the fetch failed/was aborted).
178
+ *
179
+ * One-time consumption: the promise entry is removed (along with any
180
+ * sibling aliases registered via `setInflightPromiseWithAliases`) so a
181
+ * second call on any alias returns null — only one caller can adopt the
182
+ * shared Response stream. The `inflight` set entry is intentionally
183
+ * kept so that `hasPrefetch()` continues to return true while the
184
+ * underlying fetch is still downloading — this prevents
185
+ * `prefetchDirect()` or other callers from starting a duplicate request
186
+ * during the handoff window. The inflight flag is cleaned up naturally
187
+ * by `clearPrefetchInflight()` in the fetch's `.finally()`.
188
+ */
189
+ export function consumeInflightPrefetch(
190
+ key: string,
191
+ ): Promise<Response | null> | null {
192
+ const promise = inflightPromises.get(key);
193
+ if (!promise) return null;
194
+ // Remove the promise under every alias so a second consumer cannot
195
+ // adopt the same stream and race on the body. `inflightAliases` is
196
+ // intentionally preserved — `clearPrefetchInflight()` in the fetch's
197
+ // `.finally()` still needs it to clear every inflight flag; deleting
198
+ // here would strand the sibling's flag forever.
199
+ forEachAlias(key, (k) => inflightPromises.delete(k));
200
+ return promise;
201
+ }
202
+
86
203
  /**
87
204
  * Store a prefetch response in the in-memory cache.
88
- * The response body must be fully buffered (e.g. via arrayBuffer()) before
89
- * storing, so the cached Response is self-contained and network-independent.
205
+ * The response should be a clone() of the original so the caller can
206
+ * still consume the body. The clone's body streams independently.
90
207
  *
91
208
  * Skips storage if the generation has changed since the fetch started
92
209
  * (a server action invalidated the cache mid-flight).
@@ -128,19 +245,70 @@ export function markPrefetchInflight(key: string): void {
128
245
  inflight.add(key);
129
246
  }
130
247
 
248
+ /**
249
+ * Store the in-flight Promise for a prefetch so navigation can reuse it.
250
+ */
251
+ export function setInflightPromise(
252
+ key: string,
253
+ promise: Promise<Response | null>,
254
+ ): void {
255
+ inflightPromises.set(key, promise);
256
+ }
257
+
258
+ /**
259
+ * Store the same in-flight Promise under multiple keys, recording them
260
+ * as sibling aliases. Consuming or clearing any one alias atomically
261
+ * removes every entry, guaranteeing the shared Response stream has a
262
+ * single consumer even when navigation looks up either key.
263
+ */
264
+ export function setInflightPromiseWithAliases(
265
+ keys: string[],
266
+ promise: Promise<Response | null>,
267
+ ): void {
268
+ for (const k of keys) {
269
+ inflightPromises.set(k, promise);
270
+ inflightAliases.set(k, keys);
271
+ }
272
+ }
273
+
131
274
  export function clearPrefetchInflight(key: string): void {
132
- inflight.delete(key);
275
+ forEachAlias(key, (k) => {
276
+ inflight.delete(k);
277
+ inflightPromises.delete(k);
278
+ inflightAliases.delete(k);
279
+ });
133
280
  }
134
281
 
135
282
  /**
136
283
  * Invalidate all prefetch state. Called when server actions mutate data.
137
284
  * Clears the in-memory cache, cancels in-flight prefetches, and rotates
138
285
  * the Rango state key so CDN-cached responses are also invalidated.
286
+ *
287
+ * Uses abortAllPrefetches (hard cancel) because in-flight responses
288
+ * may contain stale data after a mutation.
139
289
  */
140
290
  export function clearPrefetchCache(): void {
141
291
  generation++;
142
292
  inflight.clear();
293
+ inflightPromises.clear();
294
+ inflightAliases.clear();
143
295
  cache.clear();
144
- cancelAllPrefetches();
296
+ abortAllPrefetches();
145
297
  invalidateRangoState();
146
298
  }
299
+
300
+ /**
301
+ * Drop all in-memory prefetch state for this tab without rotating rango-state.
302
+ *
303
+ * Use for local-only invalidations (e.g. app switch in this tab) where
304
+ * other tabs should NOT observe a state rotation. Unlike clearPrefetchCache,
305
+ * does not call invalidateRangoState, so the shared X-Rango-State token
306
+ * stays intact and siblings in the old app keep their prefetches.
307
+ */
308
+ export function clearPrefetchCacheLocal(): void {
309
+ generation++;
310
+ inflight.clear();
311
+ inflightPromises.clear();
312
+ cache.clear();
313
+ abortAllPrefetches();
314
+ }