@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -2,6 +2,16 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
2
2
 
3
3
  type HeaderResult = { url: string } | "blocked" | null;
4
4
 
5
+ /**
6
+ * Null-body statuses: the Fetch spec forbids pairing these with a body, so
7
+ * `new Response(body, { status })` throws ("Response with null body status
8
+ * cannot have body"). fetch() can still surface one WITH a body straight from
9
+ * the network layer (never the JS constructor): a 304 stale-while-revalidate
10
+ * prefetch revalidated to Not Modified (body from cache), or a 204 soft
11
+ * redirect. teeWithCompletion must not re-run those through `new Response`.
12
+ */
13
+ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
14
+
5
15
  /**
6
16
  * Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
7
17
  * Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
@@ -74,7 +84,13 @@ export function handleReloadHeader(
74
84
  * Returns a new Response with one branch; the other is consumed to detect
75
85
  * end-of-stream, calling onComplete when done.
76
86
  *
77
- * If the response has no body, onComplete fires synchronously.
87
+ * `onComplete` receives `endedCleanly`: true only on a normal EOF drain, false
88
+ * on a read error or an abort (the signal cancelled the reader). Callers that
89
+ * gate a "fully complete" fast path (e.g. prefetch's `entry.complete`) must
90
+ * treat a non-clean end as incomplete — a broken stream is not complete data.
91
+ *
92
+ * If the response has no body, onComplete fires synchronously with `true`
93
+ * (an empty body is a clean, complete stream).
78
94
  * If signal is provided, an abort cancels the tracking reader.
79
95
  *
80
96
  * `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
@@ -84,7 +100,7 @@ export function handleReloadHeader(
84
100
  */
85
101
  export function teeWithCompletion(
86
102
  response: Response,
87
- onComplete: () => void,
103
+ onComplete: (endedCleanly: boolean) => void,
88
104
  signal?: AbortSignal,
89
105
  silent = false,
90
106
  ): Response {
@@ -92,15 +108,18 @@ export function teeWithCompletion(
92
108
  // rejection's .catch, so onComplete must be settled exactly once across all
93
109
  // paths (no-body early return, finally, catch).
94
110
  let settled = false;
95
- const settle = () => {
111
+ const settle = (endedCleanly: boolean) => {
96
112
  if (!settled) {
97
113
  settled = true;
98
- onComplete();
114
+ onComplete(endedCleanly);
99
115
  }
100
116
  };
101
117
 
102
- if (!response.body) {
103
- settle();
118
+ // Empty body, or a null-body status fetch() paired with a body: either way the
119
+ // body can't be re-attached via `new Response` below. Settle and pass the
120
+ // original response through; its body (when present) stays readable downstream.
121
+ if (!response.body || NULL_BODY_STATUS.has(response.status)) {
122
+ settle(true);
104
123
  return response;
105
124
  }
106
125
 
@@ -110,21 +129,31 @@ export function teeWithCompletion(
110
129
  const reader = trackingStream.getReader();
111
130
  const onAbort = signal ? reader.cancel.bind(reader) : undefined;
112
131
  if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
132
+ // Only a loop that reaches `done` is a clean EOF. A read error rejects out
133
+ // of the try and still runs the finally, so the finally must NOT assume
134
+ // clean — it gates on this flag (false on error) AND on signal.aborted
135
+ // (an abort cancels the reader so read() resolves { done: true } and the
136
+ // loop breaks here normally, NOT in the .catch — re-check the signal to
137
+ // catch that case).
138
+ let cleanEof = false;
113
139
  try {
114
140
  while (true) {
115
141
  const { done } = await reader.read();
116
- if (done) break;
142
+ if (done) {
143
+ cleanEof = true;
144
+ break;
145
+ }
117
146
  }
118
147
  } finally {
119
148
  if (onAbort) signal!.removeEventListener("abort", onAbort);
120
149
  reader.releaseLock();
121
- settle();
150
+ settle(cleanEof && !signal?.aborted);
122
151
  }
123
152
  })().catch((error) => {
124
153
  if (!silent && !signal?.aborted) {
125
154
  console.error("[Browser] Error reading tracking stream:", error);
126
155
  }
127
- settle();
156
+ settle(false);
128
157
  });
129
158
 
130
159
  return new Response(rscStream, {
@@ -25,6 +25,7 @@ import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
25
25
  import { initRangoState } from "./rango-state.js";
26
26
  import { registerNavigationStore } from "./navigation-store-handle.js";
27
27
  import { initPrefetchCache } from "./prefetch/cache.js";
28
+ import { setPrefetchConcurrency } from "./prefetch/queue.js";
28
29
  import { setPrefetchDecoder } from "./prefetch/fetch.js";
29
30
  import { setAppVersion } from "./app-version.js";
30
31
  import {
@@ -32,6 +33,7 @@ import {
32
33
  splitInterceptSegments,
33
34
  } from "./intercept-utils.js";
34
35
  import { createAppShellRef } from "./app-shell.js";
36
+ import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
35
37
 
36
38
  // Vite HMR types are provided by vite/client
37
39
 
@@ -155,6 +157,8 @@ export async function initBrowserApp(
155
157
  initialTheme,
156
158
  } = options;
157
159
 
160
+ bootLog("initBrowserApp start");
161
+ bootLog("flight decode: awaiting initial payload from document stream");
158
162
  const initialPayload =
159
163
  await deps.createFromReadableStream<RscPayload>(rscStream);
160
164
 
@@ -168,6 +172,14 @@ export async function initBrowserApp(
168
172
  // Get initial segments and compute history key from current URL
169
173
  const initialSegments = (initialPayload.metadata?.segments ??
170
174
  []) as ResolvedSegment[];
175
+ if (IS_BROWSER_DEBUG) {
176
+ bootLog("initial payload decoded", {
177
+ version: initialPayload.metadata?.version,
178
+ routerId: initialPayload.metadata?.routerId,
179
+ segments: initialSegments.map((s) => s.id),
180
+ matched: initialPayload.metadata?.matched,
181
+ });
182
+ }
171
183
  const initialHistoryKey = generateHistoryKey(window.location.href);
172
184
 
173
185
  // Create navigation store with history-based caching
@@ -206,11 +218,24 @@ export async function initBrowserApp(
206
218
  // This ensures useHandle returns correct data during hydration to avoid mismatch
207
219
  // The handles property is an async generator that yields on each push
208
220
  if (initialPayload.metadata?.handles) {
221
+ // This for-await consumes the handle generator to completion BEFORE
222
+ // hydrateRoot is called — on a streaming/PPR document the generator only
223
+ // ends when its stream side does, so the per-push logs below are the
224
+ // primary probe for "the document render is holding hydration".
225
+ bootLog("handles: consuming payload handle stream (pre-hydration await)");
209
226
  const handlesGenerator = initialPayload.metadata.handles;
210
227
  let lastHandleData: Record<string, Record<string, unknown[]>> = {};
228
+ let handlePushes = 0;
211
229
  for await (const handleData of handlesGenerator) {
212
230
  lastHandleData = handleData;
231
+ if (IS_BROWSER_DEBUG) {
232
+ handlePushes += 1;
233
+ bootLog(`handles: push #${handlePushes}`, {
234
+ segments: Object.keys(handleData),
235
+ });
236
+ }
213
237
  }
238
+ bootLog("handles: stream complete", { pushes: handlePushes });
214
239
  // Initialize event controller with initial handle state before hydration.
215
240
  eventController.setHandleData(
216
241
  lastHandleData,
@@ -220,6 +245,8 @@ export async function initBrowserApp(
220
245
  // Update the initial cache entry with the processed handleData
221
246
  // The cache entry was created by createNavigationStore but without handleData
222
247
  store.updateCacheHandleData(initialHistoryKey, lastHandleData);
248
+ } else {
249
+ bootLog("handles: none in payload");
223
250
  }
224
251
 
225
252
  // Create composable utilities
@@ -245,11 +272,17 @@ export async function initBrowserApp(
245
272
  initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
246
273
  setAppVersion(version);
247
274
 
248
- // Initialize the in-memory prefetch cache TTL from server config.
249
- // A value of 0 disables the cache; undefined falls back to the module default.
275
+ // Initialize the in-memory prefetch cache (TTL + max size) and the prefetch
276
+ // queue concurrency from server config. A TTL of 0 disables the cache;
277
+ // undefined values fall back to the module defaults.
250
278
  const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
251
- if (prefetchCacheTTL !== undefined) {
252
- initPrefetchCache(prefetchCacheTTL);
279
+ const prefetchCacheSize = initialPayload.metadata?.prefetchCacheSize;
280
+ if (prefetchCacheTTL !== undefined || prefetchCacheSize !== undefined) {
281
+ initPrefetchCache(prefetchCacheTTL, prefetchCacheSize);
282
+ }
283
+ const prefetchConcurrency = initialPayload.metadata?.prefetchConcurrency;
284
+ if (prefetchConcurrency !== undefined) {
285
+ setPrefetchConcurrency(prefetchConcurrency);
253
286
  }
254
287
 
255
288
  // Wire the RSC decoder so prefetches decode eagerly and warm the route's
@@ -314,9 +347,17 @@ export async function initBrowserApp(
314
347
  if (linkInterception) {
315
348
  navigationBridge.registerLinkInterception();
316
349
  }
350
+ bootLog("bridges registered (action + navigation)");
317
351
 
318
352
  // Build initial tree with rootLayout
353
+ bootLog("building initial segment tree (renderSegments)");
319
354
  const initialTree = renderSegments(initialPayload.metadata!.segments);
355
+ if (IS_BROWSER_DEBUG && initialTree instanceof Promise) {
356
+ initialTree.then(
357
+ () => bootLog("initial segment tree settled"),
358
+ (err: unknown) => bootLog("initial segment tree rejected", { err }),
359
+ );
360
+ }
320
361
 
321
362
  // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
322
363
  // fire many rsc:update events in quick succession. Without debouncing,
@@ -484,9 +525,14 @@ export async function initBrowserApp(
484
525
  };
485
526
  browserAppContext = context;
486
527
 
528
+ bootLog("initBrowserApp complete -- handing off to hydrateRoot");
487
529
  return context;
488
530
  }
489
531
 
532
+ // Once-flag so the hydration-commit boot log fires a single time (StrictMode
533
+ // re-runs the root effect; the second flush is not a second hydration).
534
+ let hydrationCommitLogged = false;
535
+
490
536
  /**
491
537
  * Get the browser app context. Throws if initBrowserApp hasn't been called.
492
538
  */
@@ -554,6 +600,10 @@ export function Rango(_props: RangoProps): React.ReactElement {
554
600
  // that does not depend on React internals like __reactFiber.
555
601
  React.useEffect(() => {
556
602
  document.documentElement.dataset.hydrated = "";
603
+ if (IS_BROWSER_DEBUG && !hydrationCommitLogged) {
604
+ hydrationCommitLogged = true;
605
+ bootLog("hydration commit (root effect flushed)");
606
+ }
557
607
  }, []);
558
608
 
559
609
  return (
@@ -8,7 +8,7 @@
8
8
  * - Supports hash link scrolling
9
9
  */
10
10
 
11
- import { debugLog } from "./logging.js";
11
+ import { debugLog, IS_BROWSER_DEBUG } from "./logging.js";
12
12
 
13
13
  /**
14
14
  * Defers a callback to the next animation frame.
@@ -150,10 +150,12 @@ export function initScrollRestoration(options?: {
150
150
 
151
151
  window.addEventListener("pagehide", handlePageHide);
152
152
 
153
- debugLog(
154
- "[Scroll] Initialized, loaded positions:",
155
- Object.keys(savedScrollPositions).length,
156
- );
153
+ if (IS_BROWSER_DEBUG) {
154
+ debugLog(
155
+ "[Scroll] Initialized, loaded positions:",
156
+ Object.keys(savedScrollPositions).length,
157
+ );
158
+ }
157
159
 
158
160
  return () => {
159
161
  cancelScrollRestorationPolling();
@@ -6,7 +6,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
- import { debugLog } from "./logging.js";
9
+ import { debugLog, IS_BROWSER_DEBUG } from "./logging.js";
10
10
 
11
11
  /**
12
12
  * Determines the merging behavior for segment reconciliation.
@@ -87,15 +87,17 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
87
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
88
88
 
89
89
  const diffSet = new Set(diff);
90
- debugLog(
91
- `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
- );
93
- debugLog(
94
- `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
- );
96
- debugLog(
97
- `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
- );
90
+ if (IS_BROWSER_DEBUG) {
91
+ debugLog(
92
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
93
+ );
94
+ debugLog(
95
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
96
+ );
97
+ debugLog(
98
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
99
+ );
100
+ }
99
101
 
100
102
  const segments = matched
101
103
  .map((segId: string) => {
@@ -106,9 +108,11 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
106
108
  const inDiff = diffSet.has(segId);
107
109
  // Merge partial loader data when server returns fewer loaders than cached
108
110
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
- debugLog(
110
- `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
- );
111
+ if (IS_BROWSER_DEBUG) {
112
+ debugLog(
113
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
114
+ );
115
+ }
112
116
  return mergeSegmentLoaders(fromServer, fromCache);
113
117
  }
114
118
 
@@ -159,14 +163,18 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
159
163
  // above fails to preserve a value it should have.
160
164
  assertSegmentStructure(fromCache, merged, context);
161
165
 
166
+ if (IS_BROWSER_DEBUG) {
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
169
+ );
170
+ }
171
+ return merged;
172
+ }
173
+ if (IS_BROWSER_DEBUG) {
162
174
  debugLog(
163
- `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
175
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
164
176
  );
165
- return merged;
166
177
  }
167
- debugLog(
168
- `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
- );
170
178
  return fromServer;
171
179
  }
172
180
 
@@ -180,9 +188,11 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
180
188
  return fromCache;
181
189
  }
182
190
 
183
- debugLog(
184
- `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
- );
191
+ if (IS_BROWSER_DEBUG) {
192
+ debugLog(
193
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
194
+ );
195
+ }
186
196
 
187
197
  // Return the cached segment as-is, regardless of actor. We used to clear
188
198
  // truthy `loading` here to prevent a stale Suspense fallback from
@@ -558,18 +558,15 @@ export function createServerActionBridge(
558
558
  return undefined;
559
559
  }
560
560
 
561
- // Update UI with error boundary
562
- startTransition(() => {
563
- onUpdate({ root: errorTree, metadata: metadata! });
564
- });
565
-
566
561
  // Update segment tracking to exclude error segment IDs
567
562
  const errorSegmentIds = new Set(diff);
568
563
  const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
569
564
  (id) => !errorSegmentIds.has(id),
570
565
  );
571
566
 
572
- // Update store state
567
+ // Cache (and bump the nav instance) BEFORE the UI update so a deferred
568
+ // handle pushed by the error-boundary render still applies — see the
569
+ // "normal" case below for why caching after onUpdate dropped it.
573
570
  store.setSegmentIds(segmentIdsAfterError);
574
571
  const currentHandleData = eventController.getHandleState().data;
575
572
  store.cacheSegmentsForHistory(
@@ -578,6 +575,11 @@ export function createServerActionBridge(
578
575
  currentHandleData,
579
576
  );
580
577
 
578
+ // Update UI with error boundary
579
+ startTransition(() => {
580
+ onUpdate({ root: errorTree, metadata: metadata! });
581
+ });
582
+
581
583
  // Throw the error so the action promise rejects
582
584
  if (returnValue && !returnValue.ok) {
583
585
  throw returnValue.data;
@@ -779,10 +781,16 @@ export function createServerActionBridge(
779
781
  break;
780
782
  }
781
783
 
782
- startTransition(() => {
783
- onUpdate({ root: newTree, metadata: metadata! });
784
- });
785
-
784
+ // Cache (and bump the nav instance) BEFORE the UI update, matching the
785
+ // navigation commit order (navigation-transaction.ts:157). processHandles
786
+ // is spawned by onUpdate and captures the current nav instance up front;
787
+ // a deferred handle value resolves asynchronously and is only applied
788
+ // while stillLive() (its captured instance still owns the page). Caching
789
+ // AFTER onUpdate bumped the instance out from under that in-flight
790
+ // resolve, so stillLive() turned false and the resolved handle snapshot
791
+ // was dropped on the action-revalidation path (sync siblings sharing the
792
+ // yield are held atomically, so they were dropped too).
793
+ //
786
794
  // Location state already applied above (pre-switch). Update store.
787
795
  store.setSegmentIds(matched);
788
796
  const currentHandleData = eventController.getHandleState().data;
@@ -791,6 +799,10 @@ export function createServerActionBridge(
791
799
  fullSegments,
792
800
  currentHandleData,
793
801
  );
802
+
803
+ startTransition(() => {
804
+ onUpdate({ root: newTree, metadata: metadata! });
805
+ });
794
806
  // Invalidation deferred to finalizeAction() (runs after this caches
795
807
  // the fresh segments), suppressed when the action called
796
808
  // keepClientCache().
@@ -70,6 +70,16 @@ export interface RscMetadata {
70
70
  * Sent on initial render so the browser can configure its cache duration.
71
71
  */
72
72
  prefetchCacheTTL?: number;
73
+ /**
74
+ * Max entries in the client-side in-memory prefetch cache (FIFO eviction).
75
+ * Sent on initial render so the browser can configure its cache capacity.
76
+ */
77
+ prefetchCacheSize?: number;
78
+ /**
79
+ * Max concurrent speculative prefetch requests on the client.
80
+ * Sent on initial render so the browser can configure its prefetch queue.
81
+ */
82
+ prefetchConcurrency?: number;
73
83
  /**
74
84
  * Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
75
85
  * reads it verbatim and binds the rango state cookie to it; composition
@@ -447,6 +457,10 @@ export interface NavigationStore {
447
457
  // History-based segment cache (for back/forward navigation and partial merging)
448
458
  getHistoryKey(): string;
449
459
  setHistoryKey(key: string): void;
460
+ /** Monotonic token of the most recently committed navigation. */
461
+ getNavInstance(): number;
462
+ /** Nav-instance token recorded on a cache entry (undefined if absent). */
463
+ getCacheEntryInstance(historyKey: string): number | undefined;
450
464
  cacheSegmentsForHistory(
451
465
  historyKey: string,
452
466
  segments: ResolvedSegment[],
@@ -458,10 +472,40 @@ export interface NavigationStore {
458
472
  stale: boolean;
459
473
  handleData?: HandleData;
460
474
  routerId?: string;
475
+ /**
476
+ * True when the entry's handle data is incomplete (a deferred Meta was
477
+ * still pending at navigate-away). A popstate return must revalidate with
478
+ * a FULL re-render so the server re-streams handles.
479
+ */
480
+ handlesPending?: boolean;
461
481
  }
462
482
  | undefined;
463
483
  hasHistoryCache(historyKey: string): boolean;
464
- updateCacheHandleData(historyKey: string, handleData: HandleData): void;
484
+ /**
485
+ * Update only the handleData (and optionally the stale / handlesPending flags)
486
+ * of an existing cache entry. When a flag is omitted the entry's current value
487
+ * is preserved. `stale=true` marks a single entry stale so a popstate return
488
+ * revalidates it; `handlesPending=true` additionally forces that revalidation
489
+ * to be a full re-render (so a deferred Meta re-streams).
490
+ */
491
+ updateCacheHandleData(
492
+ historyKey: string,
493
+ handleData: HandleData,
494
+ stale?: boolean,
495
+ handlesPending?: boolean,
496
+ ): void;
497
+ /**
498
+ * Owner-guarded variant of updateCacheHandleData: writes only when the entry
499
+ * is still owned by `ownerInstance`. Folds the caller's separate ownership
500
+ * probe and write into one historyCache scan for the per-yield streaming path.
501
+ */
502
+ updateCacheHandleDataIfOwned(
503
+ historyKey: string,
504
+ handleData: HandleData,
505
+ ownerInstance: number,
506
+ stale?: boolean,
507
+ handlesPending?: boolean,
508
+ ): void;
465
509
  markCacheAsStale(): void;
466
510
  markHistoryCacheStale(): void;
467
511
  markCacheAsStaleAndBroadcast(): void;
@@ -521,6 +565,15 @@ export interface FetchPartialResult {
521
565
  payload: RscPayload;
522
566
  /** Promise that resolves when the response stream is fully consumed */
523
567
  streamComplete: Promise<void>;
568
+ /**
569
+ * True only when this payload came from a prefetch-cache hit whose stream had
570
+ * ALREADY fully drained at fetch time (the route was fully prefetched). The
571
+ * commit then runs in a startTransition so loading()/Suspense content — already
572
+ * resolved — swaps in directly without flashing a fallback. A partially-warmed
573
+ * (still-streaming) prefetch hit and a cold fetch leave this false so their
574
+ * fallbacks stream as usual.
575
+ */
576
+ fullyPrefetched?: boolean;
524
577
  }
525
578
 
526
579
  /**