@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -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 +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -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 +33 -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/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -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,51 +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
- // Empty string is treated as omitted — the trie matcher fills
178
- // unmatched optional params with "" (not undefined), so reverse
179
- // must collapse those segments instead of leaving empty slots.
180
- if (value === undefined || value === "") {
181
- hadOmittedOptional = true;
182
- return "";
183
- }
184
- return encodeURIComponent(value);
185
- },
186
- );
187
- // Second pass: required params (no trailing ?)
188
- result = result.replace(
189
- /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
190
- (_, key) => {
191
- const value = effectiveParams[key];
192
- if (value === undefined) {
193
- throw new Error(`Missing param "${key}" for route "${name}"`);
194
- }
195
- return encodeURIComponent(value);
196
- },
197
- );
198
- // Clean up slashes only when an optional param was actually omitted,
199
- // so intentional trailing-slash patterns like "/blog/" are preserved.
200
- if (hadOmittedOptional) {
201
- const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
202
- result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
203
- if (hadTrailingSlash && !result.endsWith("/")) result += "/";
204
- }
205
- }
168
+ let result = effectiveParams
169
+ ? substitutePatternParams(pattern, effectiveParams, name)
170
+ : pattern;
206
171
 
207
172
  // Append search params as query string
208
173
  if (search) {
@@ -281,8 +246,12 @@ export function createHandlerContext<TEnv>(
281
246
  search: searchSchema ? resolvedSearchParams : {},
282
247
  pathname,
283
248
  url,
284
- originalUrl: new URL(request.url),
249
+ originalUrl: requestContext?.originalUrl ?? new URL(request.url),
285
250
  env: bindings,
251
+ waitUntil: requestContext
252
+ ? requestContext.waitUntil.bind(requestContext)
253
+ : fireAndForgetWaitUntil,
254
+ executionContext: requestContext?.executionContext,
286
255
  _variables: variables,
287
256
  get: ((keyOrVar: any) => {
288
257
  // Read-time guard: non-cacheable var inside cache() → throw.
@@ -387,6 +356,12 @@ export function createPrerenderContext<TEnv>(
387
356
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
388
357
  );
389
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,
390
365
  _variables: variables,
391
366
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
392
367
  set: ((keyOrVar: any, value: any) => {
@@ -476,6 +451,11 @@ export function createStaticContext<TEnv>(
476
451
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
477
452
  );
478
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,
479
459
  _variables: variables,
480
460
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
481
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
  }
@@ -14,6 +14,7 @@ import {
14
14
  type MetricsStore,
15
15
  } from "../server/context";
16
16
  import MapRootLayout from "../server/root-layout";
17
+ import { joinPrefix } from "./pattern-matching.js";
17
18
  import type { RouteEntry } from "../types";
18
19
  import type { UrlPatterns } from "../urls";
19
20
  import { VERSION } from "@rangojs/router:version";
@@ -23,10 +24,17 @@ import { VERSION } from "@rangojs/router:version";
23
24
  // stable references), so the resulting EntryData tree can be safely cached and reused
24
25
  // across requests within the same isolate.
25
26
  //
26
- // Cache is keyed by (VERSION, mountIndex, routeKey, isSSR). VERSION comes from the
27
+ // Cache is keyed by (VERSION, routerId, mountIndex, routeKey, isSSR). routeKey is
28
+ // REQUIRED in the key: loadManifest() runs the handler with forRoute=routeKey, and
29
+ // path-helper.ts prunes (skips registering) every route except forRoute, so the
30
+ // resulting Store.manifest is pruned to the requested route — NOT the full include.
31
+ // Dropping routeKey would make a sibling route miss and overwrite this entry with its
32
+ // own pruned manifest, so alternating sibling requests would thrash (re-run the
33
+ // handler every time). Running the include handler once per isolate instead of once
34
+ // per route is possible but needs an unpruned manifest cache with prune-on-read — see
35
+ // LP1 in docs/internal/matching-and-lazy-discovery.md. VERSION comes from the
27
36
  // @rangojs/router:version virtual module which Vite invalidates on RSC module HMR.
28
37
  // When VERSION changes, this module re-evaluates and the cache is recreated empty.
29
- // Including VERSION in the key is additional defense against stale entries.
30
38
  const manifestModuleCache = new Map<string, Map<string, EntryData>>();
31
39
 
32
40
  /**
@@ -34,8 +42,8 @@ const manifestModuleCache = new Map<string, Map<string, EntryData>>();
34
42
  * Handles lazy imports, unwrapping, and validation
35
43
  *
36
44
  * Results are cached at module level after first execution. Subsequent calls
37
- * for the same (routeKey, isSSR) within the same isolate return cached data
38
- * without re-executing the DSL handler.
45
+ * for the same (routerId, mountIndex, routeKey, isSSR) within the same isolate
46
+ * return cached data without re-executing the DSL handler.
39
47
  */
40
48
  /**
41
49
  * Clear the module-level manifest cache.
@@ -65,9 +73,11 @@ export async function loadManifest(
65
73
 
66
74
  const mountIndex = entry.mountIndex;
67
75
 
68
- // Check module-level cache (persists across requests within same isolate)
76
+ // Check module-level cache (persists across requests within same isolate).
69
77
  // Include routerId so multi-router setups (host routing) don't share cached
70
78
  // EntryData across routers with overlapping mountIndex + routeKey combinations.
79
+ // routeKey is in the key because loadManifest() builds a manifest pruned to
80
+ // forRoute=routeKey (see path-helper.ts) — see the cache comment above.
71
81
  const cacheKey = `${VERSION}:${entry.routerId ?? ""}:${mountIndex ?? ""}:${routeKey}:${isSSR ? 1 : 0}`;
72
82
  const cached = manifestModuleCache.get(cacheKey);
73
83
  if (cached) {
@@ -126,28 +136,37 @@ export async function loadManifest(
126
136
  // were created during pattern extraction. This prevents shortCode
127
137
  // collisions between lazy and non-lazy entries under the same parent
128
138
  // (e.g., ArticlesLayout and BlogLayout both under NavLayout).
129
- if (lazyContext && (lazyContext as any).counters) {
130
- const captured = (lazyContext as any).counters as Record<string, number>;
131
- for (const [key, value] of Object.entries(captured)) {
139
+ if (lazyContext?.counters) {
140
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
132
141
  Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
133
142
  }
134
143
  }
135
144
 
136
145
  // Propagate cache profiles for DSL-time cache("profileName") resolution.
137
146
  // Non-lazy entries carry profiles directly; lazy entries carry them
138
- // in the captured lazyContext from include() time.
139
- const entryProfiles =
140
- entry.cacheProfiles ?? (lazyContext as any)?.cacheProfiles;
141
- if (entryProfiles) {
142
- Store.cacheProfiles = entryProfiles;
143
- }
147
+ // in the captured lazyContext from include() time. Always write
148
+ // (including clearing to undefined) so a prior lazy build's profile
149
+ // map cannot leak into a later non-lazy build on the same ALS-backed
150
+ // Store — which would otherwise let cache("name") resolve a profile
151
+ // from an unrelated entry.
152
+ Store.cacheProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
144
153
 
145
154
  // Propagate rootScoped from lazyContext so that routes inside
146
155
  // nested { name: "sub" } under { name: "" } keep inherited root scope
147
- // when the manifest is rebuilt on each request.
148
- if (lazyContext && (lazyContext as any).rootScoped !== undefined) {
149
- Store.rootScoped = (lazyContext as any).rootScoped;
150
- }
156
+ // when the manifest is rebuilt on each request. Always write
157
+ // (including clearing to undefined, which makes getRootScoped()
158
+ // return its true default) so a prior lazy build's scope cannot leak
159
+ // into a later non-lazy build on the same ALS-backed Store — which
160
+ // would otherwise mis-register plain routes as non-root-scoped and
161
+ // break dot-local reverse resolution.
162
+ Store.rootScoped = lazyContext?.rootScoped;
163
+
164
+ // Propagate includeScope from lazyContext so that direct-descendant
165
+ // shortCodes of this include use the correct scoped counter namespace
166
+ // on every manifest rebuild. Always write (including clearing to
167
+ // undefined) so a prior lazy build's scope cannot leak into a later
168
+ // non-lazy build on the same ALS-backed Store.
169
+ Store.includeScope = lazyContext?.includeScope;
151
170
 
152
171
  const handlerExecStart = performance.now();
153
172
  const useItems = await getContext().runWithStore(
@@ -167,7 +186,10 @@ export async function loadManifest(
167
186
  if (entry.lazy && entry.lazyPatterns) {
168
187
  const lazyPatterns = entry.lazyPatterns as UrlPatterns<any>;
169
188
  const includePrefix = (entry as any)._lazyPrefix || "";
170
- const fullPrefix = (lazyContext?.urlPrefix || "") + includePrefix;
189
+ // Slash-collapsing join so a trailing-slash parent prefix does not
190
+ // bake a double slash into the registered route patterns (must match
191
+ // the same join in evaluateLazyEntry / the build-time runWithPrefixes).
192
+ const fullPrefix = joinPrefix(lazyContext?.urlPrefix, includePrefix);
171
193
 
172
194
  if (fullPrefix || lazyContext?.namePrefix) {
173
195
  return runWithPrefixes(fullPrefix, lazyContext?.namePrefix, () =>
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
22
22
  import { traverseBack } from "./pattern-matching.js";
23
23
  import { DefaultErrorFallback } from "../default-error-boundary.js";
24
24
  import {
25
- EntryData,
26
- LoaderEntry,
25
+ type EntryData,
26
+ type LoaderEntry,
27
27
  getContext,
28
- InterceptSelectorContext,
28
+ type InterceptSelectorContext,
29
29
  } from "../server/context";
30
30
  import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
31
31
  import type { ReactNode } from "react";
@@ -550,6 +550,7 @@ export async function matchError<TEnv>(
550
550
  segments: [errorSegment],
551
551
  matched: matchedIds,
552
552
  diff: [errorSegment.id],
553
+ resolvedIds: [errorSegment.id],
553
554
  params: matched.params,
554
555
  };
555
556
  }
@@ -196,6 +196,7 @@ export function createMatchHandlers<TEnv = any>(
196
196
  segments: [],
197
197
  matched: [],
198
198
  diff: [],
199
+ resolvedIds: [],
199
200
  params: {},
200
201
  redirect: result.redirectUrl,
201
202
  };