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

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 (47) hide show
  1. package/dist/vite/index.js +148 -97
  2. package/package.json +1 -1
  3. package/skills/api-client/SKILL.md +211 -0
  4. package/skills/loader/SKILL.md +17 -17
  5. package/skills/mime-routes/SKILL.md +1 -1
  6. package/skills/rango/SKILL.md +1 -0
  7. package/skills/response-routes/SKILL.md +61 -43
  8. package/skills/typesafety/SKILL.md +3 -3
  9. package/src/__augment-tests__/augmented.check.ts +2 -3
  10. package/src/browser/navigation-client.ts +56 -68
  11. package/src/browser/prefetch/cache.ts +58 -27
  12. package/src/browser/prefetch/fetch.ts +92 -33
  13. package/src/browser/response-adapter.ts +7 -1
  14. package/src/browser/rsc-router.tsx +5 -0
  15. package/src/build/collect-fallback-refs.ts +107 -0
  16. package/src/build/generate-manifest.ts +28 -1
  17. package/src/build/index.ts +8 -1
  18. package/src/build/prefix-tree-utils.ts +123 -0
  19. package/src/build/route-trie.ts +43 -0
  20. package/src/client.tsx +4 -23
  21. package/src/errors.ts +0 -3
  22. package/src/href-client.ts +7 -8
  23. package/src/index.rsc.ts +1 -2
  24. package/src/index.ts +1 -2
  25. package/src/router/find-match.ts +54 -6
  26. package/src/router/lazy-includes.ts +33 -14
  27. package/src/router/loader-resolution.ts +63 -34
  28. package/src/router/manifest.ts +19 -6
  29. package/src/router/pattern-matching.ts +15 -2
  30. package/src/router/router-interfaces.ts +11 -0
  31. package/src/router/trie-matching.ts +22 -3
  32. package/src/router.ts +21 -7
  33. package/src/rsc/manifest-init.ts +28 -41
  34. package/src/rsc/response-error.ts +79 -12
  35. package/src/rsc/response-route-handler.ts +16 -13
  36. package/src/server/context.ts +32 -0
  37. package/src/server/request-context.ts +47 -9
  38. package/src/types/loader-types.ts +6 -3
  39. package/src/urls/index.ts +1 -2
  40. package/src/urls/type-extraction.ts +33 -24
  41. package/src/vite/discovery/discover-routers.ts +46 -29
  42. package/src/vite/discovery/state.ts +7 -0
  43. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  44. package/src/vite/rango.ts +32 -4
  45. package/src/vite/utils/client-chunks.ts +41 -7
  46. package/src/vite/utils/manifest-utils.ts +8 -75
  47. 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-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>>();
@@ -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: "",
@@ -27,6 +27,8 @@ import { _getRequestContext } from "../server/request-context.js";
27
27
  import {
28
28
  isInsideLoaderScope,
29
29
  runInsideLoaderBodyScope,
30
+ isInsidePushCallbackScope,
31
+ runInsidePushCallbackScope,
30
32
  } from "../server/context.js";
31
33
  import { debugLog } from "./logging.js";
32
34
 
@@ -290,6 +292,12 @@ function createLoaderExecutor<TEnv>(
290
292
  );
291
293
  }
292
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.
293
301
  const snapshot =
294
302
  reqCtx._renderBarrierHandleSnapshot ??
295
303
  buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
@@ -311,15 +319,7 @@ function createLoaderExecutor<TEnv>(
311
319
  );
312
320
  }
313
321
 
314
- // Guard: reject streaming trees
315
322
  const reqCtx = reqCtxRef ?? _getRequestContext();
316
- if (reqCtx?._treeHasStreaming) {
317
- throw new Error(
318
- `ctx.rendered() is not supported when the matched route tree uses loading(). ` +
319
- `Streaming handlers may not have settled when rendered() resolves. ` +
320
- `Remove loading() from the route tree or restructure to avoid rendered().`,
321
- );
322
- }
323
323
 
324
324
  if (renderedPromise) return renderedPromise;
325
325
 
@@ -330,7 +330,10 @@ function createLoaderExecutor<TEnv>(
330
330
  }
331
331
 
332
332
  // Bidirectional deadlock check: if a handler already started
333
- // 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.
334
337
  if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
335
338
  throw new Error(
336
339
  `Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
@@ -348,7 +351,29 @@ function createLoaderExecutor<TEnv>(
348
351
  }
349
352
  reqCtx._renderBarrierWaiters.add(currentLoaderId);
350
353
 
351
- 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
+ }
352
377
  renderedResolved = true;
353
378
  });
354
379
  return renderedPromise;
@@ -404,12 +429,6 @@ export function setupLoaderAccess<TEnv>(
404
429
 
405
430
  const useLoader = createLoaderExecutor(ctx, loaderPromises);
406
431
 
407
- // Track whether we're inside a handle push callback. Loaders started
408
- // from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
409
- // block segment resolution, so they must not be registered as handler
410
- // dependencies for deadlock detection.
411
- let insideHandlePush = false;
412
-
413
432
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
414
433
  if (isHandle(item)) {
415
434
  const handle = item;
@@ -430,15 +449,17 @@ export function setupLoaderAccess<TEnv>(
430
449
  if (!store) return;
431
450
 
432
451
  if (typeof dataOrFn === "function") {
433
- // Mark scope so ctx.use(loader) calls inside the callback
434
- // are not registered as handler-to-loader deps.
435
- insideHandlePush = true;
436
- try {
437
- const result = (dataOrFn as () => Promise<unknown>)();
438
- store.push(handle.$$id, segmentId, result);
439
- } finally {
440
- insideHandlePush = false;
441
- }
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);
442
463
  return;
443
464
  }
444
465
 
@@ -450,9 +471,12 @@ export function setupLoaderAccess<TEnv>(
450
471
  // Skip when inside a DSL loader scope (resolveLoaderData also calls
451
472
  // ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
452
473
  // inside a handle push callback (push callbacks don't block segment
453
- // 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.
454
478
  const loader = item as LoaderDefinition<any, any>;
455
- if (!isInsideLoaderScope() && !insideHandlePush) {
479
+ if (!isInsideLoaderScope() && !isInsidePushCallbackScope()) {
456
480
  const reqCtx = reqCtxRef ?? _getRequestContext();
457
481
  if (reqCtx) {
458
482
  // Direction 1: handler awaits loader that already called rendered()
@@ -466,13 +490,18 @@ export function setupLoaderAccess<TEnv>(
466
490
  `Move the data dependency to a loader-to-loader pattern instead.`,
467
491
  );
468
492
  }
469
- // Direction 2: track dep so rendered() can detect the deadlock
470
- // if the loader calls it later. Skip when the barrier has already
471
- // resolved no deadlock is possible (rendered() resolves immediately).
472
- // _renderBarrierSegmentOrder is undefined before resolution, string[]
473
- // after. This also prevents false positives from handle push callbacks
474
- // that resume after their first await (post-barrier-resolution).
475
- 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) {
476
505
  if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
477
506
  reqCtx._handlerLoaderDeps.add(loader.$$id);
478
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) {
@@ -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