@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
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
5
5
  * and useRouter().prefetch(). Sends the same headers and segment IDs as a
6
- * real navigation so the server returns a proper diff. The Response is fully
7
- * buffered and stored in an in-memory cache for instant consumption on
8
- * subsequent navigation.
6
+ * real navigation so the server returns a proper diff. The response is fetched
7
+ * AND eagerly decoded (createFromFetch) up front: decoding the Flight stream
8
+ * resolves the route's client references, so the route's JS chunks are imported
9
+ * during prefetch rather than on click. The decoded payload is stored in an
10
+ * in-memory cache and reused verbatim by navigation, so a prefetched click
11
+ * loads no new code.
9
12
  *
10
13
  * In-flight promises are tracked in the cache so that navigation can reuse
11
- * a prefetch that is still downloading instead of starting a duplicate request.
14
+ * a prefetch that is still downloading/decoding instead of starting a
15
+ * duplicate request.
12
16
  */
13
17
 
14
18
  import {
@@ -20,11 +24,34 @@ import {
20
24
  storePrefetch,
21
25
  clearPrefetchInflight,
22
26
  currentGeneration,
27
+ type DecodedPrefetch,
23
28
  } from "./cache.js";
24
29
  import { getRangoState } from "../rango-state.js";
25
30
  import { enqueuePrefetch } from "./queue.js";
26
31
  import { shouldPrefetch } from "./policy.js";
27
32
  import { debugLog } from "../logging.js";
33
+ import { teeWithCompletion } from "../response-adapter.js";
34
+ import type { RscPayload } from "../types.js";
35
+
36
+ /**
37
+ * Decoder injected at app startup (see setPrefetchDecoder). This is
38
+ * `deps.createFromFetch` — decoupled from the RSC runtime exactly like the
39
+ * navigation client. Prefetch decodes through it so the route's client chunks
40
+ * are pulled during the prefetch, not on click.
41
+ */
42
+ type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
43
+
44
+ let decoder: PrefetchDecoder | null = null;
45
+
46
+ /**
47
+ * Wire the RSC decoder used to eagerly decode prefetched responses. Called
48
+ * once from initBrowserApp with the same createFromFetch the navigation client
49
+ * uses. Until set, prefetch warming is inert (prefetches are skipped) — the
50
+ * browser app always sets it before any Link can fire.
51
+ */
52
+ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
53
+ decoder = fn;
54
+ }
28
55
 
29
56
  /**
30
57
  * Check if a URL resolves to the current page (same pathname + search).
@@ -78,24 +105,34 @@ function buildPrefetchUrl(
78
105
  }
79
106
 
80
107
  /**
81
- * Core prefetch fetch logic. Fetches the response, tees the body, and stores
82
- * one branch in the in-memory cache. The returned Promise resolves to the
83
- * sibling navigation branch (or null on failure) so navigation can safely
84
- * reuse an in-flight prefetch via consumeInflightPrefetch().
108
+ * Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
109
+ * stores the decoded payload in the in-memory cache. The returned Promise
110
+ * resolves to the decoded entry (or null on failure / control header) so
111
+ * navigation can safely reuse an in-flight prefetch via
112
+ * consumeInflightPrefetch().
113
+ *
114
+ * Eager decode is the warming step: createFromFetch parses the Flight stream,
115
+ * which resolves the route's client references and imports its JS chunks. The
116
+ * stored payload is reused as-is by navigation, so the click loads no new code.
117
+ *
118
+ * Control headers are NOT acted on here. A speculative prefetch must never
119
+ * reload the page or throw a redirect — if the response carries X-RSC-Reload
120
+ * or X-RSC-Redirect, we drop it (resolve null) and let the real navigation
121
+ * re-fetch and honor it.
85
122
  *
86
123
  * Inflight + storage key selection:
87
124
  *
88
125
  * - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
89
- * inflight registration under `sourceKey`; response stored under
90
- * `sourceKey`. No wildcard leak is possible.
126
+ * inflight registration under `sourceKey`; entry stored under `sourceKey`.
127
+ * No wildcard leak is possible.
91
128
  *
92
129
  * - Otherwise: dual inflight registration under both `wildcardKey` and
93
130
  * `sourceKey` so same-source navigations adopt directly via their own
94
131
  * source key. Storage key is chosen at response time from the
95
132
  * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
96
- * modals etc.), anything else → `wildcardKey`. Cross-source navigations
97
- * that adopted via `wildcardKey` must bail out in `navigation-client.ts`
98
- * if the adopted response turns out to be source-scoped.
133
+ * modals etc.), anything else → `wildcardKey`. The entry records its scope
134
+ * so cross-source navigations that adopted via `wildcardKey` can bail out
135
+ * in `navigation-client.ts` when the adopted entry turns out source-scoped.
99
136
  */
100
137
  function executePrefetchFetch(
101
138
  wildcardKey: string,
@@ -103,14 +140,14 @@ function executePrefetchFetch(
103
140
  fetchUrl: string,
104
141
  forceSourceScope: boolean,
105
142
  signal?: AbortSignal,
106
- ): Promise<Response | null> {
143
+ ): Promise<DecodedPrefetch | null> {
107
144
  const gen = currentGeneration();
108
145
  const inflightKeys = forceSourceScope
109
146
  ? [sourceKey]
110
147
  : [wildcardKey, sourceKey];
111
148
  for (const k of inflightKeys) markPrefetchInflight(k);
112
149
 
113
- const promise: Promise<Response | null> = fetch(fetchUrl, {
150
+ const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
114
151
  priority: "low" as RequestPriority,
115
152
  signal,
116
153
  headers: {
@@ -120,25 +157,47 @@ function executePrefetchFetch(
120
157
  },
121
158
  })
122
159
  .then((response) => {
123
- if (!response.ok) return null;
124
- // Don't buffer with arrayBuffer() that blocks until the entire
125
- // body downloads, defeating streaming for slow loaders.
126
- // Tee the body: one branch for navigation, one for cache storage.
127
- const [navStream, cacheStream] = response.body!.tee();
128
- const responseInit = {
129
- headers: response.headers,
130
- status: response.status,
131
- statusText: response.statusText,
132
- };
133
- let storageKey: string;
134
- if (forceSourceScope) {
135
- storageKey = sourceKey;
136
- } else {
137
- const scope = response.headers.get("x-rsc-prefetch-scope");
138
- storageKey = scope === "source" ? sourceKey : wildcardKey;
160
+ if (!response.ok || !decoder) return null;
161
+ // Control headers mean this response is stale (reload) or redirecting.
162
+ // Don't warm it drop so navigation re-fetches and acts on the header.
163
+ if (
164
+ response.headers.has("X-RSC-Reload") ||
165
+ response.headers.has("X-RSC-Redirect")
166
+ ) {
167
+ return null;
139
168
  }
140
- storePrefetch(storageKey, new Response(cacheStream, responseInit), gen);
141
- return new Response(navStream, responseInit);
169
+
170
+ const scope: "source" | "wildcard" =
171
+ forceSourceScope ||
172
+ response.headers.get("x-rsc-prefetch-scope") === "source"
173
+ ? "source"
174
+ : "wildcard";
175
+ const storageKey = scope === "source" ? sourceKey : wildcardKey;
176
+
177
+ // Track stream completion off a tee so navigation's scroll/revalidation
178
+ // gating matches the fresh-fetch path; decode the other branch.
179
+ let resolveStreamComplete!: () => void;
180
+ const streamComplete = new Promise<void>((resolve) => {
181
+ resolveStreamComplete = resolve;
182
+ });
183
+ const tracked = teeWithCompletion(
184
+ response,
185
+ () => resolveStreamComplete(),
186
+ signal,
187
+ // Speculative prefetch: a never-consumed/aborted stream error is benign.
188
+ true,
189
+ );
190
+
191
+ // Eager decode: parsing the Flight stream imports the route's client
192
+ // chunks now, not on click.
193
+ const payload = decoder(Promise.resolve(tracked));
194
+ // Mark handled so an unconsumed prefetch decode error stays quiet; the
195
+ // error is still surfaced to navigation if it consumes the entry.
196
+ payload.catch(() => {});
197
+
198
+ const entry: DecodedPrefetch = { payload, streamComplete, scope };
199
+ storePrefetch(storageKey, entry, gen);
200
+ return entry;
142
201
  })
143
202
  .catch(() => null)
144
203
  .finally(() => {
@@ -56,11 +56,17 @@ export function handleReloadHeader(
56
56
  *
57
57
  * If the response has no body, onComplete fires synchronously.
58
58
  * If signal is provided, an abort cancels the tracking reader.
59
+ *
60
+ * `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
61
+ * low-priority prefetch that is aborted or never consumed can error its stream
62
+ * benignly, which is not worth surfacing. The fresh-navigation path keeps the
63
+ * log (default), where a stream error reflects a real failed navigation.
59
64
  */
60
65
  export function teeWithCompletion(
61
66
  response: Response,
62
67
  onComplete: () => void,
63
68
  signal?: AbortSignal,
69
+ silent = false,
64
70
  ): Response {
65
71
  if (!response.body) {
66
72
  onComplete();
@@ -84,7 +90,7 @@ export function teeWithCompletion(
84
90
  onComplete();
85
91
  }
86
92
  })().catch((error) => {
87
- if (!signal?.aborted) {
93
+ if (!silent && !signal?.aborted) {
88
94
  console.error("[Browser] Error reading tracking stream:", error);
89
95
  }
90
96
  onComplete();
@@ -23,6 +23,7 @@ import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
25
  import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setPrefetchDecoder } from "./prefetch/fetch.js";
26
27
  import { setAppVersion } from "./app-version.js";
27
28
  import {
28
29
  isInterceptSegment,
@@ -238,6 +239,10 @@ export async function initBrowserApp(
238
239
  initPrefetchCache(prefetchCacheTTL);
239
240
  }
240
241
 
242
+ // Wire the RSC decoder so prefetches decode eagerly and warm the route's
243
+ // client chunks (same createFromFetch the navigation client uses).
244
+ setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
245
+
241
246
  // Create a bound renderSegments that reads rootLayout through the shell
242
247
  // ref. On app switch the ref is updated before the tree re-renders, so
243
248
  // the new app's Document (rootLayout) replaces the previous one.
@@ -0,0 +1,107 @@
1
+ // Collect the `"use client"` client-reference keys reachable from an error /
2
+ // notFound boundary registration, for routing them into the dedicated
3
+ // `app-fallback` chunk (see vite/utils/client-chunks.ts).
4
+ //
5
+ // A boundary registration is not always a bare client element. The common,
6
+ // load-bearing pattern wraps the client boundary in providers a thrown handler
7
+ // needs (the layout that would normally supply them did not mount):
8
+ //
9
+ // defaultErrorBoundary: ({ error }) => (
10
+ // <FallbackIntl locales={...}>
11
+ // <ThemedError error={error} /> // <- the real "use client" boundary
12
+ // </FallbackIntl>
13
+ // )
14
+ //
15
+ // So the value may be (a) a handler FUNCTION returning a tree, or (b) an element
16
+ // tree with the client boundary nested below server wrappers. We:
17
+ // 1. If it's a function, CALL it with synthetic props to get the returned tree.
18
+ // This only constructs JSX — the inner components are element `type`s, never
19
+ // invoked — so no hooks run. Guarded: a boundary that needs a real render
20
+ // context (request globals, etc.) throws and is skipped (graceful: it simply
21
+ // stays on the default grouping, as before).
22
+ // 2. Walk the resulting tree and report every element whose `.type` is a
23
+ // plugin-rsc client reference.
24
+ //
25
+ // Limit: a boundary that *conditionally* renders different client components based
26
+ // on the runtime error cannot be resolved statically — only the branch taken with
27
+ // the synthetic error is seen. Such cases fall back to the default chunk; the
28
+ // custom `clientChunks` function is the escape hatch.
29
+
30
+ const CLIENT_REF = Symbol.for("react.client.reference");
31
+ const MAX_DEPTH = 40;
32
+
33
+ // Synthetic props covering the error-boundary (`{ error, reset }`) and notFound
34
+ // (`{ pathname }`) handler shapes. The handler destructures what it needs.
35
+ const SYNTHETIC_PROPS = {
36
+ error: new Error("rango: build-time fallback-chunk discovery"),
37
+ reset: () => {},
38
+ pathname: "/",
39
+ info: { componentStack: "" },
40
+ };
41
+
42
+ interface MaybeElement {
43
+ type?: { $$typeof?: symbol; $$id?: string };
44
+ props?: Record<string, unknown>;
45
+ }
46
+
47
+ function isReactNodeLike(v: unknown): boolean {
48
+ return (
49
+ Array.isArray(v) ||
50
+ (typeof v === "object" && v !== null && "$$typeof" in (v as object))
51
+ );
52
+ }
53
+
54
+ function walkElementTree(
55
+ node: unknown,
56
+ report: (refKey: string) => void,
57
+ depth: number,
58
+ ): void {
59
+ if (node == null || depth > MAX_DEPTH) return;
60
+ if (Array.isArray(node)) {
61
+ for (const child of node) walkElementTree(child, report, depth + 1);
62
+ return;
63
+ }
64
+ if (typeof node !== "object") return;
65
+
66
+ const el = node as MaybeElement;
67
+ const type = el.type;
68
+ if (type?.$$typeof === CLIENT_REF && typeof type.$$id === "string") {
69
+ // $$id is `<referenceKey>#<exportName>` in build mode — keep the referenceKey.
70
+ report(type.$$id.split("#")[0]);
71
+ }
72
+
73
+ const props = el.props;
74
+ if (props && typeof props === "object") {
75
+ // Children are always nodes; other props are followed only when they look
76
+ // like React nodes (slots/icons), never arbitrary data objects.
77
+ walkElementTree(props.children, report, depth + 1);
78
+ for (const key in props) {
79
+ if (key === "children") continue;
80
+ const value = props[key];
81
+ if (isReactNodeLike(value)) walkElementTree(value, report, depth + 1);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Report every `"use client"` client-reference key reachable from a single
88
+ * error/notFound boundary registration (handler function or element tree).
89
+ */
90
+ export function collectFallbackClientRefs(
91
+ boundary: unknown,
92
+ report: (refKey: string) => void,
93
+ ): void {
94
+ try {
95
+ let node = boundary;
96
+ if (typeof node === "function") {
97
+ node = (node as (props: unknown) => unknown)(SYNTHETIC_PROPS);
98
+ }
99
+ walkElementTree(node, report, 0);
100
+ } catch {
101
+ // The boundary needs a real render context (request globals, hooks at the
102
+ // top level) or its tree has hostile getters. Its client refs can't be
103
+ // resolved statically — skip. It stays on the default grouping (no
104
+ // regression vs. not collecting), and the custom clientChunks fn is the
105
+ // escape hatch for such cases.
106
+ }
107
+ }
@@ -16,6 +16,7 @@ import type { EntryData, TrackedInclude } from "../server/context.js";
16
16
  import type { TrailingSlashMode } from "../types.js";
17
17
  import { createRouteHelpers } from "../route-definition.js";
18
18
  import MapRootLayout from "../server/root-layout.js";
19
+ import { collectFallbackClientRefs } from "./collect-fallback-refs.js";
19
20
 
20
21
  /**
21
22
  * Node in the prefix tree
@@ -290,7 +291,17 @@ export function generateManifest<TEnv>(
290
291
  export function generateManifestFull<TEnv>(
291
292
  urlpatterns: UrlPatterns<TEnv, any>,
292
293
  mountIndex: number = 0,
293
- options?: { urlPrefix?: string },
294
+ options?: {
295
+ urlPrefix?: string;
296
+ /**
297
+ * Called once per `"use client"` component registered as an
298
+ * errorBoundary/notFoundBoundary fallback, with its client-reference key
299
+ * (`$$id`). Lets the build collect fallback module ids for dedicated
300
+ * chunking without exposing the otherwise-discarded EntryData tree. The
301
+ * EntryData map built below is local; this is the only seam that surfaces it.
302
+ */
303
+ collectClientFallbackRef?: (refKey: string) => void;
304
+ },
294
305
  ): FullManifest {
295
306
  const routeManifest: Record<string, string> = {};
296
307
  const routeAncestry: Record<string, string[]> = {};
@@ -328,6 +339,22 @@ export function generateManifestFull<TEnv>(
328
339
  },
329
340
  );
330
341
 
342
+ // Surface the "use client" components registered as error/notFound fallbacks
343
+ // (route-tree errorBoundary()/notFoundBoundary() helpers, stored on EntryData).
344
+ // The boundary may be a handler function and/or wrap the client boundary in
345
+ // server providers, so walk the whole tree (see collectFallbackClientRefs).
346
+ if (options?.collectClientFallbackRef) {
347
+ const report = options.collectClientFallbackRef;
348
+ const collect = (boundary: unknown[] | undefined) => {
349
+ for (const item of boundary ?? [])
350
+ collectFallbackClientRefs(item, report);
351
+ };
352
+ for (const entry of manifest.values()) {
353
+ collect(entry.errorBoundary);
354
+ collect(entry.notFoundBoundary);
355
+ }
356
+ }
357
+
331
358
  // Collect root-level routes and trailing slash config
332
359
  const routeTrailingSlash: Record<string, string> = {};
333
360
  for (const [name, pattern] of patternsMap.entries()) {
@@ -22,7 +22,14 @@ export {
22
22
  type GeneratedManifest,
23
23
  } from "./generate-manifest.js";
24
24
 
25
- export { buildRouteTrie, type TrieNode, type TrieLeaf } from "./route-trie.js";
25
+ export {
26
+ buildRouteTrie,
27
+ buildPerRouterTrie,
28
+ type TrieNode,
29
+ type TrieLeaf,
30
+ } from "./route-trie.js";
31
+
32
+ export { collectFallbackClientRefs } from "./collect-fallback-refs.js";
26
33
 
27
34
  export {
28
35
  writePerModuleRouteTypes,
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure prefix-tree walks shared by the build/discovery layer and the runtime
3
+ * trie builder. Kept in `build/` (not `vite/utils`) so runtime code
4
+ * (rsc/manifest-init via build/route-trie) can consume them without importing
5
+ * from the vite layer. `vite/utils/manifest-utils` re-exports them so existing
6
+ * vite-side imports stay unchanged.
7
+ */
8
+
9
+ /**
10
+ * Flatten prefix tree leaf nodes into precomputed route entries.
11
+ * Leaf nodes have no children (no nested includes), so their routes can be
12
+ * used directly by evaluateLazyEntry() without running the handler.
13
+ * Non-leaf nodes are skipped because they have nested lazy includes that
14
+ * require the handler to run for discovery.
15
+ *
16
+ * A leaf is also skipped when its staticPrefix collides with an ancestor
17
+ * include node's staticPrefix. That happens when a dynamic param collapses the
18
+ * staticPrefix of nested includes onto the parent's (e.g. `/m/:id/edit` -> sp
19
+ * `/m`): precomputing such a leaf under the collapsed prefix would let the
20
+ * ancestor's lazy entry claim a route it cannot register (the route is behind
21
+ * further nested lazy includes), producing a RouteNotFoundError at request time
22
+ * (issue #506). Those routes are resolved via the handler chain instead.
23
+ */
24
+ export function flattenLeafEntries(
25
+ prefixTree: Record<string, any>,
26
+ routeManifest: Record<string, string>,
27
+ result: Array<{ staticPrefix: string; routes: Record<string, string> }>,
28
+ ): void {
29
+ function visit(node: any, ancestorStaticPrefixes: Set<string>): void {
30
+ const children = node.children || {};
31
+ if (
32
+ Object.keys(children).length === 0 &&
33
+ node.routes &&
34
+ node.routes.length > 0
35
+ ) {
36
+ // Leaf node. Skip if its staticPrefix collides with an ancestor include
37
+ // node's staticPrefix (dynamic-param collapse) — see doc comment above.
38
+ if (ancestorStaticPrefixes.has(node.staticPrefix)) {
39
+ return;
40
+ }
41
+ // Collect its routes from the manifest
42
+ const routes: Record<string, string> = {};
43
+ for (const name of node.routes) {
44
+ if (name in routeManifest) {
45
+ routes[name] = routeManifest[name];
46
+ }
47
+ }
48
+ result.push({ staticPrefix: node.staticPrefix, routes });
49
+ } else {
50
+ // Non-leaf: recurse into children, tracking this node's staticPrefix as
51
+ // an ancestor so a collapsed nested leaf below it is not over-claimed.
52
+ const nextAncestors = new Set(ancestorStaticPrefixes);
53
+ nextAncestors.add(node.staticPrefix);
54
+ for (const child of Object.values(children)) {
55
+ visit(child, nextAncestors);
56
+ }
57
+ }
58
+ }
59
+ for (const node of Object.values(prefixTree)) {
60
+ visit(node, new Set());
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Build the staticPrefix -> routes lookup the runtime shortcut consumes from a
66
+ * flat precomputed-entry array.
67
+ *
68
+ * A staticPrefix owned by MORE THAN ONE leaf include cannot be collapsed to a
69
+ * single routes object: `new Map(entries.map(e => [e.staticPrefix, e.routes]))`
70
+ * is last-wins, so one include's routes are silently dropped and mis-assigned
71
+ * to whichever entry evaluates first. Two distinct includes legitimately share a
72
+ * staticPrefix when a dynamic param collapses their literal prefixes onto the
73
+ * same value (e.g. `include("/shop/:cat", ...)` and a nested
74
+ * `include("/shop/:brand", ...)` both extract "/shop/"). Merging them is also
75
+ * wrong — assigning the merged set to the first matching entry makes findMatch
76
+ * pick the wrong handler for routes belonging to the other include, which then
77
+ * fails its `Store.manifest.has(routeKey)` invariant at render (500 on a valid
78
+ * route, dev/prod identical).
79
+ *
80
+ * So any shared staticPrefix is OMITTED from the shortcut entirely. Those
81
+ * includes fall through to the handler path in evaluateLazyEntry(), which is the
82
+ * ground truth (identical to pre-precomputed behavior). The shortcut is purely an
83
+ * optimization, so dropping a prefix can only cost a handler run, never change a
84
+ * result.
85
+ */
86
+ export function buildPrecomputedByPrefix(
87
+ entries: Array<{ staticPrefix: string; routes: Record<string, string> }>,
88
+ ): Map<string, Record<string, string>> {
89
+ const byPrefix = new Map<string, Record<string, string>>();
90
+ const shared = new Set<string>();
91
+ for (const e of entries) {
92
+ if (byPrefix.has(e.staticPrefix)) {
93
+ shared.add(e.staticPrefix);
94
+ } else {
95
+ byPrefix.set(e.staticPrefix, e.routes);
96
+ }
97
+ }
98
+ for (const sp of shared) {
99
+ byPrefix.delete(sp);
100
+ }
101
+ return byPrefix;
102
+ }
103
+
104
+ /**
105
+ * Walk prefix tree to map each route name to its scope's staticPrefix.
106
+ */
107
+ export function buildRouteToStaticPrefix(
108
+ prefixTree: Record<string, any>,
109
+ result: Record<string, string>,
110
+ ): void {
111
+ function visit(node: any): void {
112
+ const sp = node.staticPrefix || "";
113
+ for (const name of node.routes || []) {
114
+ result[name] = sp;
115
+ }
116
+ for (const child of Object.values(node.children || {})) {
117
+ visit(child);
118
+ }
119
+ }
120
+ for (const node of Object.values(prefixTree)) {
121
+ visit(node);
122
+ }
123
+ }
@@ -10,6 +10,8 @@ import {
10
10
  parsePattern,
11
11
  type ParsedSegment,
12
12
  } from "../router/pattern-matching.js";
13
+ import { buildRouteToStaticPrefix } from "./prefix-tree-utils.js";
14
+ import type { FullManifest } from "./generate-manifest.js";
13
15
 
14
16
  // -- Trie data structures (compact keys for JSON serialization) --
15
17
 
@@ -98,6 +100,47 @@ export function buildRouteTrie(
98
100
  return root;
99
101
  }
100
102
 
103
+ /**
104
+ * Build a per-router trie from a generated manifest. This is the single
105
+ * construction path shared by build/discovery (discover-routers.ts, serialized
106
+ * into the production chunk) and the dev/HMR runtime rebuild
107
+ * (rsc/manifest-init.ts). Keeping one code path is what guarantees the dev
108
+ * runtime trie and the production serialized trie are byte-for-byte identical
109
+ * (modulo `leaf.a` ancestry, which embeds the mount index and is debug-only).
110
+ *
111
+ * Returns null when the manifest has no route ancestry (no routes), matching
112
+ * the prior guard at both call sites.
113
+ */
114
+ export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
115
+ const ancestry = manifest._routeAncestry;
116
+ if (!ancestry || Object.keys(ancestry).length === 0) {
117
+ return null;
118
+ }
119
+
120
+ // Seed every route to the root static prefix (""), then override with each
121
+ // route's include() scope prefix from the prefix tree so the trie returns the
122
+ // correct `sp` for lazy-entry lookup in find-match.
123
+ const routeToStaticPrefix: Record<string, string> = {};
124
+ for (const name of Object.keys(manifest.routeManifest)) {
125
+ routeToStaticPrefix[name] = "";
126
+ }
127
+ if (manifest.prefixTree) {
128
+ buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
129
+ }
130
+
131
+ return buildRouteTrie(
132
+ manifest.routeManifest,
133
+ ancestry,
134
+ routeToStaticPrefix,
135
+ manifest.routeTrailingSlash,
136
+ manifest.prerenderRoutes ? new Set(manifest.prerenderRoutes) : undefined,
137
+ manifest.passthroughRoutes
138
+ ? new Set(manifest.passthroughRoutes)
139
+ : undefined,
140
+ manifest.responseTypeRoutes,
141
+ );
142
+ }
143
+
101
144
  /**
102
145
  * Insert a route into the trie. Optional params expand into two branches at
103
146
  * registration time (skip-first, then present), so each terminal lives at the
package/src/client.tsx CHANGED
@@ -415,29 +415,10 @@ export {
415
415
  // href-client.ts) — no import needed.
416
416
  export { href, type PatternToPath } from "./href-client.js";
417
417
 
418
- // Response envelope types for consuming JSON response routes
419
- export type { ResponseEnvelope, ResponseError } from "./urls.js";
420
-
421
- /**
422
- * Type guard for checking if a response envelope contains an error.
423
- *
424
- * @example
425
- * ```typescript
426
- * const result: ResponseEnvelope<Product> = await fetch(url).then(r => r.json());
427
- * if (isResponseError(result)) {
428
- * console.log(result.error.message, result.error.code);
429
- * return;
430
- * }
431
- * result.data // fully typed as Product
432
- * ```
433
- */
434
- export function isResponseError<T>(
435
- result: import("./urls.js").ResponseEnvelope<T>,
436
- ): result is import("./urls.js").ResponseEnvelope<T> & {
437
- error: import("./urls.js").ResponseError;
438
- } {
439
- return result.error !== undefined;
440
- }
418
+ // Problem Details (RFC 9457) error body type for consuming JSON response routes.
419
+ // On a non-2xx response, `await res.json()` yields this shape; on success the
420
+ // body is the bare value (no envelope). Discriminate on `res.ok` / status.
421
+ export type { ProblemDetails } from "./urls.js";
441
422
 
442
423
  // Mount context for include() scoped components
443
424
  export { useMount } from "./browser/react/use-mount.js";
package/src/errors.ts CHANGED
@@ -225,7 +225,6 @@ export function isNetworkError(error: unknown): boolean {
225
225
  export class RouterError extends Error {
226
226
  name = "RouterError" as const;
227
227
  code: string;
228
- type?: string;
229
228
  status: number;
230
229
  cause?: unknown;
231
230
 
@@ -234,7 +233,6 @@ export class RouterError extends Error {
234
233
  message: string,
235
234
  options?: {
236
235
  status?: number;
237
- type?: string;
238
236
  cause?: unknown;
239
237
  },
240
238
  ) {
@@ -242,7 +240,6 @@ export class RouterError extends Error {
242
240
  Object.setPrototypeOf(this, RouterError.prototype);
243
241
  this.code = code;
244
242
  this.status = options?.status ?? 500;
245
- this.type = options?.type;
246
243
  this.cause = options?.cause;
247
244
  }
248
245
  }