@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
@@ -63,7 +63,8 @@ export function tryTrieMatch(
63
63
  // same value the regex matcher produces for the bare prefix. Without this
64
64
  // the trie misses, the regex fallback runs, and its no-config branch emits
65
65
  // a corrupt slice-off redirect. The static terminal still wins above.
66
- if (trie.w) {
66
+ // A one-or-more catch-all (`w1`, from `:name+`) rejects this empty case.
67
+ if (trie.w && !trie.w.w1) {
67
68
  return validateAndBuild(
68
69
  trie.w,
69
70
  [],
@@ -192,8 +193,9 @@ function walkTrie(
192
193
  // walkTrie otherwise only reaches node.w in the index<length branch below,
193
194
  // so without this a request to the wildcard's own prefix misses the trie
194
195
  // and the regex fallback emits a corrupt redirect. A static terminal
195
- // (node.r) still wins.
196
- if (node.w) {
196
+ // (node.r) still wins. A one-or-more catch-all (`w1`, from `:name+`) rejects
197
+ // this empty case — it requires at least one trailing segment.
198
+ if (node.w && !node.w.w1) {
197
199
  const validatedParams = leafConstraintsPass(node.w, paramValues, "");
198
200
  if (validatedParams) {
199
201
  return {
@@ -250,14 +252,20 @@ function walkTrie(
250
252
 
251
253
  if (node.w) {
252
254
  const rest = joinRemainingSegments(segments, index);
253
- const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
254
- if (validatedParams) {
255
- return {
256
- leaf: node.w,
257
- paramValues: [...paramValues],
258
- wildcardValue: rest,
259
- validatedParams,
260
- };
255
+ // A one-or-more catch-all (`w1`, from `:name+`) requires at least one
256
+ // non-empty trailing segment. `rest` can still be "" here on a malformed
257
+ // double-slash URL (e.g. `/docs//` splits to a trailing "" segment), so
258
+ // guard this in-path site the same way the root and base-case sites are.
259
+ if (!(node.w.w1 && rest === "")) {
260
+ const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
261
+ if (validatedParams) {
262
+ return {
263
+ leaf: node.w,
264
+ paramValues: [...paramValues],
265
+ wildcardValue: rest,
266
+ validatedParams,
267
+ };
268
+ }
261
269
  }
262
270
  }
263
271
 
@@ -42,3 +42,16 @@ export function encodePathSegment(value: string): string {
42
42
  (match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
43
43
  );
44
44
  }
45
+
46
+ /**
47
+ * Encode a catch-all remainder: encode each `/`-separated segment but keep the
48
+ * separators, so `a/b c` -> `a/b%20c` (not `a%2Fb%20c`). Shared by the reverse
49
+ * helper and the build-time prerender substitution so both produce identical
50
+ * URLs. `encode` defaults to the path-safe `encodePathSegment`.
51
+ */
52
+ export function encodePathRemainder(
53
+ value: string,
54
+ encode: (segment: string) => string = encodePathSegment,
55
+ ): string {
56
+ return value.split("/").map(encode).join("/");
57
+ }
package/src/router.ts CHANGED
@@ -111,6 +111,10 @@ import {
111
111
  } from "./router/prerender-match.js";
112
112
  import { resolveStateCookieName } from "./router/state-cookie-name.js";
113
113
  import { resolvePrefetchCacheTTL } from "./router/prefetch-cache-ttl.js";
114
+ import {
115
+ resolvePrefetchCacheSize,
116
+ resolvePrefetchConcurrency,
117
+ } from "./router/prefetch-limits.js";
114
118
 
115
119
  // Re-export public types and values from extracted modules
116
120
  export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
@@ -150,6 +154,8 @@ export function createRouter<TEnv = any>(
150
154
  nonce,
151
155
  version,
152
156
  prefetchCacheTTL: prefetchCacheTTLOption,
157
+ prefetchCacheSize: prefetchCacheSizeOption,
158
+ prefetchConcurrency: prefetchConcurrencyOption,
153
159
  stateCookiePrefix: stateCookiePrefixOption,
154
160
  warmup: warmupOption,
155
161
  allowDebugManifest: allowDebugManifestOption = false,
@@ -242,6 +248,14 @@ export function createRouter<TEnv = any>(
242
248
  const prefetchCacheControl: string | false =
243
249
  resolvedPrefetchCacheTTL.cacheControl;
244
250
 
251
+ // Resolve client-side prefetch limits (in-memory cache size and queue
252
+ // concurrency). Both are positive-integer counts; sub-1/non-finite inputs
253
+ // fall back to the defaults. Shipped to the client in payload metadata.
254
+ const prefetchCacheSize = resolvePrefetchCacheSize(prefetchCacheSizeOption);
255
+ const prefetchConcurrency = resolvePrefetchConcurrency(
256
+ prefetchConcurrencyOption,
257
+ );
258
+
245
259
  // Resolve warmup enabled flag (default: true)
246
260
  const warmupEnabled = warmupOption !== false;
247
261
 
@@ -406,14 +420,17 @@ export function createRouter<TEnv = any>(
406
420
 
407
421
  // Wrapper to pass debugPerformance to external createMetricsStore.
408
422
  // Also checks per-request flag set by ctx.debugPerformance() in middleware.
423
+ // With no active request context there is nowhere to hang the store, so return
424
+ // undefined: an orphan store would collect metrics no reader can reach (nothing
425
+ // holds it, and appendMetric(undefined, ...) is already a no-op).
409
426
  const getMetricsStore = () => {
410
427
  const reqCtx = _getRequestContext();
411
428
  const enabled = debugPerformance || !!reqCtx?._debugPerformance;
412
- if (!enabled) return undefined;
413
- if (!reqCtx) {
414
- return createMetricsStore(true);
415
- }
416
- reqCtx._metricsStore ??= createMetricsStore(true);
429
+ if (!enabled || !reqCtx) return undefined;
430
+ // Anchor a mid-request store to the true request entry (reqCtx._handlerStart),
431
+ // not this call's performance.now(); undefined falls back to now() inside
432
+ // createMetricsStore (metrics.ts).
433
+ reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
417
434
  return reqCtx._metricsStore;
418
435
  };
419
436
 
@@ -424,11 +441,6 @@ export function createRouter<TEnv = any>(
424
441
  const findNearestNotFoundBoundary = (entry: EntryData | null) =>
425
442
  findNotFoundBoundary(entry, defaultNotFoundBoundary);
426
443
 
427
- // Helper to get handleStore from request context
428
- const getHandleStore = (): HandleStore | undefined => {
429
- return _getRequestContext()?._handleStore;
430
- };
431
-
432
444
  // Track a pending handler promise (non-blocking).
433
445
  // Attaches a side-effect .catch() to report streaming handler errors to onError
434
446
  // without altering the rejection chain (React's streaming error boundary still handles it).
@@ -439,13 +451,14 @@ export function createRouter<TEnv = any>(
439
451
  segmentType?: string;
440
452
  },
441
453
  ): Promise<T> => {
442
- const store = getHandleStore();
454
+ // One ALS read serves both the store lookup and the onError closure.
455
+ const reqCtx = _getRequestContext();
456
+ const store = reqCtx?._handleStore;
443
457
  const tracked = store ? store.track(promise) : promise;
444
458
 
445
459
  // Report streaming handler errors to onError as a side-effect.
446
460
  // The rejection still propagates to the RSC stream for client error boundaries.
447
461
  // Captures request context eagerly (closure) so the catch handler has full context.
448
- const reqCtx = _getRequestContext();
449
462
  if (reqCtx && onError) {
450
463
  tracked.catch((error) => {
451
464
  callOnError(error, "handler", {
@@ -487,8 +500,12 @@ export function createRouter<TEnv = any>(
487
500
  ? getRequestId(errorContext.request)
488
501
  : undefined
489
502
  : undefined;
503
+ // Derived once here for both the loader.start and loader.end emits (the
504
+ // loader.error emit uses ctx.loaderName from wrapLoaderWithErrorHandling).
505
+ const loaderName = telemetrySink
506
+ ? segmentId.split(".").pop() || "unknown"
507
+ : "";
490
508
  if (telemetrySink) {
491
- const loaderName = segmentId.split(".").pop() || "unknown";
492
509
  safeEmit(telemetry, {
493
510
  type: "loader.start",
494
511
  timestamp: loaderStart,
@@ -542,7 +559,6 @@ export function createRouter<TEnv = any>(
542
559
 
543
560
  // Emit loader.end after the promise settles (fire-and-forget)
544
561
  if (telemetrySink) {
545
- const loaderName = segmentId.split(".").pop() || "unknown";
546
562
  result.then((r) => {
547
563
  safeEmit(telemetry, {
548
564
  type: "loader.end",
@@ -606,8 +622,15 @@ export function createRouter<TEnv = any>(
606
622
  routerId,
607
623
  };
608
624
 
609
- function evaluateLazyEntry(entry: RouteEntry<TEnv>): void {
610
- _evaluateLazyEntry(entry, lazyEvalDeps);
625
+ // Must return the Promise from _evaluateLazyEntry: an async include provider
626
+ // (`() => import("./routes")`) resolves off the startup path, and createFindMatch
627
+ // awaits this to know when the import + expansion have completed. Dropping it
628
+ // (typing this `void`) makes the import fire-and-forget, so findMatch spins the
629
+ // lazy-eval retry loop to its cap and returns null on the first request to any
630
+ // async include whose prefix isn't already covered by a unique precomputed entry
631
+ // (nested includes, shared prefixes, regex fallback).
632
+ function evaluateLazyEntry(entry: RouteEntry<TEnv>): void | Promise<void> {
633
+ return _evaluateLazyEntry(entry, lazyEvalDeps);
611
634
  }
612
635
 
613
636
  // Create findMatch with single-entry cache, bound to router state
@@ -968,6 +991,8 @@ export function createRouter<TEnv = any>(
968
991
  // Expose prefetch cache settings
969
992
  prefetchCacheControl,
970
993
  prefetchCacheTTL,
994
+ prefetchCacheSize,
995
+ prefetchConcurrency,
971
996
 
972
997
  // Expose the resolved rango state cookie name for the server-side writer
973
998
  // (invalidateClientCache) and for shipping to the client in metadata.
@@ -985,6 +1010,12 @@ export function createRouter<TEnv = any>(
985
1010
  // Expose resolved span tracing for the handler (Cloudflare custom spans)
986
1011
  tracing: resolvedTracing,
987
1012
 
1013
+ // Expose the raw telemetry sink so handler-level emitters (timeout, origin
1014
+ // rejection, late-handle handler.error) can emit outside the match ALS.
1015
+ // Raw (not the resolveSink no-op wrapper) so router.telemetry stays
1016
+ // undefined when unconfigured and call sites gate on truthiness.
1017
+ telemetry: telemetrySink,
1018
+
988
1019
  // Expose debug manifest flag for handler
989
1020
  allowDebugManifest: allowDebugManifestOption,
990
1021
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Full (initial-load / document) RSC payload builder.
3
+ *
4
+ * Extracted from rsc-rendering.ts so both the foreground render AND the PPR
5
+ * background shell capture (shell-capture.ts) build the SAME payload shape over
6
+ * their own handle store. `resume` requires the captured tree to match the served
7
+ * tree; building an identical payload over the same replayed segments is what
8
+ * makes that hold. Keep this byte-identical to the normal full-render path.
9
+ */
10
+
11
+ import type { MatchResult } from "../types.js";
12
+ import type { RscPayload } from "./types.js";
13
+ import type { HandlerContext } from "./handler-context.js";
14
+ import type { RequestContext } from "../server/request-context.js";
15
+ import type { HandleStore } from "../server/handle-store.js";
16
+ import { gateTransitions } from "./transition-gate.js";
17
+ import { resolvedHandleStream } from "../handles/deferred-resolution.js";
18
+
19
+ /**
20
+ * Build the metadata payload for a full (non-partial) document render.
21
+ *
22
+ * @param handleStore - the store whose resolved handle stream feeds the payload;
23
+ * the foreground passes reqCtx's store, the background capture passes its fresh
24
+ * derived-context store.
25
+ */
26
+ export function buildFullPayload(
27
+ m: MatchResult,
28
+ // env is opaque here (the payload never reads it); `any` avoids the invariance
29
+ // friction of HandlerContext<TEnv> vs the ambient RequestContext env at the two
30
+ // call sites (foreground render + background capture).
31
+ ctx: HandlerContext<any>,
32
+ url: URL,
33
+ reqCtx: RequestContext<any>,
34
+ handleStore: HandleStore,
35
+ ): RscPayload {
36
+ return {
37
+ metadata: {
38
+ pathname: url.pathname,
39
+ routerId: ctx.router.id,
40
+ basename: ctx.router.basename,
41
+ segments: gateTransitions(m.segments, reqCtx, ctx.router.onError),
42
+ matched: m.matched,
43
+ diff: m.diff,
44
+ resolvedIds: m.resolvedIds,
45
+ params: m.params,
46
+ isPartial: false,
47
+ rootLayout: ctx.router.rootLayout,
48
+ // Full render: resolve deferred handle values server-side so SSR markup and
49
+ // the first sync useHandle read see resolved values. Partial payloads
50
+ // (rsc-rendering.ts) keep streaming (handleStore.stream()).
51
+ handles: resolvedHandleStream(handleStore),
52
+ version: ctx.version,
53
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
54
+ prefetchCacheSize: ctx.router.prefetchCacheSize,
55
+ prefetchConcurrency: ctx.router.prefetchConcurrency,
56
+ stateCookieName: ctx.router.resolvedStateCookieName,
57
+ themeConfig: ctx.router.themeConfig,
58
+ // Carry warmupEnabled on the initial full-render payload so the client
59
+ // respects warmup:false from first load. The 404 and PE payloads already
60
+ // include it; without it here warmup could never be disabled on the
61
+ // normal full-load path (partial payloads omit it by design).
62
+ warmupEnabled: ctx.router.warmupEnabled,
63
+ // Carry strictMode on the initial full-render payload so the browser
64
+ // entry knows whether to wrap hydration in React.StrictMode. Partial
65
+ // (navigation) payloads omit it by design; StrictMode is decided once.
66
+ strictMode: ctx.router.strictMode,
67
+ initialTheme: reqCtx.theme,
68
+ },
69
+ };
70
+ }
@@ -31,6 +31,7 @@ import {
31
31
  buildRouteMiddlewareEntries,
32
32
  } from "./helpers.js";
33
33
  import { guardOutgoingRedirect } from "./redirect-guard.js";
34
+ import { resolvedHandleStream } from "../handles/deferred-resolution.js";
34
35
  import {
35
36
  isWebSocketUpgradeResponse,
36
37
  appendVaryAccept,
@@ -84,7 +85,8 @@ import {
84
85
  appendMetric,
85
86
  buildMetricsTiming,
86
87
  } from "../router/metrics.js";
87
- import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
88
+ import { observePhase, PHASES } from "../router/instrument.js";
89
+ import { safeEmit, resolveSink, getRequestId } from "../router/telemetry.js";
88
90
  import {
89
91
  startSSRSetup,
90
92
  getSSRSetup,
@@ -245,16 +247,19 @@ export function createRSCHandler<
245
247
  metadata: { timeout: true, phase, durationMs },
246
248
  });
247
249
 
248
- observeEvent({
249
- type: "request.timeout",
250
- timestamp: performance.now(),
251
- phase,
252
- pathname: url.pathname,
253
- routeKey,
254
- actionId,
255
- durationMs,
256
- customHandler: !!router.onTimeout,
257
- });
250
+ if (router.telemetry) {
251
+ safeEmit(resolveSink(router.telemetry), {
252
+ type: "request.timeout",
253
+ timestamp: performance.now(),
254
+ requestId: getRequestId(request),
255
+ phase,
256
+ pathname: url.pathname,
257
+ routeKey,
258
+ actionId,
259
+ durationMs,
260
+ customHandler: !!router.onTimeout,
261
+ });
262
+ }
258
263
 
259
264
  if (router.onTimeout) {
260
265
  try {
@@ -461,6 +466,11 @@ export function createRSCHandler<
461
466
  stateCookieName: router.resolvedStateCookieName,
462
467
  version,
463
468
  });
469
+ // Thread the true request entry timestamp onto the context so a metrics
470
+ // store created MID-request (ctx.debugPerformance() / getMetricsStore) anchors
471
+ // to the real start, not the opt-in moment — phases that began earlier then
472
+ // report non-negative offsets. Set unconditionally: debug may be enabled later.
473
+ requestContext._handlerStart = handlerStart;
464
474
  if (earlyMetricsStore) {
465
475
  requestContext._debugPerformance = true;
466
476
  requestContext._metricsStore = earlyMetricsStore;
@@ -568,8 +578,10 @@ export function createRSCHandler<
568
578
  if (metricsStore) {
569
579
  // When the store was created at handler start (earlyMetricsStore),
570
580
  // handler:total covers the full request. When ctx.debugPerformance()
571
- // created the store mid-request, use its requestStart to avoid a
572
- // negative startTime offset.
581
+ // created the store mid-request its requestStart is now the threaded
582
+ // _handlerStart (== handlerStart), so both branches yield the true
583
+ // request entry; reading the store's own anchor keeps this correct even
584
+ // if a store ever lands without the threading (falls back to its start).
573
585
  const totalStart = earlyMetricsStore
574
586
  ? handlerStart
575
587
  : metricsStore.requestStart;
@@ -589,7 +601,13 @@ export function createRSCHandler<
589
601
 
590
602
  const fullTiming = timingParts.join(", ");
591
603
  if (fullTiming && !isWebSocketUpgradeResponse(response)) {
592
- response.headers.set("Server-Timing", fullTiming);
604
+ try {
605
+ response.headers.set("Server-Timing", fullTiming);
606
+ } catch {
607
+ // Immutable headers (e.g. a passed-through platform Response) — drop
608
+ // the timing header, never the response. Instrumentation must not
609
+ // 500 a request.
610
+ }
593
611
  }
594
612
 
595
613
  // Single open-redirect chokepoint: every response (PE, full-page,
@@ -736,15 +754,18 @@ export function createRSCHandler<
736
754
  },
737
755
  });
738
756
 
739
- observeEvent({
740
- type: "request.origin-rejected",
741
- timestamp: performance.now(),
742
- method: request.method,
743
- pathname: url.pathname,
744
- phase: originPhase,
745
- origin: request.headers.get("origin"),
746
- host: request.headers.get("host"),
747
- });
757
+ if (router.telemetry) {
758
+ safeEmit(resolveSink(router.telemetry), {
759
+ type: "request.origin-rejected",
760
+ timestamp: performance.now(),
761
+ requestId: getRequestId(request),
762
+ method: request.method,
763
+ pathname: url.pathname,
764
+ phase: originPhase,
765
+ origin: request.headers.get("origin"),
766
+ host: request.headers.get("host"),
767
+ });
768
+ }
748
769
 
749
770
  return originResult;
750
771
  }
@@ -786,15 +807,18 @@ export function createRSCHandler<
786
807
  params: reqCtx.params as Record<string, string>,
787
808
  handledByBoundary: true,
788
809
  });
789
- observeEvent({
790
- type: "handler.error",
791
- timestamp: performance.now(),
792
- error,
793
- handledByBoundary: true,
794
- pathname: url.pathname,
795
- routeKey: reqCtx._routeName,
796
- params: reqCtx.params as Record<string, string>,
797
- });
810
+ if (router.telemetry) {
811
+ safeEmit(resolveSink(router.telemetry), {
812
+ type: "handler.error",
813
+ timestamp: performance.now(),
814
+ requestId: getRequestId(request),
815
+ error,
816
+ handledByBoundary: true,
817
+ pathname: url.pathname,
818
+ routeKey: reqCtx._routeName,
819
+ params: reqCtx.params as Record<string, string>,
820
+ });
821
+ }
798
822
  };
799
823
 
800
824
  // Set route params early so all execution paths can access ctx.params.
@@ -1131,9 +1155,12 @@ export function createRSCHandler<
1131
1155
  params: {},
1132
1156
  isPartial: false,
1133
1157
  rootLayout: router.rootLayout,
1134
- handles: handleStore.stream(),
1158
+ // Full (404) render: resolve deferred handle values server-side.
1159
+ handles: resolvedHandleStream(handleStore),
1135
1160
  version,
1136
1161
  prefetchCacheTTL: router.prefetchCacheTTL,
1162
+ prefetchCacheSize: router.prefetchCacheSize,
1163
+ prefetchConcurrency: router.prefetchConcurrency,
1137
1164
  stateCookieName: router.resolvedStateCookieName,
1138
1165
  themeConfig: router.themeConfig,
1139
1166
  warmupEnabled: router.warmupEnabled,
@@ -32,7 +32,7 @@ export async function buildRouterTrieFromUrlpatterns(
32
32
  ): Promise<void> {
33
33
  const { generateManifestFull } =
34
34
  await import("../build/generate-manifest.js");
35
- const generated = generateManifestFull(
35
+ const generated = await generateManifestFull(
36
36
  router.urlpatterns,
37
37
  undefined,
38
38
  router.basename ? { urlPrefix: router.basename } : undefined,
package/src/rsc/nonce.ts CHANGED
@@ -8,11 +8,20 @@ import { createVar } from "../context-var.js";
8
8
  /**
9
9
  * Typed ContextVar token for CSP nonce.
10
10
  *
11
- * Use this to access the nonce in middleware or handlers:
11
+ * Use this to READ the nonce in middleware or handlers:
12
12
  * ```ts
13
13
  * import { nonce } from "@rangojs/router";
14
14
  * const value = ctx.get(nonce); // string | undefined
15
15
  * ```
16
+ *
17
+ * Supply the nonce via the `createRouter({ nonce })` provider, not by writing
18
+ * this token yourself. The provider value is threaded into the router's SSR
19
+ * machinery (NonceContext/useNonce, Scripts/MetaTags attributes, the inlined
20
+ * Flight payload scripts) AND sets this token. A direct `ctx.set(nonce, value)`
21
+ * in middleware runs AFTER the SSR nonce is resolved: the value is readable via
22
+ * `ctx.get(nonce)` and gates PPR shell capture (a per-request nonce must never
23
+ * bake into a shared shell), but the router will not apply it to its own
24
+ * scripts — useNonce() stays undefined for that request.
16
25
  */
17
26
  export const nonce: ContextVar<string> = createVar<string>();
18
27
 
@@ -14,6 +14,8 @@ import { getSSRSetup } from "./ssr-setup.js";
14
14
  import type { MiddlewareFn } from "../router/middleware.js";
15
15
  import { executeMiddleware } from "../router/middleware.js";
16
16
  import { observePhase, PHASES } from "../router/instrument.js";
17
+ import { gateTransitions } from "./transition-gate.js";
18
+ import { resolvedHandleStream } from "../handles/deferred-resolution.js";
17
19
  import type { RscPayload, ReactFormState } from "./types.js";
18
20
  import {
19
21
  createResponseWithMergedHeaders,
@@ -152,6 +154,7 @@ export async function handleProgressiveEnhancement<TEnv>(
152
154
  handleStore,
153
155
  nonce,
154
156
  useActionStateId,
157
+ true, // an action ran and threw
155
158
  );
156
159
  if (errorHtml) return errorHtml;
157
160
 
@@ -205,6 +208,7 @@ export async function handleProgressiveEnhancement<TEnv>(
205
208
  handleStore,
206
209
  nonce,
207
210
  directActionId,
211
+ true, // an action ran and threw
208
212
  );
209
213
  if (errorHtml) return errorHtml;
210
214
 
@@ -269,6 +273,14 @@ export async function handleProgressiveEnhancement<TEnv>(
269
273
  headers,
270
274
  });
271
275
 
276
+ // JS/PE parity: this is an action's revalidation render, so mark it BEFORE
277
+ // matching — a stale `foregroundOnAction` cache entry must re-execute in the
278
+ // foreground during the re-render, exactly as the JS path's
279
+ // revalidateAfterAction does. The transition({ when }) gate fields below are
280
+ // set post-match (the gate reads them after rendering); foregroundOnAction
281
+ // reads _inActionRevalidation during the match, so it must be set here.
282
+ getRequestContext()._inActionRevalidation = true;
283
+
272
284
  const match = await ctx.router.match(renderRequest, { env });
273
285
 
274
286
  if (match.redirect) {
@@ -278,19 +290,34 @@ export async function handleProgressiveEnhancement<TEnv>(
278
290
  });
279
291
  }
280
292
 
293
+ // Expose the no-JS action to the transition({ when }) gate. currentUrl/Params
294
+ // are absent on this full-render path (no navigation snapshot); useActionState
295
+ // ids are block-scoped, so only a direct action id is available here.
296
+ // actionUrl is the page the action was submitted from (this request's url).
297
+ const peReqCtx = getRequestContext();
298
+ peReqCtx._gateActionId = directActionId ?? undefined;
299
+ peReqCtx._gateActionUrl = new URL(url);
300
+ peReqCtx._gateActionResult = actionResult;
301
+ peReqCtx._gateFormData = formData;
302
+
281
303
  const payload: RscPayload = {
282
304
  metadata: {
283
305
  pathname: url.pathname,
284
306
  routerId: ctx.router.id,
285
307
  basename: ctx.router.basename,
286
- segments: match.segments,
308
+ segments: gateTransitions(
309
+ match.segments,
310
+ getRequestContext(),
311
+ ctx.router.onError,
312
+ ),
287
313
  matched: match.matched,
288
314
  diff: match.diff,
289
315
  resolvedIds: match.resolvedIds,
290
316
  params: match.params,
291
317
  isPartial: false,
292
318
  rootLayout: ctx.router.rootLayout,
293
- handles: handleStore.stream(),
319
+ // PE full render: resolve deferred handle values server-side.
320
+ handles: resolvedHandleStream(handleStore),
294
321
  version: ctx.version,
295
322
  stateCookieName: ctx.router.resolvedStateCookieName,
296
323
  themeConfig: ctx.router.themeConfig,
@@ -368,7 +395,22 @@ async function renderPeErrorBoundary<TEnv>(
368
395
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
369
396
  nonce: string | undefined,
370
397
  actionId?: string | null,
398
+ // True when an action actually ran and threw (vs a malformed form body, where
399
+ // no action executed). Drives _inActionRevalidation for JS/PE parity — it must
400
+ // NOT be inferred from actionId, since a useActionState bound action can run
401
+ // and throw with no $$id (actionId === undefined) yet still be an action error.
402
+ actionRan = false,
371
403
  ): Promise<Response | null> {
404
+ // JS/PE parity for an action-triggered error re-render: a stale
405
+ // `foregroundOnAction` cache entry inside the error boundary must foreground
406
+ // too, exactly as the JS path (revalidateAfterAction sets this unconditionally
407
+ // before rendering the error boundary). Set BEFORE matchError (the cached fn
408
+ // runs during it). Gated on actionRan, NOT actionId — a malformed form body
409
+ // (actionRan=false) ran no action and must keep SWR.
410
+ if (actionRan) {
411
+ getRequestContext()._inActionRevalidation = true;
412
+ }
413
+
372
414
  let errorResult;
373
415
  try {
374
416
  errorResult = await ctx.router.matchError(request, { env }, error, "route");
@@ -395,12 +437,26 @@ async function renderPeErrorBoundary<TEnv>(
395
437
 
396
438
  setRequestContextParams(errorResult.params, errorResult.routeName);
397
439
 
440
+ // Only the failing action id + URL are in scope here (no formData/actionResult
441
+ // thread into this helper). Expose the URL only when the action id is known:
442
+ // this helper also handles malformed form bodies before action detection, and
443
+ // those should not look like action-triggered renders to transition({ when }).
444
+ if (actionId != null) {
445
+ const peErrCtx = getRequestContext();
446
+ peErrCtx._gateActionId = actionId;
447
+ peErrCtx._gateActionUrl = new URL(url);
448
+ }
449
+
398
450
  const payload: RscPayload = {
399
451
  metadata: {
400
452
  pathname: url.pathname,
401
453
  routerId: ctx.router.id,
402
454
  basename: ctx.router.basename,
403
- segments: errorResult.segments,
455
+ segments: gateTransitions(
456
+ errorResult.segments,
457
+ getRequestContext(),
458
+ ctx.router.onError,
459
+ ),
404
460
  matched: errorResult.matched,
405
461
  diff: errorResult.diff,
406
462
  resolvedIds: errorResult.resolvedIds,
@@ -408,7 +464,8 @@ async function renderPeErrorBoundary<TEnv>(
408
464
  isPartial: false,
409
465
  isError: true,
410
466
  rootLayout: ctx.router.rootLayout,
411
- handles: handleStore.stream(),
467
+ // PE error-boundary full render: resolve deferred handle values server-side.
468
+ handles: resolvedHandleStream(handleStore),
412
469
  version: ctx.version,
413
470
  stateCookieName: ctx.router.resolvedStateCookieName,
414
471
  themeConfig: ctx.router.themeConfig,
@@ -38,6 +38,7 @@ import {
38
38
  resolveSameOriginRedirect,
39
39
  resolveExternalRedirect,
40
40
  isExternalRedirect,
41
+ safeSameOriginLanding,
41
42
  EXTERNAL_REDIRECT_MARKER,
42
43
  } from "../redirect-origin.js";
43
44
  import { carryOverRedirectHeaders } from "./helpers.js";
@@ -79,7 +80,7 @@ export function guardOutgoingRedirect(
79
80
 
80
81
  // Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
81
82
  // landing.
82
- const safeTarget = basename && basename !== "/" ? basename : "/";
83
+ const safeTarget = safeSameOriginLanding(basename);
83
84
  if (process.env.NODE_ENV !== "production") {
84
85
  console.error(
85
86
  `[rango] Blocked cross-origin redirect to "${location}"; sent to ` +