@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
package/src/href-client.ts
CHANGED
|
@@ -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.
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `
|
|
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
|
-
> =
|
|
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 `
|
|
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
package/src/index.ts
CHANGED
package/src/router/find-match.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { tryTrieMatch } from "./trie-matching.js";
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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() && !
|
|
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
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
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
|
}
|
package/src/router/manifest.ts
CHANGED
|
@@ -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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|