@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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve the client-side prefetch limits — the in-memory prefetch cache size
3
+ * and the speculative-prefetch queue concurrency — once, at router init. Both
4
+ * are positive-integer counts shipped to the browser in payload metadata; the
5
+ * browser entry feeds them to the prefetch cache (`initPrefetchCache`) and the
6
+ * prefetch queue (`setPrefetchConcurrency`) at startup.
7
+ *
8
+ * Policy note: this is a SEPARATE policy from resolvePrefetchCacheTTL
9
+ * (prefetch-cache-ttl.ts). These are counts, not durations, so the disable
10
+ * paths differ: a TTL of `false`/0 intentionally turns prefetching off, but a
11
+ * cache size or concurrency of 0 would silently break prefetching while leaving
12
+ * it nominally enabled — never the intent of passing a number. So any value
13
+ * that is not a finite integer >= 1 (0, negative, fractional below 1, NaN,
14
+ * Infinity, undefined) falls back to the default. Finite values >= 1 are
15
+ * floored. Do not unify this guard with the TTL guard.
16
+ */
17
+
18
+ export const DEFAULT_PREFETCH_CACHE_SIZE = 100;
19
+ export const DEFAULT_PREFETCH_CONCURRENCY = 2;
20
+
21
+ export function resolvePrefetchCacheSize(raw: number | undefined): number {
22
+ return resolvePositiveCount(raw, DEFAULT_PREFETCH_CACHE_SIZE);
23
+ }
24
+
25
+ export function resolvePrefetchConcurrency(raw: number | undefined): number {
26
+ return resolvePositiveCount(raw, DEFAULT_PREFETCH_CONCURRENCY);
27
+ }
28
+
29
+ function resolvePositiveCount(
30
+ raw: number | undefined,
31
+ fallback: number,
32
+ ): number {
33
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 1) {
34
+ return Math.floor(raw);
35
+ }
36
+ return fallback;
37
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { createHandleStore } from "../server/handle-store.js";
3
+ import { resolveSegmentHandleValues } from "../handles/deferred-resolution.js";
3
4
  import { getRequestContext } from "../server/request-context.js";
4
5
  import {
5
6
  runWithRequestContext,
@@ -32,7 +33,9 @@ import type {
32
33
  import type { RouteMatchResult } from "./pattern-matching.js";
33
34
 
34
35
  export interface PrerenderMatchDeps<TEnv = any> {
35
- findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
36
+ findMatch: (
37
+ pathname: string,
38
+ ) => RouteMatchResult<TEnv> | null | Promise<RouteMatchResult<TEnv> | null>;
36
39
  buildRouterContext: () => RouterContext<TEnv>;
37
40
  mergedRouteMap: Record<string, string>;
38
41
  resolveAllSegments: (
@@ -73,7 +76,7 @@ export async function matchForPrerender<TEnv = any>(
73
76
  passthrough?: true;
74
77
  } | null> {
75
78
  // 1. Find the matching route entry
76
- const matched = deps.findMatch(pathname);
79
+ const matched = await deps.findMatch(pathname);
77
80
  if (!matched) return null;
78
81
 
79
82
  // Use params from trie match if available, fall back to provided params
@@ -293,7 +296,9 @@ export async function matchForPrerender<TEnv = any>(
293
296
  for (const seg of nonLoaderSegments) {
294
297
  const segHandles = handleStore.getDataForSegment(seg.id);
295
298
  if (Object.keys(segHandles).length > 0) {
296
- handlesRecord[seg.id] = segHandles;
299
+ // Resolve deferred values before encoding so the baked artifact holds
300
+ // resolved data (prerender = build-time cache).
301
+ handlesRecord[seg.id] = await resolveSegmentHandleValues(segHandles);
297
302
  }
298
303
  }
299
304
  const handles = await encodeHandles(handlesRecord);
@@ -394,7 +399,8 @@ export async function matchForPrerender<TEnv = any>(
394
399
  for (const seg of interceptResolvedSegments) {
395
400
  const segHandles = handleStore.getDataForSegment(seg.id);
396
401
  if (Object.keys(segHandles).length > 0) {
397
- interceptHandlesRecord[seg.id] = segHandles;
402
+ interceptHandlesRecord[seg.id] =
403
+ await resolveSegmentHandleValues(segHandles);
398
404
  }
399
405
  }
400
406
  // The intercept artifact serves main + intercept segments together, so
@@ -533,7 +539,7 @@ export async function renderStaticSegment<TEnv = any>(
533
539
  const segHandles = handleStore.getDataForSegment(handlerId);
534
540
  const handles =
535
541
  Object.keys(segHandles).length > 0
536
- ? await encodeHandleValue(segHandles)
542
+ ? await encodeHandleValue(await resolveSegmentHandleValues(segHandles))
537
543
  : "";
538
544
 
539
545
  return { encoded: serialized.encoded, handles };
@@ -6,7 +6,9 @@ import type { MiddlewareFn } from "./middleware.js";
6
6
  import { resolveRoute } from "./route-snapshot.js";
7
7
 
8
8
  export interface PreviewMatchDeps<TEnv = any> {
9
- findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
9
+ findMatch: (
10
+ pathname: string,
11
+ ) => RouteMatchResult<TEnv> | null | Promise<RouteMatchResult<TEnv> | null>;
10
12
  }
11
13
 
12
14
  /**
@@ -121,7 +121,9 @@ export type {
121
121
  };
122
122
 
123
123
  export interface ClassifyRequestDeps<TEnv = any> {
124
- findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
124
+ findMatch: (
125
+ pathname: string,
126
+ ) => RouteMatchResult<TEnv> | null | Promise<RouteMatchResult<TEnv> | null>;
125
127
  routerVersion: string;
126
128
  routerId: string;
127
129
  }
@@ -148,6 +150,23 @@ export async function classifyRequest<TEnv = any>(
148
150
  const pathname = url.pathname;
149
151
  const isAction =
150
152
  request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
153
+ const isLoaderFetch = url.searchParams.has("_rsc_loader");
154
+ const isPartialReq = url.searchParams.has("_rsc_partial");
155
+ const contentType = request.headers.get("content-type") || "";
156
+ const isFormSubmission =
157
+ contentType.includes("multipart/form-data") ||
158
+ contentType.includes("application/x-www-form-urlencoded");
159
+ const isPeRender = request.method === "POST" && !isAction && isFormSubmission;
160
+
161
+ // A plain document request (full-render mode) is the only mode that renders
162
+ // through match() with isSSR:true; every other mode (action, loader, PE,
163
+ // partial) renders — if at all — through the non-SSR matchPartial() path.
164
+ // Resolve the manifest under the flag the eventual render will use so
165
+ // createMatchContextForFull can reuse this snapshot instead of re-resolving.
166
+ // Response routes ignore isSSR entirely (they short-circuit before match),
167
+ // so the flag is moot for them. Mode detection below reuses these booleans.
168
+ const willFullRender =
169
+ !isAction && !isLoaderFetch && !isPartialReq && !isPeRender;
151
170
 
152
171
  const clientVersion = url.searchParams.get("_rsc_v");
153
172
  if (
@@ -189,6 +208,7 @@ export async function classifyRequest<TEnv = any>(
189
208
  const result = await resolveRoute<TEnv>(pathname, {
190
209
  findMatch: deps.findMatch,
191
210
  lite: true,
211
+ isSSR: willFullRender,
192
212
  });
193
213
 
194
214
  if (!result) {
@@ -229,7 +249,6 @@ export async function classifyRequest<TEnv = any>(
229
249
 
230
250
  const actionId =
231
251
  request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
232
- const isLoaderFetch = url.searchParams.has("_rsc_loader");
233
252
 
234
253
  const hasVariants =
235
254
  snapshot.matched.negotiateVariants &&
@@ -244,15 +263,11 @@ export async function classifyRequest<TEnv = any>(
244
263
  return { mode: "loader", route: snapshot };
245
264
  }
246
265
 
247
- const contentType = request.headers.get("content-type") || "";
248
- const isFormSubmission =
249
- contentType.includes("multipart/form-data") ||
250
- contentType.includes("application/x-www-form-urlencoded");
251
- if (request.method === "POST" && !isAction && isFormSubmission) {
266
+ if (isPeRender) {
252
267
  return { mode: "pe-render", route: snapshot };
253
268
  }
254
269
 
255
- if (url.searchParams.has("_rsc_partial")) {
270
+ if (isPartialReq) {
256
271
  return { mode: "partial-render", route: snapshot, negotiated };
257
272
  }
258
273
 
@@ -48,6 +48,15 @@ export interface RouteSnapshot<TEnv = any> {
48
48
  isPassthrough: boolean;
49
49
  /** Response type for non-RSC routes (e.g. "application/json") */
50
50
  responseType?: string;
51
+ /**
52
+ * The isSSR flag the manifest was resolved with. Recorded so consumers can
53
+ * decide whether the snapshot is reusable: a snapshot resolved with isSSR
54
+ * differs (loading({ ssr: false }) entries) and is cached under a different
55
+ * manifest partition, so the full (document) path only reuses an isSSR:true
56
+ * snapshot and the partial path only reuses a non-isSSR one. Undefined on
57
+ * inline snapshots (redirect / not-found) that are never reused.
58
+ */
59
+ isSSR?: boolean;
51
60
  }
52
61
 
53
62
  export type ResolveRouteResult<TEnv = any> =
@@ -56,7 +65,9 @@ export type ResolveRouteResult<TEnv = any> =
56
65
  | null;
57
66
 
58
67
  export interface ResolveRouteDeps<TEnv = any> {
59
- findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
68
+ findMatch: (
69
+ pathname: string,
70
+ ) => RouteMatchResult<TEnv> | null | Promise<RouteMatchResult<TEnv> | null>;
60
71
  metricsStore?: MetricsStore;
61
72
  isSSR?: boolean;
62
73
  /**
@@ -105,7 +116,7 @@ export async function resolveRoute<TEnv = any>(
105
116
 
106
117
  const routeMatchStart =
107
118
  metricsStore && !skipRouteMatchMetric ? performance.now() : 0;
108
- const matched = deps.findMatch(pathname);
119
+ const matched = await deps.findMatch(pathname);
109
120
  if (metricsStore && !skipRouteMatchMetric) {
110
121
  metricsStore.metrics.push({
111
122
  label: "route-matching",
@@ -175,6 +186,7 @@ export async function resolveRoute<TEnv = any>(
175
186
  cacheScope,
176
187
  isPassthrough,
177
188
  responseType,
189
+ isSSR,
178
190
  },
179
191
  };
180
192
  }
@@ -55,7 +55,9 @@ export interface InterceptResult {
55
55
  * Instead of passing 20+ parameters, middleware calls getRouterContext() to access them.
56
56
  */
57
57
  export interface RouterContext<TEnv = any> {
58
- findMatch: (pathname: string) => RouteMatchResult | null;
58
+ findMatch: (
59
+ pathname: string,
60
+ ) => RouteMatchResult | null | Promise<RouteMatchResult | null>;
59
61
 
60
62
  loadManifest: (
61
63
  entry: any,
@@ -9,12 +9,14 @@ import type { NonceProvider } from "../rsc/types.js";
9
9
  import type { ExecutionContext } from "../server/request-context.js";
10
10
  import type { SerializedSegmentData } from "../cache/types.js";
11
11
  import type { MiddlewareEntry, MiddlewareFn } from "./middleware.js";
12
+ import type { RouteMatchResult } from "./pattern-matching.js";
12
13
  import type { ExtractParams } from "../types/route-config.js";
13
14
  import { RSC_ROUTER_BRAND } from "./router-registry.js";
14
15
  import type { RangoOptions, RootLayoutProps } from "./router-options.js";
15
16
  import type { DefaultVars } from "../types/global-namespace.js";
16
17
  import type { ResolvedTimeouts, OnTimeoutCallback } from "./timeout.js";
17
18
  import type { ResolvedTracing } from "./tracing.js";
19
+ import type { TelemetrySink } from "./telemetry.js";
18
20
 
19
21
  /**
20
22
  * Options passed to router.fetch(), router.match(), and other request entrypoints.
@@ -301,6 +303,20 @@ export interface RangoInternal<
301
303
  */
302
304
  readonly prefetchCacheTTL: number;
303
305
 
306
+ /**
307
+ * Maximum number of decoded prefetch payloads the client keeps in its
308
+ * in-memory prefetch cache (FIFO eviction at capacity). Shipped to the
309
+ * client in payload metadata. Derived from prefetchCacheSize.
310
+ */
311
+ readonly prefetchCacheSize: number;
312
+
313
+ /**
314
+ * Maximum number of speculative prefetch requests the client runs
315
+ * concurrently. Shipped to the client in payload metadata. Derived from
316
+ * prefetchConcurrency.
317
+ */
318
+ readonly prefetchConcurrency: number;
319
+
304
320
  /**
305
321
  * Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
306
322
  * router init and shipped to the client in payload metadata. The server-side
@@ -335,6 +351,14 @@ export interface RangoInternal<
335
351
  */
336
352
  readonly tracing?: ResolvedTracing;
337
353
 
354
+ /**
355
+ * Raw telemetry sink from RangoOptions, exposed so handler-level emitters
356
+ * (rsc/handler.ts timeout/origin/late-handle) can emit WITHOUT the
357
+ * RouterContext ALS, which only match()/matchPartial() enter. See
358
+ * observeEvent's emitter list in router/instrument.ts.
359
+ */
360
+ readonly telemetry?: TelemetrySink;
361
+
338
362
  /**
339
363
  * Whether ?__debug_manifest is allowed in production.
340
364
  * Always enabled in development.
@@ -502,7 +526,14 @@ export interface RangoInternal<
502
526
  * Used by classifyRequest() for request classification without
503
527
  * entering the full match pipeline.
504
528
  */
505
- findMatch(pathname: string, metricsStore?: any): any;
529
+ // Async since a lazy async include (`() => import()`) must resolve before its
530
+ // routes can match. Typed (not `any`) so a consumer doing
531
+ // `const m = router.findMatch(p); if (!m) ...; m.entry` gets a compile error
532
+ // (m is a Promise) instead of the silent always-truthy bug.
533
+ findMatch(
534
+ pathname: string,
535
+ metricsStore?: any,
536
+ ): Promise<RouteMatchResult<TEnv> | null>;
506
537
 
507
538
  /**
508
539
  * Debug utility to serialize the manifest for inspection
@@ -497,6 +497,36 @@ export interface RangoOptions<TEnv = any> {
497
497
  */
498
498
  prefetchCacheTTL?: number | false;
499
499
 
500
+ /**
501
+ * Maximum number of decoded prefetch payloads the client keeps in its
502
+ * in-memory prefetch cache. When the cache is full the oldest entry is
503
+ * evicted (FIFO) to make room for a new prefetch.
504
+ *
505
+ * Each entry retains a fully decoded RSC payload (and the route's client
506
+ * chunks pulled in while decoding), so this is the lever on client-side
507
+ * prefetch memory: a higher value warms more routes at the cost of more
508
+ * retained payloads. Staleness is bounded separately by `prefetchCacheTTL`;
509
+ * this bounds the entry COUNT.
510
+ *
511
+ * Values below 1 (or non-finite) fall back to the default. To turn
512
+ * prefetching off entirely, set `prefetchCacheTTL: false` instead.
513
+ *
514
+ * @default 100
515
+ */
516
+ prefetchCacheSize?: number;
517
+
518
+ /**
519
+ * Maximum number of speculative prefetch requests (viewport/render strategy)
520
+ * the client runs concurrently. Hover prefetches bypass this queue and fire
521
+ * immediately; this caps only the background, idle-gated queue so prefetches
522
+ * never saturate the browser's connection pool.
523
+ *
524
+ * Values below 1 (or non-finite) fall back to the default.
525
+ *
526
+ * @default 2
527
+ */
528
+ prefetchConcurrency?: number;
529
+
500
530
  /**
501
531
  * Prefix for the rango state cookie name. The resolved name is
502
532
  * `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
@@ -19,6 +19,10 @@ import type {
19
19
  } from "../../types";
20
20
  import type { SegmentResolutionDeps } from "../types.js";
21
21
  import { resolveLoaderData } from "./loader-cache.js";
22
+ import {
23
+ isShellCaptureActive,
24
+ entryLoadingMasksLoaders,
25
+ } from "./loader-mask.js";
22
26
  import {
23
27
  handleHandlerResult,
24
28
  tryStaticHandler,
@@ -60,6 +64,24 @@ export async function resolveLoaders<TEnv>(
60
64
  const hasLoading = "loading" in entry && entry.loading !== undefined;
61
65
  const loadingDisabled = hasLoading && entry.loading === false;
62
66
 
67
+ // Emit the streaming (non-awaiting) loader shape when loading is enabled OR
68
+ // during a PPR shell capture. In capture, LIVE-lane loaders are masked with
69
+ // never-resolving promises (loader-mask.ts); the loading-disabled branch below
70
+ // AWAITS the loader promises, which would hang the capture render's match()
71
+ // forever on those masked promises. Forcing the streaming shape lets match()
72
+ // complete so the prerender can postpone the loader subtrees as holes. The
73
+ // `!loadingDisabled` short-circuit keeps the ALS check off the hot path (only
74
+ // loading-disabled entries consult it), so normal requests are unchanged.
75
+ const emitStreaming = !loadingDisabled || isShellCaptureActive();
76
+
77
+ // PPR lane decision for this entry's loaders (loader-container-bake): an
78
+ // entry WITHOUT renderable loading() puts its loaders on the BAKE lane —
79
+ // executed at capture (container bakes, nested pending promises hole at the
80
+ // consumer's Suspense) and overlay-pinned from the shell snapshot on a HIT.
81
+ // Renderable loading() keeps the LIVE lane (masked at capture, always
82
+ // fresh). Computed per entry; resolveLoaderData applies the policy.
83
+ const bakeLane = !entryLoadingMasksLoaders(entry.loading);
84
+
63
85
  // Error context for wrapLoaderPromise: without it, a throwing DSL loader never
64
86
  // fires createRouter({ onError }) (phase "loader") nor emits the loader.error
65
87
  // telemetry event — wrapLoaderPromise only builds the onError/telemetry path
@@ -67,7 +89,7 @@ export async function resolveLoaders<TEnv>(
67
89
  // loader failures the same way handlers/actions/routing/fetchable-loaders do.
68
90
  const errorContext = buildLoaderErrorContext(ctx);
69
91
 
70
- if (!loadingDisabled) {
92
+ if (emitStreaming) {
71
93
  // Streaming loaders: promises kick off now, settle during RSC serialization.
72
94
  const segments = loaderEntries.map((loaderEntry, i) => {
73
95
  const { loader } = loaderEntry;
@@ -82,7 +104,12 @@ export async function resolveLoaders<TEnv>(
82
104
  loaderId: loader.$$id,
83
105
  loaderData: deps.wrapLoaderPromise(
84
106
  runInsideLoaderScope(() =>
85
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
107
+ resolveLoaderData(
108
+ loaderEntry,
109
+ ctx,
110
+ ctx.pathname,
111
+ bakeLane ? segmentId : null,
112
+ ),
86
113
  ),
87
114
  entry,
88
115
  segmentId,
@@ -111,7 +138,12 @@ export async function resolveLoaders<TEnv>(
111
138
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
112
139
  const wrapped = deps.wrapLoaderPromise(
113
140
  runInsideLoaderScope(() =>
114
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
141
+ resolveLoaderData(
142
+ loaderEntry,
143
+ ctx,
144
+ ctx.pathname,
145
+ bakeLane ? segmentId : null,
146
+ ),
115
147
  ),
116
148
  entry,
117
149
  segmentId,
@@ -202,6 +234,7 @@ export async function resolveSegment<TEnv>(
202
234
  transition: applyViewTransitionDefault(
203
235
  entry.transition,
204
236
  deps.viewTransitionDefault,
237
+ entry.shortCode,
205
238
  ),
206
239
  params,
207
240
  belongsToRoute: false,
@@ -345,6 +378,7 @@ export async function resolveSegment<TEnv>(
345
378
  transition: applyViewTransitionDefault(
346
379
  entry.transition,
347
380
  deps.viewTransitionDefault,
381
+ entry.shortCode,
348
382
  ),
349
383
  params,
350
384
  belongsToRoute: true,
@@ -432,6 +466,7 @@ export async function resolveOrphanLayout<TEnv>(
432
466
  transition: applyViewTransitionDefault(
433
467
  orphan.transition,
434
468
  deps.viewTransitionDefault,
469
+ orphan.shortCode,
435
470
  ),
436
471
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
437
472
  });
@@ -565,6 +600,7 @@ export async function resolveParallelEntry<TEnv>(
565
600
  transition: applyViewTransitionDefault(
566
601
  parallelEntry.transition,
567
602
  deps.viewTransitionDefault,
603
+ `${parentShortCode}.${slot}`,
568
604
  ),
569
605
  params,
570
606
  slot,
@@ -21,7 +21,11 @@
21
21
  import type { LoaderEntry } from "../../server/context.js";
22
22
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
23
23
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
24
- import { getRequestContext } from "../../server/request-context.js";
24
+ import {
25
+ getRequestContext,
26
+ _getRequestContext,
27
+ runWithRequestContext,
28
+ } from "../../server/request-context.js";
25
29
  import { sortedRouteParams } from "../../cache/cache-key-utils.js";
26
30
  import {
27
31
  resolveTtl,
@@ -32,7 +36,15 @@ import {
32
36
  DEFAULT_ROUTE_TTL,
33
37
  } from "../../cache/cache-policy.js";
34
38
  import { readThroughItem } from "../../cache/read-through-swr.js";
39
+ import {
40
+ maskNestedContainerThenables,
41
+ overlayLoaderContainer,
42
+ } from "./loader-snapshot.js";
35
43
  import { recordRequestTags } from "../../cache/cache-tag.js";
44
+ import {
45
+ isShellCaptureActive,
46
+ createMaskedLoaderPromise,
47
+ } from "./loader-mask.js";
36
48
  // Lazy-loaded to avoid pulling @vitejs/plugin-rsc/rsc into modules that
37
49
  // import segment-resolution but never use loader caching.
38
50
  let _serializeResult: typeof import("../../cache/segment-codec.js").serializeResult;
@@ -123,6 +135,76 @@ export function resolveLoaderData<TEnv>(
123
135
  loaderEntry: LoaderEntry,
124
136
  ctx: HandlerContext<any, TEnv>,
125
137
  pathname: string,
138
+ bakeSegmentKey?: string | null,
139
+ ): Promise<any> {
140
+ // One ALS read serves the capture check, the record registration, and the
141
+ // seed lookup — this runs for every loader on every request.
142
+ const reqCtx = _getRequestContext();
143
+ // PPR shell capture policy — gated here, the single funnel every loader
144
+ // segment path routes through (fresh resolveLoaders, cache-hit
145
+ // resolveLoadersOnly, revalidation resolveLoadersOnlyWithRevalidation).
146
+ //
147
+ // Two lanes (docs/design/loader-container-bake.md):
148
+ // - LIVE lane (entry has renderable loading(); no `bakeSegmentKey`): never
149
+ // execute during capture. The slot gets a never-resolving promise so the
150
+ // LoaderBoundary postpones (a hole). See loader-mask.ts.
151
+ // - BAKE lane (no renderable loading(); callers pass `bakeSegmentKey`):
152
+ // execute during capture exactly like axis 1 — the settled container bakes
153
+ // into the prelude, nested pending promises postpone at the consumer's own
154
+ // Suspense. The container promise is registered on the derived context so
155
+ // captureAndStoreShell pins it into the snapshot's loader family; on a
156
+ // shell HIT the recorded container is overlaid onto the fresh run so the
157
+ // payload matches the frozen prelude byte-for-byte.
158
+ if (isShellCaptureActive(reqCtx)) {
159
+ if (!bakeSegmentKey) {
160
+ return createMaskedLoaderPromise();
161
+ }
162
+ const containerPromise = executeLoaderData(loaderEntry, ctx, pathname);
163
+ // Pre-attach a no-op catch: a bake-lane rejection during capture must
164
+ // surface through the drain's refusal (and the wrapper's error boundary),
165
+ // never as an unhandled rejection that can kill the worker before the
166
+ // drain probes this record.
167
+ containerPromise.catch(() => {});
168
+ // Nested-promise SHAPE is the liveness declaration: mask nested thenables
169
+ // in the capture's copy of the container so the consuming subtree
170
+ // postpones as a hole no matter when the promise settles, elide records a
171
+ // HOLE marker, and every HIT streams the fresh value. Without this, a
172
+ // nested promise that settled before the quiet window baked its value into
173
+ // the SHARED shell and the snapshot pinned it for every visitor
174
+ // (per-request basket data served cross-session, found live). The raw
175
+ // container is untouched: handler-side ctx.use consumption (the
176
+ // consumption-lane rule, semantic-matrix PPR3) keeps real values.
177
+ const maskedPromise = containerPromise.then((container: unknown) =>
178
+ maskNestedContainerThenables(container),
179
+ );
180
+ maskedPromise.catch(() => {});
181
+ reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
182
+ return maskedPromise;
183
+ }
184
+
185
+ if (bakeSegmentKey) {
186
+ const seed = reqCtx?._shellLoaderSeed;
187
+ if (seed && seed.has(bakeSegmentKey)) {
188
+ const recorded = seed.get(bakeSegmentKey);
189
+ // HIT tail: run fresh (only the loader body can mint the live nested
190
+ // promises), then pin the recorded paths over it. A fresh REJECTION here
191
+ // skips the overlay and flows to the per-loader error boundary — the
192
+ // payload then diverges from the prelude (same residual class as uncached
193
+ // nondeterminism in shell material).
194
+ return executeLoaderData(loaderEntry, ctx, pathname).then(
195
+ (fresh: unknown) => overlayLoaderContainer(fresh, recorded),
196
+ );
197
+ }
198
+ }
199
+
200
+ return executeLoaderData(loaderEntry, ctx, pathname);
201
+ }
202
+
203
+ /** The pre-policy loader execution: cache read-through or plain ctx.use. */
204
+ function executeLoaderData<TEnv>(
205
+ loaderEntry: LoaderEntry,
206
+ ctx: HandlerContext<any, TEnv>,
207
+ pathname: string,
126
208
  ): Promise<any> {
127
209
  const cacheConfig = loaderEntry.cache;
128
210
 
@@ -202,11 +284,20 @@ export function resolveLoaderData<TEnv>(
202
284
  ctx.params,
203
285
  );
204
286
 
287
+ // Capture the request context up front (foreground, ALS present) so the
288
+ // background stale revalidation can re-establish it. On workerd a waitUntil
289
+ // task runs detached from the request's I/O context, so a loader body that
290
+ // reads the ambient getRequestContext() would otherwise throw "called
291
+ // outside of a request context" and the revalidation would fail silently.
292
+ // The wrap is applied via wrapBackground (background path only); the
293
+ // foreground miss runs execute() directly since its context is present.
294
+ const requestCtxForExecute = getRequestContext();
205
295
  return readThroughItem({
206
296
  getItem: (k) => store.getItem!(k),
207
297
  setItem: (k, v, o) => store.setItem!(k, v, o),
208
298
  key,
209
299
  execute: () => runMiss(loaderEntry.loader),
300
+ wrapBackground: (run) => runWithRequestContext(requestCtxForExecute, run),
210
301
  serialize: (d) => codec.serializeResult(d),
211
302
  deserialize: (v) => codec.deserializeResult(v),
212
303
  storeOptions: { ttl, swr, tags },
@@ -214,7 +305,7 @@ export function resolveLoaderData<TEnv>(
214
305
  onStale: () => debugLoaderCacheLog(`[LoaderCache] STALE: ${key}`),
215
306
  onMiss: () => debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`),
216
307
  onCached: () => debugLoaderCacheLog(`[LoaderCache] Cached: ${key}`),
217
- host: getRequestContext(),
308
+ host: requestCtxForExecute,
218
309
  });
219
310
  })();
220
311
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * PPR shell-capture loader masking.
3
+ *
4
+ * During a shell CAPTURE re-render (Axis 2, see docs/design/ppr-shell-resume.md)
5
+ * route loaders are the "live lane": they must NOT execute — no side effects, no
6
+ * cost, no cache round-trips. Instead every loader segment's value slot receives
7
+ * a never-resolving promise, so the loader-consuming Suspense subtree stays
8
+ * pending and React's static `prerender` marks it as a postponed hole. The frozen
9
+ * shell (prelude) captures only the fallback; the resumed serve pass runs the
10
+ * loaders fresh through the unchanged execution path and streams their output
11
+ * into the holes.
12
+ *
13
+ * Capture mode is signalled by `requestCtx._shellCaptureRun`, set to true ONLY on
14
+ * the derived request context of the background capture task (shell-capture.ts) —
15
+ * NOT by the foreground render, whose `_shellCapture` descriptor merely means "a
16
+ * capture is wanted" and must not change behavior. This module is the single home
17
+ * for the mask so every loader execution site gates the same way (loader-cache.ts
18
+ * `resolveLoaderData`, fresh.ts `resolveLoaders`).
19
+ */
20
+
21
+ import {
22
+ _getRequestContext,
23
+ type RequestContext,
24
+ } from "../../server/request-context.js";
25
+
26
+ /**
27
+ * True when the current render is the active PPR shell capture and route loaders
28
+ * must be masked rather than executed. Reads `_shellCaptureRun` off the ALS
29
+ * request context (the capture task re-establishes its derived context via
30
+ * runWithRequestContext), so it is accurate at the loader resolution sites, which
31
+ * run synchronously inside the pipeline's context frame.
32
+ */
33
+ export function isShellCaptureActive(
34
+ reqCtx: RequestContext<any> | undefined = _getRequestContext(),
35
+ ): boolean {
36
+ return reqCtx?._shellCaptureRun === true;
37
+ }
38
+
39
+ // createMaskedLoaderPromise moved to the leaf module mask-nested.ts (shared
40
+ // with the handle-push funnel in request-context, which cannot import THIS
41
+ // module without a cycle). Re-exported to keep the mask API in one place.
42
+ export { createMaskedLoaderPromise } from "./mask-nested.js";
43
+
44
+ /**
45
+ * Lane decision for an entry's loaders under PPR (the loading() value decides;
46
+ * docs/design/loader-container-bake.md):
47
+ *
48
+ * - RENDERABLE loading() (the LoaderBoundary Suspense fallback) — the LIVE
49
+ * lane: masked at capture, guaranteed fresh on every serve. Returns true.
50
+ * - No loading() (absent, or explicitly `false`, incl. `loading(x, { ssr:
51
+ * false })` under the SSR manifest) — the BAKE lane: the loader executes
52
+ * during capture, its settled container bakes, nested pending promises hole
53
+ * at the consumer's own Suspense. Returns false.
54
+ *
55
+ * Mirrors segment-system's isRenderableLoading so the mask decision and the
56
+ * tree's boundary placement can never disagree.
57
+ */
58
+ export function entryLoadingMasksLoaders(loading: unknown): boolean {
59
+ return loading !== undefined && loading !== null && loading !== false;
60
+ }