@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.
- package/dist/vite/index.js +148 -97
- package/package.json +1 -1
- package/skills/api-client/SKILL.md +211 -0
- package/skills/loader/SKILL.md +17 -17
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/rango/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +61 -43
- package/skills/typesafety/SKILL.md +3 -3
- package/src/__augment-tests__/augmented.check.ts +2 -3
- package/src/browser/navigation-client.ts +56 -68
- package/src/browser/prefetch/cache.ts +58 -27
- package/src/browser/prefetch/fetch.ts +92 -33
- package/src/browser/response-adapter.ts +7 -1
- package/src/browser/rsc-router.tsx +5 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +28 -1
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +43 -0
- package/src/client.tsx +4 -23
- package/src/errors.ts +0 -3
- package/src/href-client.ts +7 -8
- package/src/index.rsc.ts +1 -2
- package/src/index.ts +1 -2
- package/src/router/find-match.ts +54 -6
- package/src/router/lazy-includes.ts +33 -14
- package/src/router/loader-resolution.ts +63 -34
- package/src/router/manifest.ts +19 -6
- package/src/router/pattern-matching.ts +15 -2
- package/src/router/router-interfaces.ts +11 -0
- package/src/router/trie-matching.ts +22 -3
- package/src/router.ts +21 -7
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +16 -13
- package/src/server/context.ts +32 -0
- package/src/server/request-context.ts +47 -9
- package/src/types/loader-types.ts +6 -3
- package/src/urls/index.ts +1 -2
- package/src/urls/type-extraction.ts +33 -24
- package/src/vite/discovery/discover-routers.ts +46 -29
- package/src/vite/discovery/state.ts +7 -0
- package/src/vite/plugins/client-ref-hashing.ts +12 -1
- package/src/vite/rango.ts +32 -4
- package/src/vite/utils/client-chunks.ts +41 -7
- package/src/vite/utils/manifest-utils.ts +8 -75
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* reuse an in-flight prefetch via
|
|
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`;
|
|
90
|
-
*
|
|
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`.
|
|
97
|
-
* that adopted via `wildcardKey`
|
|
98
|
-
*
|
|
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<
|
|
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<
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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?: {
|
|
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()) {
|
package/src/build/index.ts
CHANGED
|
@@ -22,7 +22,14 @@ export {
|
|
|
22
22
|
type GeneratedManifest,
|
|
23
23
|
} from "./generate-manifest.js";
|
|
24
24
|
|
|
25
|
-
export {
|
|
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
|
+
}
|
package/src/build/route-trie.ts
CHANGED
|
@@ -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
|
-
//
|
|
419
|
-
|
|
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
|
}
|