@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -20,11 +20,25 @@ let cachedPrecomputedEntries: Array<{
20
20
  /**
21
21
  * Register routes into the global route map.
22
22
  * Routes are merged with any existing registered routes.
23
- * Called by createRouter() during module evaluation.
23
+ * Called by createRouter() during module evaluation, and by lazy-include
24
+ * expansion (src/router/lazy-includes.ts) with each expansion's route delta.
25
+ *
26
+ * Merges IN PLACE — O(|map|), not O(total routes). The previous
27
+ * `globalRouteMap = { ...globalRouteMap, ...map }` copy made every
28
+ * lazy-include first hit O(total routes) on the request path: with a 26k-route
29
+ * manifest the spread measured 8.9ms/call (M4, node), paid once per level of a
30
+ * nested async-include chain (3 calls on a 3-level chain — the 464ms edge
31
+ * cold-hit in issue #666).
32
+ *
33
+ * In-place mutation is safe because every getGlobalRouteMap() consumer reads
34
+ * it fresh per call (server/request-context.ts, rsc/loader-fetch.ts,
35
+ * router/intercept-resolution.ts, testing/generated-routes.ts,
36
+ * rsc/manifest-init.ts) — none memoizes the returned reference. If you add a
37
+ * consumer that caches the map object, it will now observe later
38
+ * registrations; snapshot it yourself if you need frozen contents.
24
39
  */
25
40
  export function registerRouteMap(map: Record<string, string>): void {
26
- // Always merge with existing map (don't replace)
27
- globalRouteMap = { ...globalRouteMap, ...map };
41
+ Object.assign(globalRouteMap, map);
28
42
  }
29
43
 
30
44
  /**
@@ -139,6 +153,7 @@ export function clearAllRouterData(): void {
139
153
  perRouterManifestMap.clear();
140
154
  perRouterTrieMap.clear();
141
155
  perRouterPrecomputedEntriesMap.clear();
156
+ authoritativeTrieRouters.clear();
142
157
  }
143
158
 
144
159
  export function setRouterManifest(
@@ -162,6 +177,23 @@ export function setRouterTrie(
162
177
  perRouterTrieMap.set(routerId, trie);
163
178
  }
164
179
 
180
+ // Routers whose trie came from the COMPLETE build manifest (deserialized via
181
+ // ensureRouterManifest). For these, a trie miss is a real 404 and findMatch
182
+ // skips the regex fallback scan — the only remaining route-count-proportional
183
+ // match path (#664). Dev rebuilds (manifest-init.ts, router-discovery HMR
184
+ // pushes) deliberately never mark authoritative: the dev-only trie-gap warning
185
+ // in find-match.ts depends on the fallback running on misses, and dev route
186
+ // churn (HMR, dev-time routes) makes a stale-trie 404 unacceptable there.
187
+ const authoritativeTrieRouters: Set<string> = new Set();
188
+
189
+ export function markRouterTrieAuthoritative(routerId: string): void {
190
+ authoritativeTrieRouters.add(routerId);
191
+ }
192
+
193
+ export function isRouterTrieAuthoritative(routerId: string): boolean {
194
+ return authoritativeTrieRouters.has(routerId);
195
+ }
196
+
165
197
  export function getRouterTrie(
166
198
  routerId: string,
167
199
  ): import("./build/route-trie.js").TrieNode | undefined {
@@ -204,7 +236,12 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
204
236
  if (loader) {
205
237
  const mod = await loader();
206
238
  if (mod.manifest) perRouterManifestMap.set(routerId, mod.manifest);
207
- if (mod.trie) perRouterTrieMap.set(routerId, mod.trie);
239
+ if (mod.trie) {
240
+ perRouterTrieMap.set(routerId, mod.trie);
241
+ // A trie serialized into the build manifest comes from complete
242
+ // discovery — misses are authoritative 404s (see find-match.ts).
243
+ markRouterTrieAuthoritative(routerId);
244
+ }
208
245
  if (mod.precomputedEntries)
209
246
  perRouterPrecomputedEntriesMap.set(routerId, mod.precomputedEntries);
210
247
  routerManifestLoaders.delete(routerId);
@@ -98,10 +98,6 @@ export type NotFoundBoundaryItem = {
98
98
  type: "notFoundBoundary";
99
99
  uses?: AllUseItems[];
100
100
  };
101
- export type WhenItem = {
102
- name: string;
103
- type: "when";
104
- };
105
101
  export type CacheItem = {
106
102
  name: string;
107
103
  type: "cache";
@@ -231,7 +227,6 @@ export type InterceptUseItem =
231
227
  | NotFoundBoundaryItem
232
228
  | LayoutItem
233
229
  | RouteItem
234
- | WhenItem
235
230
  | TransitionItem;
236
231
  export type LoaderUseItem = RevalidateItem | CacheItem;
237
232
 
@@ -1,5 +1,8 @@
1
1
  import { tryTrieMatch } from "./trie-matching.js";
2
- import { getRouterTrie } from "../route-map-builder.js";
2
+ import {
3
+ getRouterTrie,
4
+ isRouterTrieAuthoritative,
5
+ } from "../route-map-builder.js";
3
6
  import {
4
7
  findMatch as findRouteMatch,
5
8
  isLazyEvaluationNeeded,
@@ -20,27 +23,39 @@ function cloneMatchResult<TEnv>(
20
23
 
21
24
  export interface FindMatchDeps<TEnv = any> {
22
25
  routesEntries: RouteEntry<TEnv>[];
23
- evaluateLazyEntry: (entry: RouteEntry<TEnv>) => void;
26
+ // Returns a Promise only when it had to resolve an async include provider
27
+ // (`() => import()`); void for eager/precomputed includes, so the per-entry
28
+ // match loop pays no microtask in the common case.
29
+ evaluateLazyEntry: (entry: RouteEntry<TEnv>) => void | Promise<void>;
24
30
  routerId: string;
25
31
  }
26
32
 
27
33
  /**
28
34
  * Create a findMatch function bound to router state.
29
35
  * Includes single-entry cache to avoid redundant matching within the same request.
36
+ *
37
+ * Async because a lazy include may be backed by an async provider
38
+ * (`() => import("./routes")`) that must be resolved before its routes can be
39
+ * matched. The hot path is unaffected: `await` only suspends on the first
40
+ * request to a not-yet-loaded dynamic include; eager routes resolve in the same
41
+ * microtask. Every caller already runs inside an async match pipeline.
30
42
  */
31
43
  export function createFindMatch<TEnv = any>(
32
44
  deps: FindMatchDeps<TEnv>,
33
- ): (pathname: string, ms?: MetricsStore) => RouteMatchResult<TEnv> | null {
45
+ ): (
46
+ pathname: string,
47
+ ms?: MetricsStore,
48
+ ) => Promise<RouteMatchResult<TEnv> | null> {
34
49
  // Single-entry cache for findMatch to avoid redundant matching within the same request.
35
50
  // previewMatch and match both call findMatch with the same pathname — this ensures
36
51
  // the route matching work (which may check thousands of routes) only happens once.
37
52
  let lastFindMatchPathname: string | null = null;
38
53
  let lastFindMatchResult: RouteMatchResult<TEnv> | null = null;
39
54
 
40
- return function findMatch(
55
+ return async function findMatch(
41
56
  pathname: string,
42
57
  ms?: MetricsStore,
43
- ): RouteMatchResult<TEnv> | null {
58
+ ): Promise<RouteMatchResult<TEnv> | null> {
44
59
  if (lastFindMatchPathname === pathname) {
45
60
  return cloneMatchResult(lastFindMatchResult);
46
61
  }
@@ -71,11 +86,33 @@ export function createFindMatch<TEnv = any>(
71
86
  const entryStart = performance.now();
72
87
  let entry: RouteEntry<TEnv> | undefined;
73
88
  let fallbackEntry: RouteEntry<TEnv> | undefined;
89
+ let candidateError: unknown;
90
+ let candidateFailed = false;
74
91
 
75
92
  for (const e of deps.routesEntries) {
76
93
  if (e.staticPrefix !== trieResult.sp) continue;
77
94
  if (!fallbackEntry) fallbackEntry = e;
78
- deps.evaluateLazyEntry(e);
95
+ try {
96
+ const ev = deps.evaluateLazyEntry(e);
97
+ if (ev) await ev;
98
+ } catch (err) {
99
+ // Multiple async includes can share one collapsed static prefix, so
100
+ // this loop may evaluate a candidate that is NOT the route's owner.
101
+ // Remember the failure and keep scanning: if another candidate owns
102
+ // the route, this sibling's failure is correctly isolated (its
103
+ // _lazyInflight was cleared, so a later request retries). But if NO
104
+ // candidate ends up owning it, the failing one WAS the owner — a
105
+ // real, trie-matched route whose module failed to import — and we
106
+ // rethrow below as a 5xx rather than silently 404ing (see finding 3:
107
+ // owner failure must stay loud).
108
+ candidateError = err;
109
+ candidateFailed = true;
110
+ console.error(
111
+ `[@rangojs/router] include at "${e.staticPrefix}" failed to load: ` +
112
+ `${(err as Error)?.message ?? String(err)}`,
113
+ );
114
+ continue;
115
+ }
79
116
  if (
80
117
  e.routes &&
81
118
  trieResult.routeKey in (e.routes as Record<string, unknown>)
@@ -85,6 +122,14 @@ export function createFindMatch<TEnv = any>(
85
122
  }
86
123
  }
87
124
 
125
+ // The trie matched this route, so it EXISTS. If no same-prefix candidate
126
+ // could own it AND one failed to load, the owning module is the one that
127
+ // threw — surface the module failure as a 5xx, not the misleading 404 a
128
+ // fallthrough would give.
129
+ if (!entry && candidateFailed) {
130
+ throw candidateError;
131
+ }
132
+
88
133
  if (!entry) entry = fallbackEntry;
89
134
 
90
135
  if (!entry) {
@@ -95,7 +140,8 @@ export function createFindMatch<TEnv = any>(
95
140
  );
96
141
  if (parent) {
97
142
  const lazyStart = performance.now();
98
- deps.evaluateLazyEntry(parent);
143
+ const ev = deps.evaluateLazyEntry(parent);
144
+ if (ev) await ev;
99
145
  pushMetric?.("match:lazy-eval", lazyStart);
100
146
  }
101
147
  entry = deps.routesEntries.find(
@@ -123,6 +169,17 @@ export function createFindMatch<TEnv = any>(
123
169
  };
124
170
  return cloneMatchResult(lastFindMatchResult);
125
171
  }
172
+ } else if (isRouterTrieAuthoritative(deps.routerId)) {
173
+ // Authoritative miss (#664): this trie was deserialized from the
174
+ // COMPLETE build manifest, so trailing-slash redirects are already
175
+ // trie-native hits and a miss means no route exists. Skip the regex
176
+ // fallback — the only route-count-proportional match path — and do
177
+ // not evaluate lazy includes for unmatched (bot-probe) traffic.
178
+ // Trie hits that need lazy splicing keep the fallback loop below
179
+ // (trieMatched === true never reaches this branch).
180
+ lastFindMatchPathname = pathname;
181
+ lastFindMatchResult = null;
182
+ return null;
126
183
  }
127
184
  }
128
185
 
@@ -141,7 +198,28 @@ export function createFindMatch<TEnv = any>(
141
198
  lastFindMatchResult = null;
142
199
  return null;
143
200
  }
144
- deps.evaluateLazyEntry(result.lazyEntry);
201
+ try {
202
+ const ev = deps.evaluateLazyEntry(result.lazyEntry);
203
+ if (ev) await ev;
204
+ } catch (err) {
205
+ // If the trie matched this pathname, the route is REAL and this lazy
206
+ // entry owns (part of) it — a module-load failure is a genuine error, so
207
+ // propagate it as a 5xx. If the trie did NOT match, this is a
208
+ // genuinely-unmatched pathname (a typo, a bot probe): its 404 must not be
209
+ // upgraded to a 500 just because some lazy include — e.g. a failing root
210
+ // `include("/")` — happened to need evaluating while we probed. Isolate
211
+ // and fall through to the 404 (finding 4: the two match phases agree —
212
+ // owner failure is loud, unmatched stays 404).
213
+ if (trieMatched) throw err;
214
+ console.error(
215
+ `[@rangojs/router] include at "${result.lazyEntry.staticPrefix}" ` +
216
+ `failed to load while resolving unmatched path "${pathname}": ` +
217
+ `${(err as Error)?.message ?? String(err)}`,
218
+ );
219
+ lastFindMatchPathname = pathname;
220
+ lastFindMatchResult = null;
221
+ return null;
222
+ }
145
223
  result = findRouteMatch(pathname, deps.routesEntries);
146
224
  }
147
225
  pushMetric?.("match:regex-fallback", regexStart);
@@ -318,10 +318,10 @@ export function observeHandler<C, R>(
318
318
  * sink is configured.
319
319
  *
320
320
  * This is the canonical emitter for SYNCHRONOUS facts that fire inside the
321
- * request's ALS scope (handler errors, timeouts, origin rejections, revalidation
322
- * decisions). A few emitters deliberately stay on the lower-level
323
- * resolveSink + safeEmit because observeEvent's lazy, per-call
324
- * getRouterContext() read does not fit them — keep this the complete list:
321
+ * request's ALS scope (revalidation decisions, cache-lookup decisions). A few
322
+ * emitters deliberately stay on the lower-level resolveSink + safeEmit because
323
+ * observeEvent's lazy, per-call getRouterContext() read does not fit them — keep
324
+ * this the complete list:
325
325
  * - router.ts wrapLoaderPromise (loader.start/end/error) and
326
326
  * segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
327
327
  * capture the sink + request id EAGERLY and emit from a fire-and-forget
@@ -330,6 +330,11 @@ export function observeHandler<C, R>(
330
330
  * loop (request.start/end/error, cache.decision, ...).
331
331
  * - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
332
332
  * sink rather than the ALS router context.
333
+ * - rsc/handler.ts handleTimeoutResponse (request.timeout), the origin guard
334
+ * (request.origin-rejected), and handleStore.onError (late-handle
335
+ * handler.error) emit via router.telemetry directly — they run outside the
336
+ * RouterContext ALS (only match()/matchPartial() enter it), so a
337
+ * getRouterContext() read there throws and the event would vanish.
333
338
  */
334
339
  export function observeEvent(event: TelemetryEvent): void {
335
340
  // getRouterContext() either throws (real impl, outside a router context — e.g.
@@ -9,6 +9,10 @@ import {
9
9
  import type { UrlPatterns } from "../urls.js";
10
10
  import type { AllUseItems, IncludeItem } from "../route-types.js";
11
11
  import type { ResolvedRouteMap, RouteEntry, TrailingSlashMode } from "../types";
12
+ import {
13
+ isIncludeProvider,
14
+ resolveIncludeModule,
15
+ } from "../urls/include-provider.js";
12
16
 
13
17
  export interface LazyEvalDeps<TEnv = any> {
14
18
  routesEntries: RouteEntry<TEnv>[];
@@ -69,7 +73,7 @@ export function findLazyIncludes<TEnv = any>(
69
73
  export function evaluateLazyEntry<TEnv = any>(
70
74
  entry: RouteEntry<TEnv>,
71
75
  deps: LazyEvalDeps<TEnv>,
72
- ): void {
76
+ ): void | Promise<void> {
73
77
  if (!entry.lazy || entry.lazyEvaluated || !entry.lazyPatterns) {
74
78
  return;
75
79
  }
@@ -87,15 +91,62 @@ export function evaluateLazyEntry<TEnv = any>(
87
91
  for (const [name, pattern] of Object.entries(routes)) {
88
92
  deps.mergedRouteMap[name] = pattern;
89
93
  }
90
- registerRouteMap(deps.mergedRouteMap);
94
+ // Register only this entry's routes (the delta): the full
95
+ // mergedRouteMap is seeded from the generated manifest at
96
+ // createRouter() time and already registered there — re-passing it
97
+ // made this request-path call O(total routes) (issue #666).
98
+ registerRouteMap(routes);
91
99
  return;
92
100
  }
93
101
  }
94
102
  }
95
103
 
96
- entry.lazyEvaluated = true;
104
+ // Async provider (`() => import("./routes")`): the route module is evaluated
105
+ // off the startup path, on the first request reaching this prefix. Concurrent
106
+ // first-hits share one in-flight promise so the import + expansion run exactly
107
+ // once. The eager path below stays fully synchronous (no Promise), so the
108
+ // per-entry match loop pays no microtask for normal includes.
109
+ const lazyPatterns = entry.lazyPatterns;
110
+ if (isIncludeProvider(lazyPatterns)) {
111
+ const inflight = (entry as { _lazyInflight?: Promise<void> })._lazyInflight;
112
+ if (inflight) return inflight;
113
+ const work = (async () => {
114
+ const resolved = resolveIncludeModule(
115
+ await lazyPatterns(),
116
+ entry.staticPrefix,
117
+ );
118
+ // Cache the resolved patterns: any later re-entry expands synchronously.
119
+ entry.lazyPatterns = resolved as unknown as UrlPatterns<TEnv>;
120
+ runExpansion(entry, deps, resolved as UrlPatterns<TEnv>);
121
+ })();
122
+ (entry as { _lazyInflight?: Promise<void> })._lazyInflight = work;
123
+ const clear = () => {
124
+ (entry as { _lazyInflight?: Promise<void> })._lazyInflight = undefined;
125
+ };
126
+ // On failure, clear the flag (lazyEvaluated stays false) so a later request
127
+ // can retry the import rather than wedging the route permanently.
128
+ work.then(clear, clear);
129
+ return work;
130
+ }
131
+
132
+ runExpansion(entry, deps, lazyPatterns as UrlPatterns<TEnv>);
133
+ }
97
134
 
98
- const lazyPatterns = entry.lazyPatterns as UrlPatterns<TEnv>;
135
+ /**
136
+ * Synchronously expand a lazy entry's (already-resolved) patterns into routes
137
+ * and splice any nested lazy includes as new entries. Runs once per entry.
138
+ */
139
+ function runExpansion<TEnv = any>(
140
+ entry: RouteEntry<TEnv>,
141
+ deps: LazyEvalDeps<TEnv>,
142
+ lazyPatterns: UrlPatterns<TEnv>,
143
+ ): void {
144
+ // lazyEvaluated is set at the END, only after the handler ran and the routes
145
+ // (and any nested includes) were spliced. Setting it up-front would mark the
146
+ // entry done even if the handler throws mid-expansion: the async provider
147
+ // path clears _lazyInflight on rejection so a later request can retry, but a
148
+ // premature lazyEvaluated=true would make evaluateLazyEntry short-circuit
149
+ // (line ~77) forever, wedging the route at 404 until the isolate restarts.
99
150
  const lazyContext = entry.lazyContext;
100
151
 
101
152
  const manifest = new Map<string, EntryData>();
@@ -105,12 +156,9 @@ export function evaluateLazyEntry<TEnv = any>(
105
156
 
106
157
  let handlerResult: AllUseItems[] = [];
107
158
 
108
- const lazyCounters: Record<string, number> = {};
109
- if (lazyContext?.counters) {
110
- for (const [key, value] of Object.entries(lazyContext.counters)) {
111
- lazyCounters[key] = value;
112
- }
113
- }
159
+ const lazyCounters: Record<string, number> = lazyContext?.counters
160
+ ? { ...lazyContext.counters }
161
+ : {};
114
162
 
115
163
  RangoContext.run(
116
164
  {
@@ -170,7 +218,12 @@ export function evaluateLazyEntry<TEnv = any>(
170
218
  staticPrefix: extractStaticPrefix(fullPrefix),
171
219
  routes: {} as ResolvedRouteMap<any>,
172
220
  trailingSlash: entry.trailingSlash,
173
- handler: (lazyInclude.patterns as UrlPatterns<TEnv>).handler,
221
+ // include entries don't invoke their own handler (real handlers come from
222
+ // the expanded routes); use the parent's placeholder. A provider has no
223
+ // `.handler` until resolved, so never read it here.
224
+ handler: isIncludeProvider(lazyInclude.patterns)
225
+ ? entry.handler
226
+ : (lazyInclude.patterns as UrlPatterns<TEnv>).handler,
174
227
  mountIndex: deps.nextMountIndex(),
175
228
  routerId: deps.routerId,
176
229
  lazy: true,
@@ -196,5 +249,12 @@ export function evaluateLazyEntry<TEnv = any>(
196
249
  deps.routesEntries.splice(insertIndex, 0, nestedEntry);
197
250
  }
198
251
 
199
- registerRouteMap(deps.mergedRouteMap);
252
+ // Delta only — see the matching comment on the precomputed branch above and
253
+ // the WHY block on registerRouteMap (issue #666).
254
+ registerRouteMap(routesObject);
255
+
256
+ // Expansion fully succeeded (handler ran, routes + nested includes spliced) —
257
+ // mark done now so a mid-expansion throw above leaves lazyEvaluated=false and
258
+ // the entry retriable.
259
+ entry.lazyEvaluated = true;
200
260
  }
@@ -420,10 +420,22 @@ function createLoaderExecutor<TEnv>(
420
420
  // throw. rendered() gating uses the captured isDslLoader (above), so this
421
421
  // does not grant rendered() to handler-invoked loaders. Uses a body-only
422
422
  // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
423
+ //
424
+ // `handlerInvoked` (!isDslLoader) rides on the scope for the CONSUMPTION-
425
+ // LANE RULE: a handler-consumed loader's value is a BAKED copy in every
426
+ // shared artifact (cache(), "use cache", the PPR shell), so its identity
427
+ // reads are exempt from the shell-capture guard — same allowance the
428
+ // cache-purity guards give it. DSL segment loaders keep their lane
429
+ // machinery (live = masked at capture, bake = guarded). A DSL loader's
430
+ // nested deps inherit isDslLoader=false only when the CHAIN started in a
431
+ // handler; a chain started by the segment funnel stays DSL (the loader
432
+ // scope ALS survives the body's awaits).
423
433
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
424
434
  Promise.resolve(
425
- runInsideLoaderBodyScope(() =>
426
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
435
+ runInsideLoaderBodyScope(
436
+ () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
437
+ loader.$$id,
438
+ !isDslLoader,
427
439
  ),
428
440
  ).finally(() => {
429
441
  pendingLoaders.delete(loader.$$id);
@@ -11,8 +11,17 @@ import MapRootLayout from "../server/root-layout";
11
11
  import { joinPrefix } from "./pattern-matching.js";
12
12
  import type { RouteEntry } from "../types";
13
13
  import type { UrlPatterns } from "../urls";
14
+ import {
15
+ isIncludeProvider,
16
+ resolveIncludeModule,
17
+ } from "../urls/include-provider.js";
14
18
  import { VERSION } from "@rangojs/router:version";
15
19
 
20
+ // Tags an error thrown while dynamically importing an async include's module
21
+ // (`() => import("./routes")`) so loadManifest's outer catch re-raises it as a
22
+ // server error rather than masking it as a RouteNotFoundError (404).
23
+ const MODULE_LOAD_FAILURE = Symbol.for("rango.moduleLoadFailure");
24
+
16
25
  // Module-level manifest cache: avoids re-executing DSL handler on every request.
17
26
  // Handler execution is deterministic (components, loaders, middleware are module-level
18
27
  // stable references), so the resulting EntryData tree can be safely cached and reused
@@ -73,10 +82,14 @@ export async function loadManifest(
73
82
  Store.mountIndex = mountIndex;
74
83
  Store.isSSR = isSSR;
75
84
  if (metricsStore) Store.metrics = metricsStore;
76
- // Restore cached manifest into Store
77
- for (const [k, v] of cached) {
78
- Store.manifest.set(k, v);
79
- }
85
+ // Alias the request-scoped Store to the cached Map instead of copying it
86
+ // entry-by-entry. At request time this Store is throwaway (no ambient
87
+ // RangoContext store spans classify->match->render, so getOrCreateStore
88
+ // returns a fresh detached store here), so nothing mutates it in place after
89
+ // loadManifest returns. The fresh path below REASSIGNS Store.manifest rather
90
+ // than clearing it in place, so a later fresh load sharing this Store cannot
91
+ // poison the cached Map.
92
+ Store.manifest = cached;
80
93
  pushMetric?.("manifest:cache-hit", cacheStart);
81
94
  return cached.get(routeKey)!;
82
95
  }
@@ -94,7 +107,11 @@ export async function loadManifest(
94
107
  pushMetric?.("manifest:store-setup", storeSetupStart);
95
108
 
96
109
  const clearStart = performance.now();
97
- Store.manifest.clear();
110
+ // Reassign rather than clear() in place: a prior cache-hit may have aliased
111
+ // Store.manifest to a shared module-cache Map (see cache-hit branch above),
112
+ // and clearing it in place would poison that cache. A fresh Map isolates this
113
+ // build; the cache still receives an independent copy at the end.
114
+ Store.manifest = new Map();
98
115
  pushMetric?.("manifest:clear", clearStart);
99
116
 
100
117
  try {
@@ -165,6 +182,29 @@ export async function loadManifest(
165
182
  // not exist in the non-lazy (root handler) path and would produce
166
183
  // mismatched shortCodes.
167
184
  if (entry.lazy && entry.lazyPatterns) {
185
+ // Resolve an async include provider (`() => import("./routes")`) before
186
+ // running its handler. The match-time precomputed shortcut can skip
187
+ // evaluateLazyEntry's resolution, so render-time must resolve it here;
188
+ // cache the resolved patterns on the entry so later renders reuse them.
189
+ if (isIncludeProvider(entry.lazyPatterns)) {
190
+ try {
191
+ entry.lazyPatterns = resolveIncludeModule(
192
+ await entry.lazyPatterns(),
193
+ entry.staticPrefix,
194
+ ) as unknown as UrlPatterns<any>;
195
+ } catch (err) {
196
+ // A failed dynamic import of a REAL, matched route's module is a
197
+ // server error, not a missing route. Tag it so the outer catch
198
+ // surfaces it as a 5xx instead of masking it as a
199
+ // RouteNotFoundError — which renders the 404 page (monitoring
200
+ // misses the failure and a CDN could cache the 404).
201
+ if (err && typeof err === "object") {
202
+ (err as Record<PropertyKey, unknown>)[MODULE_LOAD_FAILURE] =
203
+ true;
204
+ }
205
+ throw err;
206
+ }
207
+ }
168
208
  const lazyPatterns = entry.lazyPatterns as UrlPatterns<any>;
169
209
  const includePrefix = (entry as any)._lazyPrefix || "";
170
210
  // Slash-collapsing join so a trailing-slash parent prefix does not
@@ -192,12 +232,7 @@ export async function loadManifest(
192
232
 
193
233
  if (promiseResult !== null) {
194
234
  const load = await (promiseResult as Promise<any>);
195
- if (
196
- load &&
197
- load !== null &&
198
- typeof load === "object" &&
199
- "default" in load
200
- ) {
235
+ if (load && typeof load === "object" && "default" in load) {
201
236
  // Promise<{ default: () => Array }> - e.g., dynamic import
202
237
  if (typeof load.default !== "function") {
203
238
  throw new Error(
@@ -255,6 +290,16 @@ export async function loadManifest(
255
290
 
256
291
  return Store.manifest.get(routeKey)!;
257
292
  } catch (e) {
293
+ // A tagged async-include module-load failure is a server error for a REAL
294
+ // matched route — re-raise it unchanged (becomes a 5xx) instead of masking
295
+ // it as a RouteNotFoundError (which renders/caches a 404).
296
+ if (
297
+ e &&
298
+ typeof e === "object" &&
299
+ (e as Record<PropertyKey, unknown>)[MODULE_LOAD_FAILURE]
300
+ ) {
301
+ throw e;
302
+ }
258
303
  throw new RouteNotFoundError(
259
304
  `Failed to load route handlers for ${path}: ${(e as Error).message}`,
260
305
  {