@rangojs/router 0.0.0-experimental.115 → 0.0.0-experimental.116

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 (37) hide show
  1. package/dist/vite/index.js +148 -97
  2. package/package.json +17 -18
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/mime-routes/SKILL.md +1 -1
  5. package/skills/rango/SKILL.md +1 -0
  6. package/skills/response-routes/SKILL.md +61 -43
  7. package/skills/typesafety/SKILL.md +3 -3
  8. package/src/__augment-tests__/augmented.check.ts +2 -3
  9. package/src/build/collect-fallback-refs.ts +107 -0
  10. package/src/build/generate-manifest.ts +28 -1
  11. package/src/build/index.ts +8 -1
  12. package/src/build/prefix-tree-utils.ts +123 -0
  13. package/src/build/route-trie.ts +43 -0
  14. package/src/client.tsx +4 -23
  15. package/src/errors.ts +0 -3
  16. package/src/href-client.ts +7 -8
  17. package/src/index.rsc.ts +1 -2
  18. package/src/index.ts +1 -2
  19. package/src/router/find-match.ts +54 -6
  20. package/src/router/lazy-includes.ts +33 -14
  21. package/src/router/manifest.ts +19 -6
  22. package/src/router/pattern-matching.ts +15 -2
  23. package/src/router/router-interfaces.ts +11 -0
  24. package/src/router/trie-matching.ts +22 -3
  25. package/src/router.ts +21 -7
  26. package/src/rsc/manifest-init.ts +28 -41
  27. package/src/rsc/response-error.ts +79 -12
  28. package/src/rsc/response-route-handler.ts +16 -13
  29. package/src/urls/index.ts +1 -2
  30. package/src/urls/type-extraction.ts +33 -24
  31. package/src/vite/discovery/discover-routers.ts +46 -29
  32. package/src/vite/discovery/state.ts +7 -0
  33. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  34. package/src/vite/rango.ts +32 -4
  35. package/src/vite/utils/client-chunks.ts +41 -7
  36. package/src/vite/utils/manifest-utils.ts +8 -75
  37. package/src/vite/utils/shared-utils.ts +58 -0
@@ -16,7 +16,6 @@
16
16
 
17
17
  import type { GetRegisteredRoutes } from "./types.js";
18
18
  import type { JsonSerialize } from "./serialize.js";
19
- import type { ResponseEnvelope } from "./urls.js";
20
19
 
21
20
  /**
22
21
  * Parse constraint values into a union type for paths
@@ -163,16 +162,16 @@ type ResponsePayloadForKey<
163
162
  }[keyof TRoutes];
164
163
 
165
164
  /**
166
- * Public response type for a route, keyed by pattern or concrete path. The
167
- * payload is wrapped in `JsonSerialize` so it describes the JSON **wire** value a
168
- * consumer receives from `fetch().then(r => r.json())`, not the handler's raw
169
- * return type — e.g. a handler returning `{ createdAt: Date }` resolves here to
170
- * `ResponseEnvelope<{ createdAt: string }>`.
165
+ * Public response type for a route, keyed by pattern or concrete path. JSON
166
+ * response routes send the handler's return value verbatim (bare), so the
167
+ * payload is wrapped only in `JsonSerialize` to describe the JSON **wire** value
168
+ * a consumer receives from `fetch().then(r => r.json())` — e.g. a handler
169
+ * returning `{ createdAt: Date }` resolves here to `{ createdAt: string }`.
171
170
  */
172
171
  export type PathResponse<
173
172
  TPath extends string,
174
173
  TRoutes = GetRegisteredRoutes,
175
- > = ResponseEnvelope<JsonSerialize<ResponsePayloadFor<TPath, TRoutes>>>;
174
+ > = JsonSerialize<ResponsePayloadFor<TPath, TRoutes>>;
176
175
 
177
176
  /**
178
177
  * Strip trailing slash from a path (e.g., "/blog/" -> "/blog" | "/blog/")
@@ -256,7 +255,7 @@ declare global {
256
255
  *
257
256
  * The payload is the JSON **wire** shape (via `Rango.JsonSerialize`), not the
258
257
  * handler's raw return — a handler returning `{ createdAt: Date }` resolves
259
- * here to `ResponseEnvelope<{ createdAt: string }>`, matching what
258
+ * here to `{ createdAt: string }` (bare, no envelope), matching what
260
259
  * `fetch().then(r => r.json())` actually yields.
261
260
  *
262
261
  * Only resolves once `Rango.RegisteredRoutes` carries response metadata (the
package/src/index.rsc.ts CHANGED
@@ -138,8 +138,7 @@ export {
138
138
  type IncludeOptions,
139
139
  type IncludeItem,
140
140
  type RouteResponse,
141
- type ResponseError,
142
- type ResponseEnvelope,
141
+ type ProblemDetails,
143
142
  type ResponseHandler,
144
143
  type ResponseHandlerContext,
145
144
  type JsonResponseHandler,
package/src/index.ts CHANGED
@@ -104,8 +104,7 @@ export type {
104
104
  JsonResponsePathFn,
105
105
  TextResponsePathFn,
106
106
  RouteResponse,
107
- ResponseError,
108
- ResponseEnvelope,
107
+ ProblemDetails,
109
108
  } from "./urls.js";
110
109
 
111
110
  // Middleware context types
@@ -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
  }
@@ -1,5 +1,5 @@
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
4
  type EntryData,
5
5
  RangoContext,
@@ -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-stability-review.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>>();
@@ -145,10 +158,13 @@ export function evaluateLazyEntry<TEnv = any>(
145
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: "",
@@ -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-stability-review.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) {
@@ -176,7 +186,10 @@ export async function loadManifest(
176
186
  if (entry.lazy && entry.lazyPatterns) {
177
187
  const lazyPatterns = entry.lazyPatterns as UrlPatterns<any>;
178
188
  const includePrefix = (entry as any)._lazyPrefix || "";
179
- 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);
180
193
 
181
194
  if (fullPrefix || lazyContext?.namePrefix) {
182
195
  return runWithPrefixes(fullPrefix, lazyContext?.namePrefix, () =>
@@ -317,6 +317,21 @@ export function extractStaticPrefix(pattern: string): string {
317
317
  return pattern.slice(0, lastSlash);
318
318
  }
319
319
 
320
+ /**
321
+ * Join a URL prefix to a sub-prefix, collapsing the duplicate slash when the
322
+ * base ends with "/" and the sub-prefix starts with "/". This mirrors the
323
+ * canonical join in `include()` (urls/include-helper.ts) and `runWithPrefixes`
324
+ * (server/context.ts) so a nested lazy include's runtime staticPrefix matches
325
+ * the build-time trie's `sp` (e.g. `include("/parent/", …)` containing
326
+ * `include("/child", …)` resolves to `/parent/child`, not `/parent//child`).
327
+ */
328
+ export function joinPrefix(base: string | undefined, prefix: string): string {
329
+ if (!base) return prefix;
330
+ return base.endsWith("/") && prefix.startsWith("/")
331
+ ? base + prefix.slice(1)
332
+ : base + prefix;
333
+ }
334
+
320
335
  /**
321
336
  * Match a pathname against registered routes
322
337
  *
@@ -343,8 +358,6 @@ export interface RouteMatchResult<TEnv = any> {
343
358
  params: Record<string, string>;
344
359
  optionalParams: Set<string>;
345
360
  redirectTo?: string;
346
- /** Ancestry shortCodes for layout pruning (from trie match) */
347
- ancestry?: string[];
348
361
  /** Route has pre-rendered data available (from trie) */
349
362
  pr?: true;
350
363
  /** Passthrough: handler kept for live fallback on unknown params (from trie) */
@@ -365,6 +365,17 @@ export interface RangoInternal<
365
365
  /** @internal basename for runtime manifest generation */
366
366
  readonly __basename?: string;
367
367
 
368
+ /**
369
+ * @internal Router-level error/notFound fallbacks (`createRouter` options),
370
+ * exposed for the build-time clientChunks discovery so a `"use client"`
371
+ * default boundary is routed into the dedicated `app-fallback` chunk. Unlike
372
+ * the route-tree `errorBoundary()`/`notFoundBoundary()` helpers these never
373
+ * land in `EntryData`, so they are read directly off the router instance.
374
+ */
375
+ readonly __defaultErrorBoundary?: RangoOptions<TEnv>["defaultErrorBoundary"];
376
+ readonly __defaultNotFoundBoundary?: RangoOptions<TEnv>["defaultNotFoundBoundary"];
377
+ readonly __notFound?: RangoOptions<TEnv>["notFound"];
378
+
368
379
  match(
369
380
  request: Request,
370
381
  input?: RouterRequestInput<TEnv>,
@@ -19,8 +19,6 @@ export interface TrieMatchResult {
19
19
  * from `params` (read as `undefined`), matching the
20
20
  * `ExtractParams<"/:locale?/...">` type. */
21
21
  optionalParams?: string[];
22
- /** Ancestry shortCodes for layout pruning */
23
- ancestry: string[];
24
22
  /** Redirect target if trailing slash requires it */
25
23
  redirectTo?: string;
26
24
  /** Route has pre-rendered data available */
@@ -63,6 +61,19 @@ export function tryTrieMatch(
63
61
  pathnameHasTrailingSlash,
64
62
  );
65
63
  }
64
+ // A root-level wildcard ("/*") matches "/" with an empty remainder, the
65
+ // same value the regex matcher produces for the bare prefix. Without this
66
+ // the trie misses, the regex fallback runs, and its no-config branch emits
67
+ // a corrupt slice-off redirect. The static terminal still wins above.
68
+ if (trie.w) {
69
+ return validateAndBuild(
70
+ trie.w,
71
+ [],
72
+ "",
73
+ pathname,
74
+ pathnameHasTrailingSlash,
75
+ );
76
+ }
66
77
  return null;
67
78
  }
68
79
 
@@ -105,6 +116,15 @@ function walkTrie(
105
116
  if (node.r) {
106
117
  return { leaf: node.r, paramValues: [...paramValues] };
107
118
  }
119
+ // A wildcard at this node matches the bare prefix with an empty remainder
120
+ // (e.g. "/files" against "/files/*"), mirroring the regex matcher's `*=""`.
121
+ // walkTrie otherwise only reaches node.w in the index<length branch below,
122
+ // so without this a request to the wildcard's own prefix misses the trie
123
+ // and the regex fallback emits a corrupt redirect. A static terminal
124
+ // (node.r) still wins.
125
+ if (node.w) {
126
+ return { leaf: node.w, paramValues: [...paramValues], wildcardValue: "" };
127
+ }
108
128
  return null;
109
129
  }
110
130
 
@@ -229,7 +249,6 @@ function validateAndBuild(
229
249
  routeKey: leaf.n,
230
250
  sp: leaf.sp,
231
251
  params,
232
- ancestry: leaf.a,
233
252
  };
234
253
 
235
254
  if (leaf.op) result.optionalParams = leaf.op;
package/src/router.ts CHANGED
@@ -21,6 +21,7 @@ import type { AllUseItems } from "./route-types.js";
21
21
  import type { UrlPatterns } from "./urls.js";
22
22
  import type { UrlBuilder } from "./urls/pattern-types.js";
23
23
  import { urls } from "./urls.js";
24
+ import { buildPrecomputedByPrefix } from "./build/prefix-tree-utils.js";
24
25
  import {
25
26
  type EntryData,
26
27
  getContext,
@@ -70,6 +71,7 @@ import {
70
71
  } from "./router/middleware.js";
71
72
  import {
72
73
  extractStaticPrefix,
74
+ joinPrefix,
73
75
  traverseBack,
74
76
  } from "./router/pattern-matching.js";
75
77
  import { resolveSink, safeEmit, getRequestId } from "./router/telemetry.js";
@@ -363,9 +365,11 @@ export function createRouter<TEnv = any>(
363
365
  getRouterPrecomputedEntries(routerId) ?? getPrecomputedEntries();
364
366
  if (current !== precomputedSource) {
365
367
  precomputedSource = current;
366
- precomputedByPrefix = current
367
- ? new Map(current.map((e) => [e.staticPrefix, e.routes]))
368
- : null;
368
+ // buildPrecomputedByPrefix drops any staticPrefix owned by more than one
369
+ // leaf include instead of collapsing it last-wins (which would mis-assign
370
+ // one include's routes to another's entry and 500 a valid sibling route).
371
+ // Such shared-prefix includes resolve via the handler path instead.
372
+ precomputedByPrefix = current ? buildPrecomputedByPrefix(current) : null;
369
373
  }
370
374
  return precomputedByPrefix;
371
375
  }
@@ -832,10 +836,13 @@ export function createRouter<TEnv = any>(
832
836
 
833
837
  // Create placeholder RouteEntry for each lazy include
834
838
  for (const lazyInclude of lazyIncludes) {
835
- // Compute the full URL prefix (combining parent prefix if any)
836
- const fullPrefix = lazyInclude.context.urlPrefix
837
- ? lazyInclude.context.urlPrefix + lazyInclude.prefix
838
- : lazyInclude.prefix;
839
+ // Compute the full URL prefix (combining parent prefix if any). Use the
840
+ // slash-collapsing join so a trailing-slash parent prefix does not
841
+ // produce a double-slash staticPrefix the trie's sp can never match.
842
+ const fullPrefix = joinPrefix(
843
+ lazyInclude.context.urlPrefix,
844
+ lazyInclude.prefix,
845
+ );
839
846
 
840
847
  const lazyEntry: RouteEntry<TEnv> & { _lazyPrefix?: string } = {
841
848
  prefix: "",
@@ -998,6 +1005,13 @@ export function createRouter<TEnv = any>(
998
1005
  // Expose basename for runtime manifest generation
999
1006
  __basename: basename,
1000
1007
 
1008
+ // Expose router-level boundary defaults for build-time clientChunks
1009
+ // discovery (so a "use client" default boundary lands in app-fallback).
1010
+ // These are createRouter options, never pushed onto EntryData.
1011
+ __defaultErrorBoundary: defaultErrorBoundary,
1012
+ __defaultNotFoundBoundary: defaultNotFoundBoundary,
1013
+ __notFound: notFound,
1014
+
1001
1015
  // RSC request handler (lazily created on first call)
1002
1016
  fetch: (() => {
1003
1017
  // Handler is created on first call and reused
@@ -13,6 +13,7 @@ import {
13
13
  setRouteTrie,
14
14
  setRouterManifest,
15
15
  setRouterTrie,
16
+ setRouterPrecomputedEntries,
16
17
  } from "../route-map-builder.js";
17
18
 
18
19
  /**
@@ -36,47 +37,13 @@ export async function buildRouterTrieFromUrlpatterns(
36
37
  undefined,
37
38
  router.basename ? { urlPrefix: router.basename } : undefined,
38
39
  );
39
- if (
40
- generated._routeAncestry &&
41
- Object.keys(generated._routeAncestry).length > 0
42
- ) {
43
- const { buildRouteTrie } = await import("../build/route-trie.js");
44
- // Map each route to its include() staticPrefix so the trie
45
- // returns the correct sp for lazy entry lookup in findMatch.
46
- const routeToStaticPrefix: Record<string, string> = {};
47
- for (const name of Object.keys(generated.routeManifest)) {
48
- routeToStaticPrefix[name] = "";
49
- }
50
- // Override with prefix from include() entries so the trie
51
- // returns the correct sp for lazy entry lookup in findMatch.
52
- // Walk recursively to include routes in nested includes.
53
- if (generated.prefixTree) {
54
- const visitPrefixNode = (node: any): void => {
55
- const sp = node.staticPrefix || "";
56
- for (const route of node.routes || []) {
57
- routeToStaticPrefix[route] = sp;
58
- }
59
- for (const child of Object.values(node.children || {})) {
60
- visitPrefixNode(child);
61
- }
62
- };
63
- for (const node of Object.values(generated.prefixTree)) {
64
- visitPrefixNode(node);
65
- }
66
- }
67
- const trie = buildRouteTrie(
68
- generated.routeManifest,
69
- generated._routeAncestry,
70
- routeToStaticPrefix,
71
- generated.routeTrailingSlash,
72
- generated.prerenderRoutes
73
- ? new Set(generated.prerenderRoutes)
74
- : undefined,
75
- generated.passthroughRoutes
76
- ? new Set(generated.passthroughRoutes)
77
- : undefined,
78
- generated.responseTypeRoutes,
79
- );
40
+ // Build the trie through the SAME shared helper the production discovery uses
41
+ // (discover-routers.ts), so the dev runtime-rebuilt trie and the prod
42
+ // serialized trie cannot drift. buildPerRouterTrie returns null when there
43
+ // are no routes.
44
+ const { buildPerRouterTrie } = await import("../build/route-trie.js");
45
+ const trie = buildPerRouterTrie(generated);
46
+ if (trie) {
80
47
  setRouterTrie(router.id, trie);
81
48
  // Set global trie only if not already set by another router
82
49
  if (!getRouteTrie()) {
@@ -84,6 +51,26 @@ export async function buildRouterTrieFromUrlpatterns(
84
51
  }
85
52
  }
86
53
  setRouterManifest(router.id, generated.routeManifest);
54
+
55
+ // Match the production discovery path: precompute leaf-include entries so the
56
+ // match-time shortcut in evaluateLazyEntry applies in dev/Cloudflare too.
57
+ // Without this, dev re-runs each matched leaf include's handler at match time
58
+ // (evaluateLazyEntry) AND again at render time (loadManifest); with it, the
59
+ // match-time run is skipped and the handler runs once per first request.
60
+ // Identical route ownership to the handler path (the shortcut is guarded by
61
+ // the same prefixIsShared and #506 checks production uses).
62
+ const { flattenLeafEntries } = await import("../build/prefix-tree-utils.js");
63
+ const precomputed: Array<{
64
+ staticPrefix: string;
65
+ routes: Record<string, string>;
66
+ }> = [];
67
+ flattenLeafEntries(
68
+ generated.prefixTree,
69
+ generated.routeManifest,
70
+ precomputed,
71
+ );
72
+ setRouterPrecomputedEntries(router.id, precomputed);
73
+
87
74
  // Merge into global manifest (needed for reverse/href across routers)
88
75
  const existing = hasCachedManifest() ? getGlobalRouteMap() : {};
89
76
  setCachedManifest({ ...existing, ...generated.routeManifest });