@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d

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 (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +57 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. package/src/browser/action-response-classifier.ts +0 -99
@@ -1,5 +1,5 @@
1
1
  import { tryTrieMatch } from "./trie-matching.js";
2
- import { getRouteTrie, getRouterTrie } from "../route-map-builder.js";
2
+ import { getRouterTrie } from "../route-map-builder.js";
3
3
  import {
4
4
  findMatch as findRouteMatch,
5
5
  isLazyEvaluationNeeded,
@@ -8,6 +8,19 @@ import {
8
8
  import type { MetricsStore } from "../server/context";
9
9
  import type { RouteEntry } from "../types";
10
10
 
11
+ // Return a shallow copy with an independent `params` object. The single-entry
12
+ // cache below is module-lifetime and keyed only on pathname, so the same result
13
+ // object is handed to every same-pathname request in the isolate. ctx.params
14
+ // aliases this `params` (see request-context), so without an own copy a handler
15
+ // that mutates ctx.params would corrupt the cached entry for later requests.
16
+ // `entry` and the flags are intentionally shared by reference: they are
17
+ // read-only, and entry identity is compared in match-api (prevMatch.entry).
18
+ function cloneMatchResult<TEnv>(
19
+ r: RouteMatchResult<TEnv> | null,
20
+ ): RouteMatchResult<TEnv> | null {
21
+ return r ? { ...r, params: { ...r.params } } : null;
22
+ }
23
+
11
24
  export interface FindMatchDeps<TEnv = any> {
12
25
  routesEntries: RouteEntry<TEnv>[];
13
26
  evaluateLazyEntry: (entry: RouteEntry<TEnv>) => void;
@@ -35,9 +48,10 @@ export function createFindMatch<TEnv = any>(
35
48
  pathname: string,
36
49
  ms?: MetricsStore,
37
50
  ): RouteMatchResult<TEnv> | null {
38
- // Return cached result if same pathname (avoids double-match per request)
51
+ // Return cached result if same pathname (avoids double-match per request).
52
+ // Clone so a caller mutating ctx.params cannot corrupt the shared cache.
39
53
  if (lastFindMatchPathname === pathname) {
40
- return lastFindMatchResult;
54
+ return cloneMatchResult(lastFindMatchResult);
41
55
  }
42
56
 
43
57
  // Helper to push sub-metrics
@@ -56,12 +70,19 @@ export function createFindMatch<TEnv = any>(
56
70
  // routers and must not be used — in multi-router setups (host routing)
57
71
  // overlapping paths like "/" would match the wrong app's route.
58
72
  const routeTrie = getRouterTrie(deps.routerId);
73
+ // Whether the trie produced a match for this pathname (independent of
74
+ // whether the owning RouteEntry was resolvable yet). Used to suppress the
75
+ // R3 dev warning below: if the trie DID match but we fell through to the
76
+ // regex fallback only because a lazy entry was not spliced in yet, that is
77
+ // not a trie gap.
78
+ let trieMatched = false;
59
79
  if (routeTrie) {
60
80
  const trieStart = performance.now();
61
81
  const trieResult = tryTrieMatch(routeTrie, pathname);
62
82
  pushMetric?.("match:trie", trieStart);
63
83
 
64
84
  if (trieResult) {
85
+ trieMatched = true;
65
86
  // Find the RouteEntry that contains this route.
66
87
  // Multiple entries can share the same staticPrefix (e.g., several
67
88
  // include("/", patterns) calls all produce staticPrefix=""). Evaluate
@@ -114,7 +135,6 @@ export function createFindMatch<TEnv = any>(
114
135
  params: trieResult.params,
115
136
  optionalParams: new Set(trieResult.optionalParams || []),
116
137
  redirectTo: trieResult.redirectTo,
117
- ancestry: trieResult.ancestry,
118
138
  ...(trieResult.pr ? { pr: true } : {}),
119
139
  ...(trieResult.pt ? { pt: true } : {}),
120
140
  ...(trieResult.responseType
@@ -125,7 +145,7 @@ export function createFindMatch<TEnv = any>(
125
145
  : {}),
126
146
  ...(trieResult.rscFirst ? { rscFirst: true } : {}),
127
147
  };
128
- return lastFindMatchResult;
148
+ return cloneMatchResult(lastFindMatchResult);
129
149
  }
130
150
  }
131
151
  }
@@ -153,8 +173,36 @@ export function createFindMatch<TEnv = any>(
153
173
  }
154
174
  pushMetric?.("match:regex-fallback", regexStart);
155
175
 
176
+ // The trie is the single source of truth and is built before findMatch in
177
+ // both dev (handler rebuild) and production (ensureRouterManifest). If the
178
+ // trie was present yet the regex fallback resolved a real match, the trie
179
+ // has a gap (e.g. a route shape it cannot represent) and dev/prod could
180
+ // diverge if the trie were ever absent. Surface it in dev; folded out in
181
+ // production builds.
182
+ //
183
+ // Suppress when the trie DID match (`trieMatched`): that path falls through
184
+ // to the regex fallback only on the first request to a not-yet-spliced lazy
185
+ // entry (e.g. a 2+-level nested include whose deeper parent has not been
186
+ // evaluated). The trie knew the route; runtime lazy discovery simply lagged.
187
+ // That is the supported lazy-include flow, not a trie gap, so warning on it
188
+ // is a false positive (it manufactures bug reports and erodes the signal).
189
+ if (
190
+ process.env.NODE_ENV !== "production" &&
191
+ routeTrie &&
192
+ !trieMatched &&
193
+ result &&
194
+ !isLazyEvaluationNeeded(result)
195
+ ) {
196
+ console.warn(
197
+ `[@rangojs/router] Route "${pathname}" resolved via the regex fallback ` +
198
+ `even though the route trie was present. The trie should be the single ` +
199
+ `matching source of truth; this indicates a trie gap. Please report this ` +
200
+ `with your route configuration.`,
201
+ );
202
+ }
203
+
156
204
  lastFindMatchPathname = pathname;
157
205
  lastFindMatchResult = result;
158
- return result;
206
+ return cloneMatchResult(result);
159
207
  };
160
208
  }
@@ -18,6 +18,8 @@ import { isInsideCacheScope } from "../server/context.js";
18
18
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
19
19
  import { isAutoGeneratedRouteName } from "../route-name.js";
20
20
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
21
+ import { substitutePatternParams } from "./substitute-pattern-params.js";
22
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
21
23
 
22
24
  /**
23
25
  * Strip internal _rsc* query params from a URL.
@@ -158,48 +160,14 @@ export function createReverseFunction(
158
160
  );
159
161
  }
160
162
 
161
- let result = pattern;
162
-
163
163
  // Merge current request params as defaults, explicit params override
164
164
  const effectiveParams = currentParams
165
165
  ? { ...currentParams, ...hrefParams }
166
166
  : hrefParams;
167
167
 
168
- // Substitute params (strip constraint and optional syntax: :param(a|b)? -> value)
169
- // Optional params (:param?) are omitted when not provided
170
- if (effectiveParams) {
171
- let hadOmittedOptional = false;
172
- // First pass: optional params (trailing ?)
173
- result = result.replace(
174
- /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
175
- (_, key) => {
176
- const value = effectiveParams[key];
177
- if (value === undefined) {
178
- hadOmittedOptional = true;
179
- return "";
180
- }
181
- return encodeURIComponent(value);
182
- },
183
- );
184
- // Second pass: required params (no trailing ?)
185
- result = result.replace(
186
- /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
187
- (_, key) => {
188
- const value = effectiveParams[key];
189
- if (value === undefined) {
190
- throw new Error(`Missing param "${key}" for route "${name}"`);
191
- }
192
- return encodeURIComponent(value);
193
- },
194
- );
195
- // Clean up slashes only when an optional param was actually omitted,
196
- // so intentional trailing-slash patterns like "/blog/" are preserved.
197
- if (hadOmittedOptional) {
198
- const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
199
- result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
200
- if (hadTrailingSlash && !result.endsWith("/")) result += "/";
201
- }
202
- }
168
+ let result = effectiveParams
169
+ ? substitutePatternParams(pattern, effectiveParams, name)
170
+ : pattern;
203
171
 
204
172
  // Append search params as query string
205
173
  if (search) {
@@ -278,8 +246,12 @@ export function createHandlerContext<TEnv>(
278
246
  search: searchSchema ? resolvedSearchParams : {},
279
247
  pathname,
280
248
  url,
281
- originalUrl: new URL(request.url),
249
+ originalUrl: requestContext?.originalUrl ?? new URL(request.url),
282
250
  env: bindings,
251
+ waitUntil: requestContext
252
+ ? requestContext.waitUntil.bind(requestContext)
253
+ : fireAndForgetWaitUntil,
254
+ executionContext: requestContext?.executionContext,
283
255
  _variables: variables,
284
256
  get: ((keyOrVar: any) => {
285
257
  // Read-time guard: non-cacheable var inside cache() → throw.
@@ -384,6 +356,12 @@ export function createPrerenderContext<TEnv>(
384
356
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
385
357
  );
386
358
  },
359
+ // Build-time prerender has no live request. waitUntil is a true no-op
360
+ // (running fn() here would fire side effects during build, which is
361
+ // incorrect — these are meant to outlive the live response).
362
+ // executionContext is absent for the same reason.
363
+ waitUntil: () => {},
364
+ executionContext: undefined,
387
365
  _variables: variables,
388
366
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
389
367
  set: ((keyOrVar: any, value: any) => {
@@ -473,6 +451,11 @@ export function createStaticContext<TEnv>(
473
451
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
474
452
  );
475
453
  },
454
+ // Static() handlers have no live request. waitUntil is a true no-op
455
+ // (running fn() here would fire side effects during build, which is
456
+ // incorrect). executionContext is absent for the same reason.
457
+ waitUntil: () => {},
458
+ executionContext: undefined,
476
459
  _variables: variables,
477
460
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
478
461
  set: ((keyOrVar: any, value: any) => {
@@ -66,28 +66,14 @@ export function findInterceptForRoute(
66
66
  let current: EntryData | null = fromEntry;
67
67
 
68
68
  while (current) {
69
- if (current.intercept && current.intercept.length > 0) {
70
- for (const intercept of current.intercept) {
69
+ // current first, then its sibling layouts — same order as before.
70
+ for (const source of [current, ...current.layout]) {
71
+ for (const intercept of source.intercept) {
71
72
  if (
72
73
  intercept.routeName === targetRouteKey &&
73
74
  evaluateInterceptWhen(intercept, selectorContext, isAction)
74
75
  ) {
75
- return { intercept, entry: current };
76
- }
77
- }
78
- }
79
-
80
- if (current.layout && current.layout.length > 0) {
81
- for (const siblingLayout of current.layout) {
82
- if (siblingLayout.intercept && siblingLayout.intercept.length > 0) {
83
- for (const intercept of siblingLayout.intercept) {
84
- if (
85
- intercept.routeName === targetRouteKey &&
86
- evaluateInterceptWhen(intercept, selectorContext, isAction)
87
- ) {
88
- return { intercept, entry: siblingLayout };
89
- }
90
- }
76
+ return { intercept, entry: source };
91
77
  }
92
78
  }
93
79
  }
@@ -1,8 +1,8 @@
1
1
  import { registerRouteMap } from "../route-map-builder.js";
2
- import { extractStaticPrefix } from "./pattern-matching.js";
2
+ import { extractStaticPrefix, joinPrefix } from "./pattern-matching.js";
3
3
  import {
4
- EntryData,
5
- RSCRouterContext,
4
+ type EntryData,
5
+ RangoContext,
6
6
  runWithPrefixes,
7
7
  getIsolatedLazyParent,
8
8
  } from "../server/context";
@@ -81,11 +81,16 @@ export function evaluateLazyEntry<TEnv = any>(
81
81
  // Check for pre-computed routes from build-time data.
82
82
  // Only leaf nodes (no nested includes) are precomputed, so entries with
83
83
  // nested lazy includes fall through to the handler below.
84
- // When multiple entries share the same staticPrefix (e.g., several
85
- // include("/", ...) calls), the precomputed data merges all their routes
86
- // into one entry. Assigning that merged set to the first matching entry
87
- // causes findMatch to pick the wrong handler for routes belonging to a
88
- // different include. Skip the shortcut when the prefix is shared.
84
+ //
85
+ // The load-bearing protection against two includes sharing a staticPrefix
86
+ // lives UPSTREAM in buildPrecomputedByPrefix (build/prefix-tree-utils): a
87
+ // shared staticPrefix is omitted from the map entirely, so currentPrecomputed
88
+ // never returns routes for it and the shortcut is skipped. The live-count
89
+ // check below is a secondary guard only — it is TIMING-BLIND (it counts
90
+ // routesEntries, which cannot see a nested sibling that has not been spliced
91
+ // in yet), so it must NOT be relied on alone. Kept as defense-in-depth for the
92
+ // all-siblings-live case (e.g. several include("/", ...) placeholders created
93
+ // up front).
89
94
  const currentPrecomputed = deps.getPrecomputedByPrefix();
90
95
  if (currentPrecomputed) {
91
96
  const routes = currentPrecomputed.get(entry.staticPrefix);
@@ -113,7 +118,15 @@ export function evaluateLazyEntry<TEnv = any>(
113
118
  const lazyPatterns = entry.lazyPatterns as UrlPatterns<TEnv>;
114
119
  const lazyContext = entry.lazyContext;
115
120
 
116
- // Create a new context for evaluating the lazy patterns
121
+ // Create a new context for evaluating the lazy patterns.
122
+ // KNOWN REDUNDANCY (LP3, docs/internal/matching-and-lazy-discovery.md): this
123
+ // runs lazyPatterns.handler() purely to extract `patterns` (route name ->
124
+ // pattern) for matching, and DISCARDS the EntryData `manifest` it builds.
125
+ // loadManifest() then runs the SAME handler again on the first request to
126
+ // build the EntryData tree for rendering. Unifying the two runs is deferred
127
+ // (the two run in different contexts — see the LP3 todo in
128
+ // lazy-include-perf.test.ts). The precomputed-entries shortcut above avoids
129
+ // THIS run entirely for leaf includes.
117
130
  const manifest = new Map<string, EntryData>();
118
131
  const patterns = new Map<string, string>();
119
132
  const patternsByPrefix = new Map<string, Map<string, string>>();
@@ -125,14 +138,13 @@ export function evaluateLazyEntry<TEnv = any>(
125
138
  // Merge captured counters from include() to maintain consistent
126
139
  // shortCode indices with sibling entries from pattern extraction
127
140
  const lazyCounters: Record<string, number> = {};
128
- if (lazyContext && (lazyContext as any).counters) {
129
- const captured = (lazyContext as any).counters as Record<string, number>;
130
- for (const [key, value] of Object.entries(captured)) {
141
+ if (lazyContext?.counters) {
142
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
131
143
  lazyCounters[key] = value;
132
144
  }
133
145
  }
134
146
 
135
- RSCRouterContext.run(
147
+ RangoContext.run(
136
148
  {
137
149
  manifest,
138
150
  patterns,
@@ -141,14 +153,18 @@ export function evaluateLazyEntry<TEnv = any>(
141
153
  namespace: "lazy",
142
154
  parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
143
155
  counters: lazyCounters,
144
- cacheProfiles: (lazyContext as any)?.cacheProfiles,
145
- rootScoped: (lazyContext as any)?.rootScoped,
156
+ cacheProfiles: lazyContext?.cacheProfiles,
157
+ rootScoped: lazyContext?.rootScoped,
158
+ includeScope: lazyContext?.includeScope,
146
159
  },
147
160
  () => {
148
- // Run the lazy patterns handler with the original context prefixes
149
- // The prefix comes from the IncludeItem stored in lazyPatterns
161
+ // Run the lazy patterns handler with the original context prefixes.
162
+ // The prefix comes from the IncludeItem stored in lazyPatterns. Use the
163
+ // slash-collapsing join so a trailing-slash parent prefix does not bake a
164
+ // double slash into the registered route patterns (entry.routes,
165
+ // reverse(), EntryData.pattern, mountPath) when the handler runs.
150
166
  const includePrefix = (entry as any)._lazyPrefix || "";
151
- const fullPrefix = (lazyContext?.urlPrefix || "") + includePrefix;
167
+ const fullPrefix = joinPrefix(lazyContext?.urlPrefix, includePrefix);
152
168
 
153
169
  if (fullPrefix || lazyContext?.namePrefix) {
154
170
  runWithPrefixes(fullPrefix, lazyContext?.namePrefix, () => {
@@ -190,10 +206,13 @@ export function evaluateLazyEntry<TEnv = any>(
190
206
  // Detect nested lazy includes and register them as new entries
191
207
  const nestedLazyIncludes = findLazyIncludes(handlerResult);
192
208
  for (const lazyInclude of nestedLazyIncludes) {
193
- // Compute the full URL prefix (combining parent prefix if any)
194
- const fullPrefix = lazyInclude.context.urlPrefix
195
- ? lazyInclude.context.urlPrefix + lazyInclude.prefix
196
- : lazyInclude.prefix;
209
+ // Compute the full URL prefix (combining parent prefix if any). Use the
210
+ // slash-collapsing join so a trailing-slash parent prefix does not produce
211
+ // a double-slash staticPrefix the trie's sp can never match.
212
+ const fullPrefix = joinPrefix(
213
+ lazyInclude.context.urlPrefix,
214
+ lazyInclude.prefix,
215
+ );
197
216
 
198
217
  const nestedEntry: RouteEntry<TEnv> & { _lazyPrefix?: string } = {
199
218
  prefix: "",
@@ -24,7 +24,12 @@ import { isHandle, collectHandleData, type Handle } from "../handle.js";
24
24
  import { buildHandleSnapshot } from "../server/handle-store.js";
25
25
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
26
26
  import { _getRequestContext } from "../server/request-context.js";
27
- import { isInsideLoaderScope } from "../server/context.js";
27
+ import {
28
+ isInsideLoaderScope,
29
+ runInsideLoaderBodyScope,
30
+ isInsidePushCallbackScope,
31
+ runInsidePushCallbackScope,
32
+ } from "../server/context.js";
28
33
  import { debugLog } from "./logging.js";
29
34
 
30
35
  /**
@@ -266,7 +271,10 @@ function createLoaderExecutor<TEnv>(
266
271
  search: (ctx as any).search,
267
272
  pathname: ctx.pathname,
268
273
  url: ctx.url,
274
+ originalUrl: ctx.originalUrl,
269
275
  env: ctx.env,
276
+ waitUntil: ctx.waitUntil.bind(ctx),
277
+ executionContext: ctx.executionContext,
270
278
  get: ((keyOrVar: any) =>
271
279
  contextGet(variables, keyOrVar)) as typeof ctx.get,
272
280
  use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
@@ -284,6 +292,12 @@ function createLoaderExecutor<TEnv>(
284
292
  );
285
293
  }
286
294
  const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
295
+ // The complete snapshot is cached at barrier resolution for
296
+ // non-streaming trees, and by rendered() after handleStore.settled for
297
+ // streaming trees (where the eager snapshot would have been incomplete
298
+ // because loading() handlers were still in flight). Either way it is
299
+ // present by the time a loader reads a handle; the fresh build is only
300
+ // a defensive fallback.
287
301
  const snapshot =
288
302
  reqCtx._renderBarrierHandleSnapshot ??
289
303
  buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
@@ -305,15 +319,7 @@ function createLoaderExecutor<TEnv>(
305
319
  );
306
320
  }
307
321
 
308
- // Guard: reject streaming trees
309
322
  const reqCtx = reqCtxRef ?? _getRequestContext();
310
- if (reqCtx?._treeHasStreaming) {
311
- throw new Error(
312
- `ctx.rendered() is not supported when the matched route tree uses loading(). ` +
313
- `Streaming handlers may not have settled when rendered() resolves. ` +
314
- `Remove loading() from the route tree or restructure to avoid rendered().`,
315
- );
316
- }
317
323
 
318
324
  if (renderedPromise) return renderedPromise;
319
325
 
@@ -324,7 +330,10 @@ function createLoaderExecutor<TEnv>(
324
330
  }
325
331
 
326
332
  // Bidirectional deadlock check: if a handler already started
327
- // awaiting this loader, calling rendered() would deadlock.
333
+ // awaiting this loader, calling rendered() would deadlock. This is the
334
+ // real cycle guard (it holds for both streaming and non-streaming): the
335
+ // handler blocks segment resolution, which blocks the barrier, which
336
+ // blocks this loader.
328
337
  if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
329
338
  throw new Error(
330
339
  `Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
@@ -342,7 +351,29 @@ function createLoaderExecutor<TEnv>(
342
351
  }
343
352
  reqCtx._renderBarrierWaiters.add(currentLoaderId);
344
353
 
345
- renderedPromise = reqCtx._renderBarrier.then(() => {
354
+ // Streaming trees (loading()): the barrier resolves once the segment
355
+ // tree is resolved, but loading() handlers stream behind Suspense and
356
+ // their handle pushes are still in flight then. Their async execution
357
+ // IS tracked in the handle store (trackHandler -> store.track), so after
358
+ // the barrier we seal (no further handlers register once the tree is
359
+ // resolved) and wait for settled — every tracked handler, streaming
360
+ // included, has finished pushing. The loader's own segment streams in
361
+ // after, so this does not block the shell; the deadlock guard above
362
+ // keeps a handler from depending on this loader.
363
+ const streaming = reqCtx._treeHasStreaming === true;
364
+ renderedPromise = reqCtx._renderBarrier.then(async () => {
365
+ if (streaming) {
366
+ reqCtx._handleStore.seal();
367
+ await reqCtx._handleStore.settled;
368
+ // The eager snapshot was intentionally left unbuilt for streaming
369
+ // (it would have been incomplete). Build the complete one once, now
370
+ // that the store has settled, so every ctx.use(handle) reads the
371
+ // cached snapshot instead of rebuilding it per call.
372
+ reqCtx._renderBarrierHandleSnapshot ??= buildHandleSnapshot(
373
+ reqCtx._handleStore,
374
+ reqCtx._renderBarrierSegmentOrder ?? [],
375
+ );
376
+ }
346
377
  renderedResolved = true;
347
378
  });
348
379
  return renderedPromise;
@@ -350,8 +381,19 @@ function createLoaderExecutor<TEnv>(
350
381
  };
351
382
 
352
383
  const doneLoader = track(`loader:${loader.$$id}`, 2);
384
+ // Run the loader body inside loader scope so request-scoped reads
385
+ // (cookies()/headers() and non-cacheable ctx.get) are exempt from the
386
+ // cache-purity guards: loaders always run fresh, so their reads never leak
387
+ // into a cached segment. DSL loaders are already wrapped by fresh.ts; this
388
+ // also covers handler-invoked loaders (ctx.use(Loader) from a handler),
389
+ // which otherwise execute in the caller's cache scope and would wrongly
390
+ // throw. rendered() gating uses the captured isDslLoader (above), so this
391
+ // does not grant rendered() to handler-invoked loaders. Uses a body-only
392
+ // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
353
393
  const promise = Promise.resolve(
354
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
394
+ runInsideLoaderBodyScope(() =>
395
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
396
+ ),
355
397
  ).finally(() => {
356
398
  pendingLoaders.delete(loader.$$id);
357
399
  doneLoader();
@@ -387,12 +429,6 @@ export function setupLoaderAccess<TEnv>(
387
429
 
388
430
  const useLoader = createLoaderExecutor(ctx, loaderPromises);
389
431
 
390
- // Track whether we're inside a handle push callback. Loaders started
391
- // from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
392
- // block segment resolution, so they must not be registered as handler
393
- // dependencies for deadlock detection.
394
- let insideHandlePush = false;
395
-
396
432
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
397
433
  if (isHandle(item)) {
398
434
  const handle = item;
@@ -413,15 +449,17 @@ export function setupLoaderAccess<TEnv>(
413
449
  if (!store) return;
414
450
 
415
451
  if (typeof dataOrFn === "function") {
416
- // Mark scope so ctx.use(loader) calls inside the callback
417
- // are not registered as handler-to-loader deps.
418
- insideHandlePush = true;
419
- try {
420
- const result = (dataOrFn as () => Promise<unknown>)();
421
- store.push(handle.$$id, segmentId, result);
422
- } finally {
423
- insideHandlePush = false;
424
- }
452
+ // Run the callback inside the push-callback scope so ctx.use(loader)
453
+ // calls it makes including after its own awaits, for an async
454
+ // callback — are not registered as handler-to-loader deps and do not
455
+ // trip the deadlock guard. A pushed promise value is not tracked by
456
+ // handleStore.settled and does not block segment resolution, so it
457
+ // cannot form a rendered() deadlock. The ALS scope (not a plain
458
+ // boolean) is what survives the callback's awaits.
459
+ const result = runInsidePushCallbackScope(() =>
460
+ (dataOrFn as () => Promise<unknown>)(),
461
+ );
462
+ store.push(handle.$$id, segmentId, result);
425
463
  return;
426
464
  }
427
465
 
@@ -433,9 +471,12 @@ export function setupLoaderAccess<TEnv>(
433
471
  // Skip when inside a DSL loader scope (resolveLoaderData also calls
434
472
  // ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
435
473
  // inside a handle push callback (push callbacks don't block segment
436
- // resolution so they can't cause rendered() deadlocks).
474
+ // resolution so they can't cause rendered() deadlocks). The push-callback
475
+ // check is an ALS scope so it also exempts an ASYNC callback's continuation
476
+ // after its first await — relevant on streaming trees, where the guard
477
+ // state now stays live until handleStore.settled.
437
478
  const loader = item as LoaderDefinition<any, any>;
438
- if (!isInsideLoaderScope() && !insideHandlePush) {
479
+ if (!isInsideLoaderScope() && !isInsidePushCallbackScope()) {
439
480
  const reqCtx = reqCtxRef ?? _getRequestContext();
440
481
  if (reqCtx) {
441
482
  // Direction 1: handler awaits loader that already called rendered()
@@ -449,13 +490,18 @@ export function setupLoaderAccess<TEnv>(
449
490
  `Move the data dependency to a loader-to-loader pattern instead.`,
450
491
  );
451
492
  }
452
- // Direction 2: track dep so rendered() can detect the deadlock
453
- // if the loader calls it later. Skip when the barrier has already
454
- // resolved no deadlock is possible (rendered() resolves immediately).
455
- // _renderBarrierSegmentOrder is undefined before resolution, string[]
456
- // after. This also prevents false positives from handle push callbacks
457
- // that resume after their first await (post-barrier-resolution).
458
- if (reqCtx._renderBarrierSegmentOrder === undefined) {
493
+ // Direction 2: track dep so rendered() can detect the deadlock if the
494
+ // loader calls it later. Skip once the guard window is CLOSED — for a
495
+ // non-streaming tree that is when the barrier resolves (rendered()
496
+ // resolves immediately), and for a streaming tree it is when
497
+ // handleStore.settled completes (rendered() keeps waiting until then, so
498
+ // a loading() handler resuming after the barrier can still form a
499
+ // cycle). Using the explicit guard-closed flag rather than
500
+ // _renderBarrierSegmentOrder keeps tracking live across the streaming
501
+ // settle wait. (Handle push callbacks are already excluded above via
502
+ // isInsidePushCallbackScope(), so they cannot produce false positives
503
+ // here.)
504
+ if (!reqCtx._renderBarrierGuardClosed) {
459
505
  if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
460
506
  reqCtx._handlerLoaderDeps.add(loader.$$id);
461
507
  }