@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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 (260) hide show
  1. package/dist/bin/rango.js +10 -6
  2. package/dist/testing/vitest.js +82 -0
  3. package/dist/vite/index.js +55 -48
  4. package/package.json +61 -21
  5. package/skills/caching/SKILL.md +2 -1
  6. package/skills/hooks/SKILL.md +40 -29
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +3 -1
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +12 -0
  15. package/skills/route/SKILL.md +10 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/src/__internal.ts +0 -65
  32. package/src/browser/action-coordinator.ts +1 -1
  33. package/src/browser/action-fence.ts +47 -0
  34. package/src/browser/cookie-name.ts +140 -0
  35. package/src/browser/event-controller.ts +1 -83
  36. package/src/browser/invalidate-client-cache.ts +52 -0
  37. package/src/browser/navigation-bridge.ts +14 -1
  38. package/src/browser/navigation-client.ts +14 -1
  39. package/src/browser/navigation-store-handle.ts +38 -0
  40. package/src/browser/navigation-store.ts +26 -51
  41. package/src/browser/navigation-transaction.ts +0 -32
  42. package/src/browser/partial-update.ts +1 -83
  43. package/src/browser/prefetch/cache.ts +6 -45
  44. package/src/browser/prefetch/fetch.ts +7 -0
  45. package/src/browser/prefetch/queue.ts +6 -3
  46. package/src/browser/rango-state.ts +157 -99
  47. package/src/browser/react/Link.tsx +0 -2
  48. package/src/browser/react/NavigationProvider.tsx +2 -1
  49. package/src/browser/react/ScrollRestoration.tsx +10 -6
  50. package/src/browser/react/filter-segment-order.ts +0 -2
  51. package/src/browser/react/index.ts +0 -51
  52. package/src/browser/react/location-state-shared.ts +0 -13
  53. package/src/browser/react/location-state.ts +0 -1
  54. package/src/browser/react/use-action.ts +6 -15
  55. package/src/browser/react/use-handle.ts +0 -5
  56. package/src/browser/react/use-link-status.ts +0 -4
  57. package/src/browser/react/use-navigation.ts +0 -3
  58. package/src/browser/react/use-params.ts +0 -2
  59. package/src/browser/react/use-search-params.ts +0 -5
  60. package/src/browser/react/use-segments.ts +0 -13
  61. package/src/browser/rsc-router.tsx +12 -4
  62. package/src/browser/server-action-bridge.ts +77 -15
  63. package/src/browser/types.ts +7 -2
  64. package/src/browser/validate-redirect-origin.ts +4 -5
  65. package/src/build/route-trie.ts +3 -0
  66. package/src/build/route-types/param-extraction.ts +6 -3
  67. package/src/build/route-types/router-processing.ts +0 -8
  68. package/src/cache/cache-policy.ts +0 -54
  69. package/src/cache/cache-runtime.ts +27 -24
  70. package/src/cache/cache-scope.ts +0 -27
  71. package/src/cache/cache-tag.ts +0 -37
  72. package/src/cache/cf/cf-cache-store.ts +94 -46
  73. package/src/cache/cf/index.ts +0 -24
  74. package/src/cache/document-cache.ts +11 -36
  75. package/src/cache/handle-snapshot.ts +0 -40
  76. package/src/cache/index.ts +0 -27
  77. package/src/cache/memory-segment-store.ts +2 -48
  78. package/src/cache/profile-registry.ts +7 -3
  79. package/src/cache/read-through-swr.ts +41 -11
  80. package/src/cache/segment-codec.ts +0 -16
  81. package/src/cache/types.ts +0 -98
  82. package/src/client.rsc.tsx +1 -22
  83. package/src/client.tsx +14 -38
  84. package/src/component-utils.ts +19 -0
  85. package/src/deps/ssr.ts +0 -1
  86. package/src/handle.ts +28 -18
  87. package/src/handles/MetaTags.tsx +0 -14
  88. package/src/handles/meta.ts +0 -39
  89. package/src/host/cookie-handler.ts +0 -36
  90. package/src/host/errors.ts +0 -24
  91. package/src/host/index.ts +6 -0
  92. package/src/host/pattern-matcher.ts +7 -50
  93. package/src/host/router.ts +1 -65
  94. package/src/host/testing.ts +40 -27
  95. package/src/host/types.ts +6 -2
  96. package/src/href-client.ts +0 -4
  97. package/src/index.rsc.ts +42 -3
  98. package/src/index.ts +31 -1
  99. package/src/internal-debug.ts +2 -4
  100. package/src/loader.rsc.ts +19 -9
  101. package/src/loader.ts +12 -4
  102. package/src/network-error-thrower.tsx +1 -6
  103. package/src/outlet-provider.tsx +1 -5
  104. package/src/prerender/param-hash.ts +10 -11
  105. package/src/prerender/store.ts +23 -30
  106. package/src/prerender.ts +58 -3
  107. package/src/root-error-boundary.tsx +1 -19
  108. package/src/route-content-wrapper.tsx +1 -44
  109. package/src/route-definition/dsl-helpers.ts +7 -19
  110. package/src/route-definition/helpers-types.ts +3 -3
  111. package/src/route-definition/redirect.ts +11 -1
  112. package/src/route-map-builder.ts +0 -16
  113. package/src/router/basename.ts +14 -0
  114. package/src/router/content-negotiation.ts +0 -13
  115. package/src/router/error-handling.ts +12 -16
  116. package/src/router/find-match.ts +4 -30
  117. package/src/router/intercept-resolution.ts +10 -1
  118. package/src/router/lazy-includes.ts +1 -57
  119. package/src/router/loader-resolution.ts +3 -2
  120. package/src/router/logging.ts +0 -6
  121. package/src/router/manifest.ts +1 -25
  122. package/src/router/match-api.ts +0 -20
  123. package/src/router/match-context.ts +0 -22
  124. package/src/router/match-handlers.ts +57 -58
  125. package/src/router/match-middleware/background-revalidation.ts +0 -7
  126. package/src/router/match-middleware/cache-lookup.ts +1 -54
  127. package/src/router/match-middleware/cache-store.ts +0 -31
  128. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  129. package/src/router/match-middleware/segment-resolution.ts +0 -21
  130. package/src/router/match-pipelines.ts +1 -42
  131. package/src/router/match-result.ts +1 -52
  132. package/src/router/metrics.ts +0 -34
  133. package/src/router/middleware-cookies.ts +0 -13
  134. package/src/router/middleware-types.ts +0 -115
  135. package/src/router/middleware.ts +7 -30
  136. package/src/router/navigation-snapshot.ts +0 -51
  137. package/src/router/params-util.ts +23 -0
  138. package/src/router/pattern-matching.ts +1 -33
  139. package/src/router/prerender-match.ts +33 -45
  140. package/src/router/request-classification.ts +1 -38
  141. package/src/router/revalidation.ts +5 -58
  142. package/src/router/router-context.ts +0 -26
  143. package/src/router/router-interfaces.ts +7 -0
  144. package/src/router/router-options.ts +30 -0
  145. package/src/router/segment-resolution/fresh.ts +25 -57
  146. package/src/router/segment-resolution/helpers.ts +34 -0
  147. package/src/router/segment-resolution/loader-cache.ts +10 -13
  148. package/src/router/segment-resolution/revalidation.ts +5 -42
  149. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  150. package/src/router/segment-resolution.ts +4 -1
  151. package/src/router/state-cookie-name.ts +33 -0
  152. package/src/router/telemetry-otel.ts +0 -20
  153. package/src/router/telemetry.ts +96 -19
  154. package/src/router/timeout.ts +0 -20
  155. package/src/router/trie-matching.ts +63 -40
  156. package/src/router/types.ts +1 -63
  157. package/src/router/url-params.ts +0 -5
  158. package/src/router.ts +40 -9
  159. package/src/rsc/handler.ts +14 -2
  160. package/src/rsc/helpers.ts +34 -0
  161. package/src/rsc/origin-guard.ts +0 -12
  162. package/src/rsc/progressive-enhancement.ts +4 -1
  163. package/src/rsc/rsc-rendering.ts +4 -7
  164. package/src/rsc/runtime-warnings.ts +14 -0
  165. package/src/rsc/server-action.ts +30 -28
  166. package/src/rsc/types.ts +2 -1
  167. package/src/runtime-env.ts +18 -0
  168. package/src/search-params.ts +0 -16
  169. package/src/segment-loader-promise.ts +14 -2
  170. package/src/segment-system.tsx +79 -88
  171. package/src/server/cookie-store.ts +52 -1
  172. package/src/server/handle-store.ts +7 -24
  173. package/src/server/loader-registry.ts +5 -24
  174. package/src/server/request-context.ts +74 -77
  175. package/src/ssr/index.tsx +14 -14
  176. package/src/static-handler.ts +10 -13
  177. package/src/testing/cache-status.ts +119 -0
  178. package/src/testing/collect-handle.ts +40 -0
  179. package/src/testing/dispatch.ts +581 -0
  180. package/src/testing/dom.entry.ts +22 -0
  181. package/src/testing/e2e/fixture.ts +188 -0
  182. package/src/testing/e2e/index.ts +127 -0
  183. package/src/testing/e2e/matchers.ts +35 -0
  184. package/src/testing/e2e/page-helpers.ts +272 -0
  185. package/src/testing/e2e/parity.ts +387 -0
  186. package/src/testing/e2e/server.ts +195 -0
  187. package/src/testing/flight-matchers.ts +97 -0
  188. package/src/testing/flight-normalize.ts +11 -0
  189. package/src/testing/flight-runtime.d.ts +57 -0
  190. package/src/testing/flight-tree.ts +682 -0
  191. package/src/testing/flight.entry.ts +52 -0
  192. package/src/testing/flight.ts +186 -0
  193. package/src/testing/generated-routes.ts +183 -0
  194. package/src/testing/index.ts +98 -0
  195. package/src/testing/internal/context.ts +348 -0
  196. package/src/testing/internal/flight-client-globals.ts +30 -0
  197. package/src/testing/internal/seed-vars.ts +54 -0
  198. package/src/testing/render-handler.ts +311 -0
  199. package/src/testing/render-route.tsx +504 -0
  200. package/src/testing/run-loader.ts +378 -0
  201. package/src/testing/run-middleware.ts +205 -0
  202. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  203. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  204. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  205. package/src/testing/vitest-stubs/version.ts +5 -0
  206. package/src/testing/vitest.ts +305 -0
  207. package/src/theme/ThemeProvider.tsx +0 -52
  208. package/src/theme/ThemeScript.tsx +0 -6
  209. package/src/theme/constants.ts +0 -12
  210. package/src/theme/index.ts +0 -7
  211. package/src/theme/theme-context.ts +1 -5
  212. package/src/theme/theme-script.ts +0 -14
  213. package/src/theme/use-theme.ts +0 -3
  214. package/src/types/boundaries.ts +0 -35
  215. package/src/types/error-types.ts +25 -89
  216. package/src/types/global-namespace.ts +15 -15
  217. package/src/types/handler-context.ts +16 -13
  218. package/src/types/index.ts +0 -10
  219. package/src/types/request-scope.ts +0 -19
  220. package/src/types/route-config.ts +6 -50
  221. package/src/types/route-entry.ts +0 -6
  222. package/src/types/segments.ts +0 -13
  223. package/src/urls/include-helper.ts +0 -4
  224. package/src/urls/index.ts +0 -6
  225. package/src/urls/path-helper-types.ts +2 -2
  226. package/src/urls/path-helper.ts +0 -54
  227. package/src/urls/urls-function.ts +0 -13
  228. package/src/use-loader.tsx +0 -186
  229. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  230. package/src/vite/discovery/discover-routers.ts +6 -7
  231. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  232. package/src/vite/plugin-types.ts +3 -1
  233. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  234. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  235. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  236. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  237. package/src/vite/plugins/expose-action-id.ts +2 -73
  238. package/src/vite/plugins/expose-id-utils.ts +0 -55
  239. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  240. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  241. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  242. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  243. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  244. package/src/vite/plugins/performance-tracks.ts +0 -3
  245. package/src/vite/plugins/use-cache-transform.ts +0 -36
  246. package/src/vite/plugins/version-injector.ts +0 -20
  247. package/src/vite/plugins/version-plugin.ts +1 -49
  248. package/src/vite/plugins/virtual-entries.ts +0 -15
  249. package/src/vite/rango.ts +1 -108
  250. package/src/vite/router-discovery.ts +2 -1
  251. package/src/vite/utils/ast-handler-extract.ts +0 -16
  252. package/src/vite/utils/bundle-analysis.ts +6 -13
  253. package/src/vite/utils/client-chunks.ts +0 -6
  254. package/src/vite/utils/forward-user-plugins.ts +0 -22
  255. package/src/vite/utils/manifest-utils.ts +0 -4
  256. package/src/vite/utils/package-resolution.ts +1 -73
  257. package/src/vite/utils/prerender-utils.ts +0 -35
  258. package/src/vite/utils/shared-utils.ts +3 -35
  259. package/src/browser/react/use-client-cache.ts +0 -58
  260. package/src/browser/shallow.ts +0 -40
@@ -54,10 +54,8 @@ export interface InterceptResult {
54
54
  * Instead of passing 20+ parameters, middleware calls getRouterContext() to access them.
55
55
  */
56
56
  export interface RouterContext<TEnv = any> {
57
- // Route matching
58
57
  findMatch: (pathname: string) => RouteMatchResult | null;
59
58
 
60
- // Manifest loading
61
59
  loadManifest: (
62
60
  entry: any,
63
61
  routeKey: string,
@@ -66,10 +64,8 @@ export interface RouterContext<TEnv = any> {
66
64
  isSSR?: boolean,
67
65
  ) => Promise<EntryData>;
68
66
 
69
- // Entry traversal
70
67
  traverseBack: (entry: EntryData) => Generator<EntryData>;
71
68
 
72
- // Handler context creation
73
69
  createHandlerContext: (
74
70
  params: Record<string, string>,
75
71
  request: Request,
@@ -83,7 +79,6 @@ export interface RouterContext<TEnv = any> {
83
79
  isPassthroughRoute?: boolean,
84
80
  ) => HandlerContext<any, TEnv>;
85
81
 
86
- // Loader setup
87
82
  setupLoaderAccess: (
88
83
  ctx: HandlerContext<any, TEnv>,
89
84
  loaderPromises: Map<string, Promise<any>>,
@@ -94,7 +89,6 @@ export interface RouterContext<TEnv = any> {
94
89
  loaderPromises: Map<string, Promise<any>>,
95
90
  ) => void;
96
91
 
97
- // Context access
98
92
  getContext: () => {
99
93
  getOrCreateStore: (key: string) => any;
100
94
  runWithStore: <T>(
@@ -105,16 +99,13 @@ export interface RouterContext<TEnv = any> {
105
99
  ) => T;
106
100
  };
107
101
 
108
- // Metrics
109
102
  getMetricsStore: () => MetricsStore | undefined;
110
103
 
111
- // Cache
112
104
  createCacheScope: (
113
105
  cacheConfig: any,
114
106
  parent: CacheScope | null,
115
107
  ) => CacheScope | null;
116
108
 
117
- // Intercept detection
118
109
  findInterceptForRoute: (
119
110
  routeKey: string,
120
111
  parentEntry: EntryData | null,
@@ -122,7 +113,6 @@ export interface RouterContext<TEnv = any> {
122
113
  isAction: boolean,
123
114
  ) => InterceptResult | null;
124
115
 
125
- // Segment resolution (with revalidation)
126
116
  resolveAllSegmentsWithRevalidation: (
127
117
  entries: EntryData[],
128
118
  routeKey: string,
@@ -166,12 +156,10 @@ export interface RouterContext<TEnv = any> {
166
156
  revalidationContext?: RevalidationContext,
167
157
  ) => Promise<ResolvedSegment[]>;
168
158
 
169
- // Collect with markers
170
159
  collectWithMarkers?: <T>(
171
160
  gen: AsyncGenerator<T | { __type: "id"; id: string }>,
172
161
  ) => Promise<{ items: T[]; matchedIds: string[] }>;
173
162
 
174
- // Revalidation evaluation
175
163
  evaluateRevalidation: (params: {
176
164
  segment: ResolvedSegment;
177
165
  prevParams: Record<string, string>;
@@ -195,7 +183,6 @@ export interface RouterContext<TEnv = any> {
195
183
  | "intercept-loader";
196
184
  }) => Promise<boolean>;
197
185
 
198
- // Request context
199
186
  getRequestContext: () =>
200
187
  | {
201
188
  waitUntil: (fn: () => Promise<void>) => void;
@@ -203,7 +190,6 @@ export interface RouterContext<TEnv = any> {
203
190
  }
204
191
  | undefined;
205
192
 
206
- // Simple segment resolution (without revalidation - for full match)
207
193
  resolveAllSegments: (
208
194
  entries: EntryData[],
209
195
  routeKey: string,
@@ -213,7 +199,6 @@ export interface RouterContext<TEnv = any> {
213
199
  options?: { skipLoaders?: boolean },
214
200
  ) => Promise<ResolvedSegment[]>;
215
201
 
216
- // Generator-based simple resolution
217
202
  resolveAllSegmentsGenerator?: (
218
203
  entries: EntryData[],
219
204
  routeKey: string,
@@ -222,21 +207,17 @@ export interface RouterContext<TEnv = any> {
222
207
  loaderPromises: Map<string, Promise<any>>,
223
208
  ) => AsyncGenerator<ResolvedSegment | { __type: "id"; id: string }>;
224
209
 
225
- // Collect segments from generator
226
210
  collectSegmentsFromGenerator?: <T>(
227
211
  gen: AsyncGenerator<T | { __type: "id"; id: string }>,
228
212
  ) => Promise<T[]>;
229
213
 
230
- // Handle store
231
214
  createHandleStore: () => any;
232
215
 
233
- // Loaders-only resolution (for full match cache hit - no revalidation)
234
216
  resolveLoadersOnly?: (
235
217
  entries: EntryData[],
236
218
  handlerContext: HandlerContext<any, TEnv>,
237
219
  ) => Promise<ResolvedSegment[]>;
238
220
 
239
- // Loaders-only resolution (for cache hit scenarios)
240
221
  resolveLoadersOnlyWithRevalidation?: (
241
222
  entries: EntryData[],
242
223
  handlerContext: HandlerContext<any, TEnv>,
@@ -258,10 +239,8 @@ export interface RouterContext<TEnv = any> {
258
239
  // Telemetry sink (optional, no-op when undefined)
259
240
  telemetry?: TelemetrySink;
260
241
 
261
- // Request ID for telemetry span correlation (set per-request in match handlers)
262
242
  requestId?: string;
263
243
 
264
- // Intercept loaders only (for cache hit + intercept scenarios)
265
244
  resolveInterceptLoadersOnly?: (
266
245
  intercept: InterceptEntry,
267
246
  entry: EntryData,
@@ -284,7 +263,6 @@ export interface RouterContext<TEnv = any> {
284
263
  } | null>;
285
264
  }
286
265
 
287
- // AsyncLocalStorage instance for router context
288
266
  const routerContext = new AsyncLocalStorage<RouterContext<any>>();
289
267
 
290
268
  /**
@@ -308,10 +286,6 @@ export function getRouterContext<TEnv = any>(): RouterContext<TEnv> {
308
286
  *
309
287
  * All async code within fn() can call getRouterContext() to access router closures.
310
288
  * This works across async boundaries thanks to AsyncLocalStorage.
311
- *
312
- * @param deps Router dependencies to make available
313
- * @param fn Function to run with dependencies available
314
- * @returns Result of fn()
315
289
  */
316
290
  export function runWithRouterContext<T, TEnv = any>(
317
291
  deps: RouterContext<TEnv>,
@@ -290,6 +290,13 @@ export interface RangoInternal<
290
290
  */
291
291
  readonly prefetchCacheTTL: number;
292
292
 
293
+ /**
294
+ * Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
295
+ * router init and shipped to the client in payload metadata. The server-side
296
+ * cookie writer reads it from here; the client reads it from metadata.
297
+ */
298
+ readonly resolvedStateCookieName: string;
299
+
293
300
  /**
294
301
  * Whether connection warmup is enabled.
295
302
  * When true, the client sends HEAD /?_rsc_warmup after idle periods
@@ -132,6 +132,21 @@ export interface RangoOptions<TEnv = any> {
132
132
  */
133
133
  allowDebugManifest?: boolean;
134
134
 
135
+ /**
136
+ * DEVELOPMENT/TEST ONLY. Emit an `X-Rango-Cache` response header describing
137
+ * the cache status of the matched route, for use by testing primitives such
138
+ * as `assertCacheStatus`.
139
+ *
140
+ * Defaults to `false`. When neither this option nor the
141
+ * `RANGO_TEST_SIGNALS=1` environment flag is set, NO header is emitted and
142
+ * router output is byte-identical to the default.
143
+ *
144
+ * The header encodes per-segment (v1: coarse route-level) status keyed by the
145
+ * route NAME, e.g. `X-Rango-Cache: product.detail=hit`. Do NOT enable in
146
+ * production — it exposes internal cache decisions.
147
+ */
148
+ debugCacheSignal?: boolean;
149
+
135
150
  /**
136
151
  * Document component that wraps the entire application.
137
152
  *
@@ -481,6 +496,21 @@ export interface RangoOptions<TEnv = any> {
481
496
  */
482
497
  prefetchCacheTTL?: number | false;
483
498
 
499
+ /**
500
+ * Prefix for the rango state cookie name. The resolved name is
501
+ * `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
502
+ * characters (`[A-Za-z0-9-]`) and an empty result falls back to the default.
503
+ *
504
+ * The rango state cookie keys the client's prefetch / HTTP caches. Overriding
505
+ * the prefix lets you align it with cookie-naming policies or consent-manager
506
+ * classification lists, or avoid colliding with an existing `rango-state`
507
+ * cookie. It is not a full-name override: the `_{routerId}` suffix is what
508
+ * keeps sibling apps on one origin from clobbering each other's state.
509
+ *
510
+ * @default "rango-state"
511
+ */
512
+ stateCookiePrefix?: string;
513
+
484
514
  /**
485
515
  * Enable connection warmup to keep TCP+TLS alive after idle periods.
486
516
  *
@@ -27,59 +27,17 @@ import {
27
27
  tryStaticSlot,
28
28
  resolveLayoutComponent,
29
29
  resolveWithErrorBoundary,
30
+ warnOnStreamedResponse,
30
31
  } from "./helpers.js";
31
32
  import { applyViewTransitionDefault } from "./view-transition-default.js";
32
33
  import { getRouterContext } from "../router-context.js";
33
- import { resolveSink, safeEmit } from "../telemetry.js";
34
+ import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
34
35
  import {
35
36
  track,
36
37
  RangoContext,
37
38
  runInsideLoaderScope,
38
39
  } from "../../server/context.js";
39
40
 
40
- // ---------------------------------------------------------------------------
41
- // Streamed handler telemetry
42
- // ---------------------------------------------------------------------------
43
-
44
- /**
45
- * Attach a fire-and-forget rejection observer to a streamed handler promise.
46
- * React catches the actual error via its error boundary; this only emits
47
- * the handler.error telemetry event.
48
- */
49
- function observeStreamedHandler(
50
- promise: Promise<ReactNode>,
51
- segmentId: string,
52
- segmentType: string,
53
- pathname?: string,
54
- routeKey?: string,
55
- params?: Record<string, string>,
56
- ): void {
57
- let routerCtx;
58
- try {
59
- routerCtx = getRouterContext();
60
- } catch {
61
- return;
62
- }
63
- if (!routerCtx?.telemetry) return;
64
- const sink = resolveSink(routerCtx.telemetry);
65
- const reqId = routerCtx.requestId;
66
- promise.catch((err: unknown) => {
67
- const errorObj = err instanceof Error ? err : new Error(String(err));
68
- safeEmit(sink, {
69
- type: "handler.error",
70
- timestamp: performance.now(),
71
- requestId: reqId,
72
- segmentId,
73
- segmentType,
74
- error: errorObj,
75
- handledByBoundary: true,
76
- pathname,
77
- routeKey,
78
- params,
79
- });
80
- });
81
- }
82
-
83
41
  // ---------------------------------------------------------------------------
84
42
  // Fresh path (full match, no revalidation)
85
43
  // ---------------------------------------------------------------------------
@@ -133,18 +91,32 @@ export async function resolveLoaders<TEnv>(
133
91
 
134
92
  // Loading disabled: still start all loaders in parallel, but only emit
135
93
  // settled promises so handlers don't stream loading placeholders.
136
- const pendingLoaderData = loaderEntries.map((loaderEntry) => {
94
+ //
95
+ // Wrap each loader promise with wrapLoaderPromise BEFORE awaiting. The wrapped
96
+ // promise resolves to a LoaderDataResult and never rejects, routing a failed
97
+ // loader to its own per-loader error boundary. Awaiting the RAW promises here
98
+ // instead would (1) propagate a rejection to the segment-level boundary,
99
+ // collapsing the whole entry and discarding successful sibling data, and
100
+ // (2) leave the other in-flight raw promises without a .catch, producing
101
+ // unhandled rejections. Mirrors the loading path and intercept-resolution.
102
+ const pendingLoaderData = loaderEntries.map((loaderEntry, i) => {
103
+ const { loader } = loaderEntry;
104
+ const segmentId = `${shortCode}D${i}.${loader.$$id}`;
137
105
  const start = performance.now();
138
- const promise = runInsideLoaderScope(() =>
139
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
106
+ const wrapped = deps.wrapLoaderPromise(
107
+ runInsideLoaderScope(() =>
108
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
109
+ ),
110
+ entry,
111
+ segmentId,
112
+ ctx.pathname,
140
113
  );
141
- return { promise, start, loaderId: loaderEntry.loader.$$id };
114
+ return { wrapped, start, segmentId, loaderId: loader.$$id };
142
115
  });
143
- await Promise.all(pendingLoaderData.map((p) => p.promise));
116
+ await Promise.all(pendingLoaderData.map((p) => p.wrapped));
144
117
 
145
118
  return loaderEntries.map((loaderEntry, i) => {
146
119
  const { loader } = loaderEntry;
147
- const segmentId = `${shortCode}D${i}.${loader.$$id}`;
148
120
  const pending = pendingLoaderData[i]!;
149
121
  if (ms && !ms.metrics.some((m) => m.label === `loader:${loader.$$id}`)) {
150
122
  // All loaders ran in parallel via Promise.all — each span covers
@@ -160,19 +132,14 @@ export async function resolveLoaders<TEnv>(
160
132
  );
161
133
  }
162
134
  return {
163
- id: segmentId,
135
+ id: pending.segmentId,
164
136
  namespace: entry.id,
165
137
  type: "loader" as const,
166
138
  index: i,
167
139
  component: null,
168
140
  params: ctx.params,
169
141
  loaderId: loader.$$id,
170
- loaderData: deps.wrapLoaderPromise(
171
- pending.promise,
172
- entry,
173
- segmentId,
174
- ctx.pathname,
175
- ),
142
+ loaderData: pending.wrapped,
176
143
  belongsToRoute,
177
144
  };
178
145
  });
@@ -297,6 +264,7 @@ export async function resolveSegment<TEnv>(
297
264
  if (entry.loading) {
298
265
  const result = handleHandlerResult(handler(context));
299
266
  if (result instanceof Promise) {
267
+ warnOnStreamedResponse(result, entry.id);
300
268
  result.finally(doneRouteHandler).catch(() => {});
301
269
  const tracked = deps.trackHandler(result, {
302
270
  segmentId: entry.shortCode,
@@ -52,6 +52,40 @@ export function handleHandlerResult(
52
52
  return result;
53
53
  }
54
54
 
55
+ /**
56
+ * Dev-only: warn when a handler on a route that declares loading() resolves or
57
+ * rejects with a Response (e.g. redirect()).
58
+ *
59
+ * On a non-loading route a returned/thrown Response short-circuits to an HTTP
60
+ * redirect. But when the route declares loading(), the handler result is
61
+ * streamed (not awaited at the resolution boundary), so the Response surfaces
62
+ * only during RSC serialization and is rendered into the stream instead of
63
+ * becoming a 302/308 — a silent failure mode. Issue redirects from middleware,
64
+ * a loader, or a synchronous handler return instead. Compiled out in production.
65
+ */
66
+ export function warnOnStreamedResponse(
67
+ result: Promise<unknown>,
68
+ entryId: string,
69
+ ): void {
70
+ if (process.env.NODE_ENV === "production") return;
71
+ // A Response can surface either as a rejection (handleHandlerResult rethrows a
72
+ // resolved Response) or as a resolved value (the raw parallel-slot handler is
73
+ // not run through handleHandlerResult). Check both so every streamed path is
74
+ // covered. Each handler is an independent observer; it does not consume the
75
+ // rejection for the trackHandler/observeStreamedHandler chains.
76
+ const check = (value: unknown) => {
77
+ if (value instanceof Response) {
78
+ console.warn(
79
+ `[rango] Handler for "${entryId}" returned a Response (e.g. ` +
80
+ `redirect()), but it declares loading(): the Response is rendered ` +
81
+ `into the RSC stream, NOT sent as an HTTP redirect. Issue redirects ` +
82
+ `from middleware, a loader, or a synchronous handler return.`,
83
+ );
84
+ }
85
+ };
86
+ result.then(check, check);
87
+ }
88
+
55
89
  // ---------------------------------------------------------------------------
56
90
  // Static handler interception
57
91
  // ---------------------------------------------------------------------------
@@ -8,7 +8,7 @@
8
8
  * Cache key resolution (3-tier, matching CacheScope.resolveKey):
9
9
  * 1. options.key(requestCtx) — full override
10
10
  * 2. store.keyGenerator(requestCtx, defaultKey) — store-level modification
11
- * 3. loader:{loaderId}:{pathname}:{sortedParams} — default
11
+ * 3. loader:{loaderId}:{host}{pathname}:{sortedParams} — default
12
12
  *
13
13
  * Values are serialized via RSC Flight (serializeResult/deserializeResult),
14
14
  * supporting ReactNode, Promises, null, and all RSC-serializable types.
@@ -57,12 +57,13 @@ function debugLoaderCacheLog(message: string): void {
57
57
 
58
58
  function getDefaultLoaderCacheKey(
59
59
  loaderId: string,
60
+ host: string,
60
61
  pathname: string,
61
62
  params: Record<string, string>,
62
63
  ): string {
63
64
  const paramStr = sortedRouteParams(params);
64
65
  const base = paramStr ? `${pathname}:${paramStr}` : pathname;
65
- return `loader:${loaderId}:${base}`;
66
+ return `loader:${loaderId}:${host}${base}`;
66
67
  }
67
68
 
68
69
  /**
@@ -76,7 +77,13 @@ async function resolveLoaderKey(
76
77
  params: Record<string, string>,
77
78
  ): Promise<string> {
78
79
  const options = loaderEntry.cache!.options;
79
- const defaultKey = getDefaultLoaderCacheKey(loaderId, pathname, params);
80
+ // The host is part of the loader cache identity, matching the route-level
81
+ // cache (cache-scope getCacheKeyBase: `${host}${pathname}`) and "use cache"
82
+ // (cache-runtime pushes ctx.url.host). Without it, a multi-tenant host router
83
+ // serving the same pathname for different hosts would leak one host's cached
84
+ // loader data to another.
85
+ const host = getRequestContext()?.url?.host ?? "localhost";
86
+ const defaultKey = getDefaultLoaderCacheKey(loaderId, host, pathname, params);
80
87
  if (options === false) return defaultKey;
81
88
  return resolveCacheKey(options.key, store, defaultKey, "LoaderCache");
82
89
  }
@@ -139,14 +146,8 @@ export function resolveLoaderData<TEnv>(
139
146
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
140
147
  const swr = swrWindow || undefined;
141
148
  const tags = resolveTags(loaderEntry);
142
- // Loader tags are config-derived, so they are the complete set whether this is
143
- // a cache hit or miss; record them every time so a document built from this
144
- // loader is tagged for invalidation.
145
149
  recordRequestTags(tags);
146
150
 
147
- // Wrap ctx.use() so cache HIT primes the handler's memoization map.
148
- // ctx.use() closes over the match context's loaderPromises (not request context's).
149
- // By intercepting ctx.use(), we inject cached data into the correct map.
150
151
  const originalUse = ctx.use;
151
152
  const dataPromise = (async () => {
152
153
  const codec = await getCodec();
@@ -174,10 +175,6 @@ export function resolveLoaderData<TEnv>(
174
175
  });
175
176
  })();
176
177
 
177
- // Temporarily replace ctx.use() so the handler's call returns cached data.
178
- // This is needed because ctx.use() closes over the match context's loaderPromises
179
- // map which is separate from the request context. By wrapping use(), we intercept
180
- // the handler's call and return the shared dataPromise.
181
178
  const wrappedUse = ((item: any) => {
182
179
  if (item === loaderEntry.loader || item?.$$id === loaderId) {
183
180
  return dataPromise;
@@ -34,6 +34,7 @@ import {
34
34
  import { resolveLoaderData } from "./loader-cache.js";
35
35
  import {
36
36
  handleHandlerResult,
37
+ warnOnStreamedResponse,
37
38
  tryStaticHandler,
38
39
  tryStaticSlot,
39
40
  resolveLayoutComponent,
@@ -42,54 +43,13 @@ import {
42
43
  import { applyViewTransitionDefault } from "./view-transition-default.js";
43
44
  import { getRouterContext } from "../router-context.js";
44
45
  import { resolveSink, safeEmit } from "../telemetry.js";
46
+ import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
45
47
  import {
46
48
  track,
47
49
  RangoContext,
48
50
  runInsideLoaderScope,
49
51
  } from "../../server/context.js";
50
52
 
51
- // ---------------------------------------------------------------------------
52
- // Telemetry helpers
53
- // ---------------------------------------------------------------------------
54
-
55
- /**
56
- * Attach a fire-and-forget rejection observer to a streamed handler promise.
57
- * Silently no-ops when called outside RouterContext (e.g. in unit tests).
58
- */
59
- function observeStreamedHandler(
60
- promise: Promise<ReactNode>,
61
- segmentId: string,
62
- segmentType: string,
63
- pathname?: string,
64
- routeKey?: string,
65
- params?: Record<string, string>,
66
- ): void {
67
- let routerCtx;
68
- try {
69
- routerCtx = getRouterContext();
70
- } catch {
71
- return;
72
- }
73
- if (!routerCtx?.telemetry) return;
74
- const sink = resolveSink(routerCtx.telemetry);
75
- const reqId = routerCtx.requestId;
76
- promise.catch((err: unknown) => {
77
- const errorObj = err instanceof Error ? err : new Error(String(err));
78
- safeEmit(sink, {
79
- type: "handler.error",
80
- timestamp: performance.now(),
81
- requestId: reqId,
82
- segmentId,
83
- segmentType,
84
- error: errorObj,
85
- handledByBoundary: true,
86
- pathname,
87
- routeKey,
88
- params,
89
- });
90
- });
91
- }
92
-
93
53
  /**
94
54
  * Trace a parallel slot that's being force-rendered on a full refetch (client
95
55
  * has no cached state). User revalidate fns are bypassed in this case — see
@@ -564,6 +524,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
564
524
  const result =
565
525
  typeof handler === "function" ? handler(context) : handler;
566
526
  if (result instanceof Promise) {
527
+ warnOnStreamedResponse(result, parallelId);
567
528
  const tracked = deps.trackHandler(result, {
568
529
  segmentId: parallelId,
569
530
  segmentType: "parallel",
@@ -762,6 +723,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
762
723
  if (!actionContext) {
763
724
  const result = handleHandlerResult(handler(context));
764
725
  if (result instanceof Promise) {
726
+ warnOnStreamedResponse(result, routeEntry.id);
765
727
  result.finally(doneHandler).catch(() => {});
766
728
  const tracked = deps.trackHandler(result, {
767
729
  segmentId: entry.shortCode,
@@ -1274,6 +1236,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1274
1236
  const result =
1275
1237
  typeof handler === "function" ? handler(context) : handler;
1276
1238
  if (result instanceof Promise) {
1239
+ warnOnStreamedResponse(result, parallelId);
1277
1240
  const tracked = deps.trackHandler(result, {
1278
1241
  segmentId: parallelId,
1279
1242
  segmentType: "parallel",
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Streamed Handler Telemetry
3
+ *
4
+ * Shared fire-and-forget rejection observer for streamed handler promises,
5
+ * used by both the fresh and revalidation segment-resolution paths. Lives in
6
+ * its own module (never mocked) so the resolution unit tests that mock
7
+ * helpers.js / telemetry.js with explicit export lists do not resolve it to
8
+ * undefined.
9
+ */
10
+
11
+ import type { ReactNode } from "react";
12
+ import { getRouterContext } from "../router-context.js";
13
+ import { resolveSink, safeEmit } from "../telemetry.js";
14
+
15
+ /**
16
+ * Attach a fire-and-forget rejection observer to a streamed handler promise.
17
+ * React catches the actual error via its error boundary; this only emits
18
+ * the handler.error telemetry event.
19
+ */
20
+ export function observeStreamedHandler(
21
+ promise: Promise<ReactNode>,
22
+ segmentId: string,
23
+ segmentType: string,
24
+ pathname?: string,
25
+ routeKey?: string,
26
+ params?: Record<string, string>,
27
+ ): void {
28
+ let routerCtx;
29
+ try {
30
+ routerCtx = getRouterContext();
31
+ } catch {
32
+ return;
33
+ }
34
+ if (!routerCtx?.telemetry) return;
35
+ const sink = resolveSink(routerCtx.telemetry);
36
+ const reqId = routerCtx.requestId;
37
+ promise.catch((err: unknown) => {
38
+ const errorObj = err instanceof Error ? err : new Error(String(err));
39
+ safeEmit(sink, {
40
+ type: "handler.error",
41
+ timestamp: performance.now(),
42
+ requestId: reqId,
43
+ segmentId,
44
+ segmentType,
45
+ error: errorObj,
46
+ handledByBoundary: true,
47
+ pathname,
48
+ routeKey,
49
+ params,
50
+ });
51
+ });
52
+ }
@@ -1,5 +1,8 @@
1
1
  // Barrel re-export -- see segment-resolution/ for implementations.
2
- export { handleHandlerResult } from "./segment-resolution/helpers.js";
2
+ export {
3
+ handleHandlerResult,
4
+ warnOnStreamedResponse,
5
+ } from "./segment-resolution/helpers.js";
3
6
  export {
4
7
  resolveLoaders,
5
8
  type ResolveSegmentOptions,
@@ -0,0 +1,33 @@
1
+ import { DEFAULT_STATE_COOKIE_PREFIX } from "../browser/cookie-name.js";
2
+
3
+ /**
4
+ * Resolve the rango state cookie name once, server-side, at router init. The
5
+ * resolved string is shipped in payload metadata and the client reads it
6
+ * verbatim, so composition happens in exactly one place.
7
+ *
8
+ * Shape: `{sanitizedPrefix}_{sanitizedRouterId}`. The prefix charset excludes
9
+ * `_` so the FIRST `_` is always the prefix/routerId boundary; that keeps the
10
+ * name injective even though a routerId may legitimately contain `_` (the
11
+ * counter fallback is `router_{n}`). Without that exclusion, prefix
12
+ * `rango-state` + id `router_0` and prefix `rango-state_router` + id `0` would
13
+ * both resolve to `rango-state_router_0` and silently share a cache key.
14
+ */
15
+
16
+ // Prefix excludes `_` so it can never collide with the separator.
17
+ function sanitizePrefix(prefix: string): string {
18
+ return prefix.replace(/[^A-Za-z0-9-]/g, "");
19
+ }
20
+
21
+ // routerId keeps `_` (so `router_0` survives); other illegal chars are dropped.
22
+ function sanitizeRouterId(routerId: string): string {
23
+ return routerId.replace(/[^A-Za-z0-9_-]/g, "");
24
+ }
25
+
26
+ export function resolveStateCookieName(
27
+ prefix: string | undefined,
28
+ routerId: string,
29
+ ): string {
30
+ const sanitized = sanitizePrefix(prefix ?? DEFAULT_STATE_COOKIE_PREFIX);
31
+ const finalPrefix = sanitized || DEFAULT_STATE_COOKIE_PREFIX;
32
+ return `${finalPrefix}_${sanitizeRouterId(routerId)}`;
33
+ }
@@ -125,10 +125,6 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
125
125
  return {
126
126
  emit(event: TelemetryEvent): void {
127
127
  switch (event.type) {
128
- // -----------------------------------------------------------------
129
- // Request lifecycle
130
- // -----------------------------------------------------------------
131
-
132
128
  case "request.start": {
133
129
  const span = tracer.startSpan("rango.request", {
134
130
  attributes: {
@@ -169,10 +165,6 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
169
165
  break;
170
166
  }
171
167
 
172
- // -----------------------------------------------------------------
173
- // Loader lifecycle
174
- // -----------------------------------------------------------------
175
-
176
168
  case "loader.start": {
177
169
  const span = tracer.startSpan("rango.loader", {
178
170
  attributes: {
@@ -231,10 +223,6 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
231
223
  break;
232
224
  }
233
225
 
234
- // -----------------------------------------------------------------
235
- // Handler errors (instant span)
236
- // -----------------------------------------------------------------
237
-
238
226
  case "handler.error": {
239
227
  const attrs: Record<string, string | number | boolean> = {
240
228
  "rango.handled_by_boundary": event.handledByBoundary,
@@ -257,10 +245,6 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
257
245
  break;
258
246
  }
259
247
 
260
- // -----------------------------------------------------------------
261
- // Cache decision (instant span)
262
- // -----------------------------------------------------------------
263
-
264
248
  case "cache.decision": {
265
249
  const attrs: Record<string, string | number | boolean> = {
266
250
  "http.route": event.pathname,
@@ -277,10 +261,6 @@ export function createOTelSink(tracer: OTelTracer): TelemetrySink {
277
261
  break;
278
262
  }
279
263
 
280
- // -----------------------------------------------------------------
281
- // Revalidation decision (instant span)
282
- // -----------------------------------------------------------------
283
-
284
264
  case "revalidation.decision": {
285
265
  const span = tracer.startSpan("rango.revalidation.decision", {
286
266
  attributes: {