@rangojs/router 0.0.0-experimental.131 → 0.0.0-experimental.132
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/bin/rango.js +69 -24
- package/dist/vite/index.js +182 -41
- package/package.json +6 -3
- package/src/browser/connection-warmup.ts +134 -0
- package/src/browser/event-controller.ts +5 -4
- package/src/browser/partial-update.ts +32 -16
- package/src/browser/react/NavigationProvider.tsx +6 -83
- package/src/browser/react/filter-segment-order.ts +17 -0
- package/src/browser/react/use-link-status.ts +10 -2
- package/src/browser/react/use-navigation.ts +10 -2
- package/src/build/route-types/ast-route-extraction.ts +15 -8
- package/src/build/route-types/include-resolution.ts +109 -21
- package/src/build/route-types/per-module-writer.ts +15 -2
- package/src/cache/cache-key-utils.ts +29 -13
- package/src/cache/cf/cf-cache-store.ts +129 -5
- package/src/decode-loader-results.ts +11 -1
- package/src/encode-kv.ts +49 -0
- package/src/handles/meta.ts +5 -1
- package/src/host/cookie-handler.ts +2 -21
- package/src/prerender/param-hash.ts +6 -5
- package/src/regex-escape.ts +8 -0
- package/src/route-definition/dsl-helpers.ts +6 -2
- package/src/router/error-handling.ts +32 -1
- package/src/router/handler-context.ts +6 -1
- package/src/router/instrument.ts +14 -10
- package/src/router/intercept-resolution.ts +16 -1
- package/src/router/loader-resolution.ts +49 -19
- package/src/router/match-middleware/background-revalidation.ts +6 -0
- package/src/router/match-middleware/cache-store.ts +6 -0
- package/src/router/middleware.ts +67 -27
- package/src/router/pattern-matching.ts +3 -9
- package/src/router/revalidation.ts +65 -23
- package/src/router/router-context.ts +1 -0
- package/src/router/router-options.ts +3 -3
- package/src/router/segment-resolution/loader-cache.ts +13 -0
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/trie-matching.ts +74 -20
- package/src/router.ts +2 -2
- package/src/rsc/progressive-enhancement.ts +20 -0
- package/src/rsc/server-action.ts +124 -47
- package/src/search-params.ts +8 -6
- package/src/segment-system.tsx +7 -1
- package/src/server/cookie-parse.ts +32 -0
- package/src/server/handle-store.ts +14 -14
- package/src/server/request-context.ts +5 -26
- package/src/ssr/index.tsx +5 -4
- package/src/testing/render-handler.ts +11 -0
- package/src/vite/plugins/expose-id-utils.ts +77 -2
- package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
- package/src/vite/utils/prerender-utils.ts +1 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection warmup: keep TLS alive after idle periods.
|
|
3
|
+
*
|
|
4
|
+
* After IDLE_TIMEOUT of no user interaction the connection is marked "cold".
|
|
5
|
+
* On the next pointer/touch interaction or a visibility change, a HEAD request
|
|
6
|
+
* warms the TLS connection before the user actually clicks a link.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from NavigationProvider so the idle/cold/warmup state machine is
|
|
9
|
+
* unit-testable with an injected environment (document, fetch, timers).
|
|
10
|
+
*
|
|
11
|
+
* Ordering bug this guards against: the warmup listeners (mousemove/touchstart)
|
|
12
|
+
* share events with the idle-reset listeners, and the idle-reset listener is
|
|
13
|
+
* registered first, so it clears the live "cold" flag before the warmup
|
|
14
|
+
* listener reads it on the same event. A separate coldLatch — set when the
|
|
15
|
+
* connection goes cold and cleared only when warmup fires or listeners detach,
|
|
16
|
+
* never by the idle reset — lets the warmup decision see the pre-reset cold
|
|
17
|
+
* state regardless of listener ordering.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const IDLE_TIMEOUT = 60_000;
|
|
21
|
+
const DEBOUNCE_DELAY = 150;
|
|
22
|
+
|
|
23
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
24
|
+
|
|
25
|
+
export interface WarmupEnv {
|
|
26
|
+
doc: Pick<
|
|
27
|
+
Document,
|
|
28
|
+
"addEventListener" | "removeEventListener" | "visibilityState"
|
|
29
|
+
>;
|
|
30
|
+
fetch: typeof fetch;
|
|
31
|
+
setTimeout: (fn: () => void, ms: number) => TimerHandle;
|
|
32
|
+
clearTimeout: (handle: TimerHandle | undefined) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultEnv(): WarmupEnv {
|
|
36
|
+
return {
|
|
37
|
+
doc: document,
|
|
38
|
+
fetch: (...args) => fetch(...args),
|
|
39
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
40
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start the connection-warmup state machine. Returns a cleanup function that
|
|
46
|
+
* cancels timers and removes all listeners.
|
|
47
|
+
*/
|
|
48
|
+
export function startConnectionWarmup(
|
|
49
|
+
env: WarmupEnv = defaultEnv(),
|
|
50
|
+
): () => void {
|
|
51
|
+
const { doc, setTimeout: setT, clearTimeout: clearT } = env;
|
|
52
|
+
|
|
53
|
+
let idleTimer: TimerHandle | undefined;
|
|
54
|
+
let debounceTimer: TimerHandle | undefined;
|
|
55
|
+
// Cold latch for the warmup decision — see module header. Set in markCold;
|
|
56
|
+
// cleared only when warmup fires or listeners detach, NOT by resetIdleTimer,
|
|
57
|
+
// so triggerWarmup sees the pre-reset cold state regardless of listener
|
|
58
|
+
// ordering (the idle-reset listener runs first on a shared mousemove event).
|
|
59
|
+
let coldLatch = false;
|
|
60
|
+
let warmupListenersAttached = false;
|
|
61
|
+
|
|
62
|
+
function sendWarmup() {
|
|
63
|
+
coldLatch = false;
|
|
64
|
+
env.fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function triggerWarmup() {
|
|
68
|
+
if (!coldLatch) return;
|
|
69
|
+
clearT(debounceTimer);
|
|
70
|
+
debounceTimer = setT(() => {
|
|
71
|
+
sendWarmup();
|
|
72
|
+
detachWarmupListeners();
|
|
73
|
+
resetIdleTimer();
|
|
74
|
+
}, DEBOUNCE_DELAY);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onVisibilityChange() {
|
|
78
|
+
if (doc.visibilityState === "visible" && coldLatch) {
|
|
79
|
+
triggerWarmup();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function attachWarmupListeners() {
|
|
84
|
+
if (warmupListenersAttached) return;
|
|
85
|
+
warmupListenersAttached = true;
|
|
86
|
+
doc.addEventListener("visibilitychange", onVisibilityChange);
|
|
87
|
+
doc.addEventListener("mousemove", triggerWarmup, { once: true });
|
|
88
|
+
doc.addEventListener("touchstart", triggerWarmup, { once: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function detachWarmupListeners() {
|
|
92
|
+
warmupListenersAttached = false;
|
|
93
|
+
coldLatch = false;
|
|
94
|
+
doc.removeEventListener("visibilitychange", onVisibilityChange);
|
|
95
|
+
doc.removeEventListener("mousemove", triggerWarmup);
|
|
96
|
+
doc.removeEventListener("touchstart", triggerWarmup);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function markCold() {
|
|
100
|
+
coldLatch = true;
|
|
101
|
+
attachWarmupListeners();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resetIdleTimer() {
|
|
105
|
+
clearT(idleTimer);
|
|
106
|
+
idleTimer = setT(markCold, IDLE_TIMEOUT);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Activity events that reset the idle timer. mousemove/touchstart overlap
|
|
110
|
+
// with the warmup listeners; this listener is registered first, which is the
|
|
111
|
+
// ordering the coldLatch defends against.
|
|
112
|
+
const activityEvents = [
|
|
113
|
+
"mousemove",
|
|
114
|
+
"keydown",
|
|
115
|
+
"touchstart",
|
|
116
|
+
"scroll",
|
|
117
|
+
] as const;
|
|
118
|
+
const activityOptions: AddEventListenerOptions = { passive: true };
|
|
119
|
+
|
|
120
|
+
for (const event of activityEvents) {
|
|
121
|
+
doc.addEventListener(event, resetIdleTimer, activityOptions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resetIdleTimer();
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
clearT(idleTimer);
|
|
128
|
+
clearT(debounceTimer);
|
|
129
|
+
detachWarmupListeners();
|
|
130
|
+
for (const event of activityEvents) {
|
|
131
|
+
doc.removeEventListener(event, resetIdleTimer);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -10,7 +10,10 @@ import type {
|
|
|
10
10
|
HandleData,
|
|
11
11
|
StreamingToken,
|
|
12
12
|
} from "./types.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
filterSegmentOrder,
|
|
15
|
+
filterRouteSegmentIds,
|
|
16
|
+
} from "./react/filter-segment-order.js";
|
|
14
17
|
|
|
15
18
|
// Polyfill Symbol.dispose for Safari and older browsers
|
|
16
19
|
if (typeof Symbol.dispose === "undefined") {
|
|
@@ -703,9 +706,7 @@ export function createEventController(
|
|
|
703
706
|
const newSegmentOrder = filterSegmentOrder(rawMatched);
|
|
704
707
|
// Separate list for useSegments(): "layouts and routes only" — strip
|
|
705
708
|
// parallels (".@") and loader sub-ids (D digit) without reordering.
|
|
706
|
-
const newRouteSegmentIds = rawMatched
|
|
707
|
-
(id) => !id.includes(".@") && !/D\d+\./.test(id),
|
|
708
|
-
);
|
|
709
|
+
const newRouteSegmentIds = filterRouteSegmentIds(rawMatched);
|
|
709
710
|
|
|
710
711
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
711
712
|
// Partial update: merge new data with existing
|
|
@@ -191,9 +191,10 @@ export function createPartialUpdater(
|
|
|
191
191
|
const { payload, streamComplete: rawStreamComplete } = fetchResult;
|
|
192
192
|
debugLog("payload.metadata", payload.metadata);
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
// Side effect only: end the streaming token once the stream settles.
|
|
195
|
+
// The wrapped promise was never read as a value; only the .end() matters.
|
|
196
|
+
// The .catch keeps an unhandled rejection from leaking if the stream errors.
|
|
197
|
+
rawStreamComplete.then(() => streamingToken.end()).catch(() => {});
|
|
197
198
|
|
|
198
199
|
const currentRouterId = store.getRouterId?.();
|
|
199
200
|
if (
|
|
@@ -400,19 +401,34 @@ export function createPartialUpdater(
|
|
|
400
401
|
? reconciled.interceptSegments
|
|
401
402
|
: undefined,
|
|
402
403
|
};
|
|
403
|
-
|
|
404
|
-
|
|
404
|
+
let newTree: Awaited<ReturnType<typeof renderSegments>>;
|
|
405
|
+
if (signal) {
|
|
406
|
+
// Race render against abort. Store the abort handler and register it
|
|
407
|
+
// { once:true } so a non-aborted render (which wins the race) can
|
|
408
|
+
// remove it in finally — otherwise the listener stays attached and the
|
|
409
|
+
// rejecting promise never settles. Mirrors teeWithCompletion in
|
|
410
|
+
// browser/response-adapter.ts.
|
|
411
|
+
let onAbort: (() => void) | undefined;
|
|
412
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
413
|
+
if (signal.aborted) {
|
|
414
|
+
reject(new DOMException("Navigation aborted", "AbortError"));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
onAbort = () =>
|
|
418
|
+
reject(new DOMException("Navigation aborted", "AbortError"));
|
|
419
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
newTree = await Promise.race([
|
|
405
423
|
renderSegments(reconciled.mainSegments, renderOptions),
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
])
|
|
415
|
-
: renderSegments(reconciled.mainSegments, renderOptions));
|
|
424
|
+
abortPromise,
|
|
425
|
+
]);
|
|
426
|
+
} finally {
|
|
427
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
newTree = await renderSegments(reconciled.mainSegments, renderOptions);
|
|
431
|
+
}
|
|
416
432
|
|
|
417
433
|
if (signal?.aborted) {
|
|
418
434
|
debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
|
|
@@ -540,7 +556,7 @@ export function createPartialUpdater(
|
|
|
540
556
|
});
|
|
541
557
|
});
|
|
542
558
|
} else if (mode.type === "action") {
|
|
543
|
-
startTransition(
|
|
559
|
+
startTransition(() => {
|
|
544
560
|
if (fullHasTransition && addTransitionType) {
|
|
545
561
|
addTransitionType("action");
|
|
546
562
|
}
|
|
@@ -29,6 +29,7 @@ import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
|
29
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
30
|
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
31
31
|
import { createAppShellRef, type AppShellRef } from "../app-shell.js";
|
|
32
|
+
import { startConnectionWarmup } from "../connection-warmup.js";
|
|
32
33
|
import { debugLog } from "../logging.js";
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -250,91 +251,13 @@ export function NavigationProvider({
|
|
|
250
251
|
return value;
|
|
251
252
|
}, []);
|
|
252
253
|
|
|
253
|
-
// Connection warmup: keep TLS alive after idle periods.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
254
|
+
// Connection warmup: keep TLS alive after idle periods. After 60s of no
|
|
255
|
+
// interaction the connection is marked cold; the next pointer/touch
|
|
256
|
+
// interaction or visibility change warms TLS via a HEAD request before the
|
|
257
|
+
// user clicks a link. State machine lives in connection-warmup.ts.
|
|
257
258
|
useEffect(() => {
|
|
258
259
|
if (!warmupEnabled) return;
|
|
259
|
-
|
|
260
|
-
const IDLE_TIMEOUT = 60_000;
|
|
261
|
-
const DEBOUNCE_DELAY = 150;
|
|
262
|
-
|
|
263
|
-
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
265
|
-
let isCold = false;
|
|
266
|
-
let warmupListenersAttached = false;
|
|
267
|
-
|
|
268
|
-
function sendWarmup() {
|
|
269
|
-
isCold = false;
|
|
270
|
-
fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function triggerWarmup() {
|
|
274
|
-
if (!isCold) return;
|
|
275
|
-
clearTimeout(debounceTimer);
|
|
276
|
-
debounceTimer = setTimeout(() => {
|
|
277
|
-
sendWarmup();
|
|
278
|
-
detachWarmupListeners();
|
|
279
|
-
resetIdleTimer();
|
|
280
|
-
}, DEBOUNCE_DELAY);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function onVisibilityChange() {
|
|
284
|
-
if (document.visibilityState === "visible" && isCold) {
|
|
285
|
-
triggerWarmup();
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function attachWarmupListeners() {
|
|
290
|
-
if (warmupListenersAttached) return;
|
|
291
|
-
warmupListenersAttached = true;
|
|
292
|
-
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
293
|
-
document.addEventListener("mousemove", triggerWarmup, { once: true });
|
|
294
|
-
document.addEventListener("touchstart", triggerWarmup, { once: true });
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function detachWarmupListeners() {
|
|
298
|
-
warmupListenersAttached = false;
|
|
299
|
-
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
300
|
-
document.removeEventListener("mousemove", triggerWarmup);
|
|
301
|
-
document.removeEventListener("touchstart", triggerWarmup);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function markCold() {
|
|
305
|
-
isCold = true;
|
|
306
|
-
attachWarmupListeners();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function resetIdleTimer() {
|
|
310
|
-
clearTimeout(idleTimer);
|
|
311
|
-
isCold = false;
|
|
312
|
-
idleTimer = setTimeout(markCold, IDLE_TIMEOUT);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Activity events that reset the idle timer
|
|
316
|
-
const activityEvents = [
|
|
317
|
-
"mousemove",
|
|
318
|
-
"keydown",
|
|
319
|
-
"touchstart",
|
|
320
|
-
"scroll",
|
|
321
|
-
] as const;
|
|
322
|
-
const activityOptions: AddEventListenerOptions = { passive: true };
|
|
323
|
-
|
|
324
|
-
for (const event of activityEvents) {
|
|
325
|
-
document.addEventListener(event, resetIdleTimer, activityOptions);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
resetIdleTimer();
|
|
329
|
-
|
|
330
|
-
return () => {
|
|
331
|
-
clearTimeout(idleTimer);
|
|
332
|
-
clearTimeout(debounceTimer);
|
|
333
|
-
detachWarmupListeners();
|
|
334
|
-
for (const event of activityEvents) {
|
|
335
|
-
document.removeEventListener(event, resetIdleTimer);
|
|
336
|
-
}
|
|
337
|
-
};
|
|
260
|
+
return startConnectionWarmup();
|
|
338
261
|
}, [warmupEnabled]);
|
|
339
262
|
|
|
340
263
|
// Cancel non-matching prefetches when navigation starts.
|
|
@@ -51,3 +51,20 @@ export function filterSegmentOrder(matched: string[]): string[] {
|
|
|
51
51
|
}
|
|
52
52
|
return result;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the "layouts and routes only" id list for useSegments().segmentIds.
|
|
57
|
+
*
|
|
58
|
+
* Strips parallel slot ids (contain ".@") and loader sub-ids ("D" followed by
|
|
59
|
+
* a digit, e.g. "M0L0D1.user") without reordering. Distinct from
|
|
60
|
+
* filterSegmentOrder, which also reorders slots after their parent for handle
|
|
61
|
+
* collection.
|
|
62
|
+
*
|
|
63
|
+
* Shared by SSR (ssr/index.tsx) and the client event controller
|
|
64
|
+
* (event-controller.ts) so both produce identical output; if they diverge,
|
|
65
|
+
* useSegments().segmentIds rendered during SSR and after hydration disagree
|
|
66
|
+
* and React reports a hydration mismatch.
|
|
67
|
+
*/
|
|
68
|
+
export function filterRouteSegmentIds(matched: string[]): string[] {
|
|
69
|
+
return matched.filter((id) => !id.includes(".@") && !/D\d+\./.test(id));
|
|
70
|
+
}
|
|
@@ -102,7 +102,7 @@ export function useLinkStatus(): LinkStatus {
|
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
const update = () => {
|
|
106
106
|
const state = ctx.eventController.getState();
|
|
107
107
|
const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
|
|
108
108
|
|
|
@@ -119,7 +119,15 @@ export function useLinkStatus(): LinkStatus {
|
|
|
119
119
|
// Always update base state
|
|
120
120
|
setBasePending(isPending);
|
|
121
121
|
}
|
|
122
|
-
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Catch-up: re-read state synchronously on mount before subscribing, so a
|
|
125
|
+
// navigation that started between the seeding render and this effect commit
|
|
126
|
+
// isn't dropped until the next (debounced) notify. Mirrors usePathname /
|
|
127
|
+
// useSearchParams.
|
|
128
|
+
update();
|
|
129
|
+
|
|
130
|
+
return ctx.eventController.subscribe(update);
|
|
123
131
|
}, [linkTo, origin]);
|
|
124
132
|
|
|
125
133
|
// If not inside a Link, return not pending
|
|
@@ -70,7 +70,7 @@ export function useNavigation<T>(
|
|
|
70
70
|
|
|
71
71
|
// Subscribe to event controller state changes (only runs on client)
|
|
72
72
|
useEffect(() => {
|
|
73
|
-
|
|
73
|
+
const update = () => {
|
|
74
74
|
const currentState = ctx.eventController.getState();
|
|
75
75
|
const publicState = toPublicState(currentState);
|
|
76
76
|
const nextSelected = selectorRef.current
|
|
@@ -109,7 +109,15 @@ export function useNavigation<T>(
|
|
|
109
109
|
// Always update base state so UI reflects current state
|
|
110
110
|
setBaseValue(nextSelected);
|
|
111
111
|
}
|
|
112
|
-
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Catch-up: re-read state synchronously on mount before subscribing, so a
|
|
115
|
+
// state change between the seeding render and this effect commit isn't
|
|
116
|
+
// dropped until the next (debounced) notify. Mirrors usePathname /
|
|
117
|
+
// useSearchParams.
|
|
118
|
+
update();
|
|
119
|
+
|
|
120
|
+
return ctx.eventController.subscribe(update);
|
|
113
121
|
}, []);
|
|
114
122
|
|
|
115
123
|
return value as T | PublicNavigationState;
|
|
@@ -15,19 +15,26 @@ import { extractParamsFromPattern } from "./param-extraction.js";
|
|
|
15
15
|
* the pattern, name, params, and optional search schema from each.
|
|
16
16
|
* Skips unnamed paths (no { name: "..." }).
|
|
17
17
|
*/
|
|
18
|
-
export function extractRoutesFromSource(
|
|
18
|
+
export function extractRoutesFromSource(
|
|
19
|
+
code: string,
|
|
20
|
+
sourceFileArg?: ts.SourceFile,
|
|
21
|
+
): Array<{
|
|
19
22
|
name: string;
|
|
20
23
|
pattern: string;
|
|
21
24
|
params?: Record<string, string>;
|
|
22
25
|
search?: Record<string, string>;
|
|
23
26
|
}> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Reuse a caller-provided SourceFile (parsed once per scan) when given;
|
|
28
|
+
// otherwise parse the block here. The walk does not mutate the tree.
|
|
29
|
+
const sourceFile =
|
|
30
|
+
sourceFileArg ??
|
|
31
|
+
ts.createSourceFile(
|
|
32
|
+
"input.tsx",
|
|
33
|
+
code,
|
|
34
|
+
ts.ScriptTarget.Latest,
|
|
35
|
+
true,
|
|
36
|
+
ts.ScriptKind.TSX,
|
|
37
|
+
);
|
|
31
38
|
const routes: Array<{
|
|
32
39
|
name: string;
|
|
33
40
|
pattern: string;
|
|
@@ -22,6 +22,61 @@ export interface UnresolvableInclude {
|
|
|
22
22
|
detail: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Per-scan memo
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per-scan memo shared across the include-resolution recursion. Keys are
|
|
31
|
+
* absolute file paths; values cache the readFileSync result and the
|
|
32
|
+
* ts.SourceFile parsed from the FULL file source. A separate blockSources map
|
|
33
|
+
* caches parses of extracted sub-blocks (urls() call text), keyed by the exact
|
|
34
|
+
* block string, so the two extractors (routes + includes) for the same block
|
|
35
|
+
* share a single parse.
|
|
36
|
+
*
|
|
37
|
+
* The memo is purely a performance accelerator: same input -> same parse, so
|
|
38
|
+
* generated output is identical to the un-memoized path. A fresh memo is
|
|
39
|
+
* created per top-level scan entry, so stale file edits between scans are never
|
|
40
|
+
* served.
|
|
41
|
+
*/
|
|
42
|
+
export interface ScanMemo {
|
|
43
|
+
files: Map<string, string>;
|
|
44
|
+
blockSourceFiles: Map<string, ts.SourceFile>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createScanMemo(): ScanMemo {
|
|
48
|
+
return { files: new Map(), blockSourceFiles: new Map() };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseBlock(memo: ScanMemo | undefined, block: string): ts.SourceFile {
|
|
52
|
+
if (memo) {
|
|
53
|
+
const cached = memo.blockSourceFiles.get(block);
|
|
54
|
+
if (cached) return cached;
|
|
55
|
+
}
|
|
56
|
+
const sf = ts.createSourceFile(
|
|
57
|
+
"input.tsx",
|
|
58
|
+
block,
|
|
59
|
+
ts.ScriptTarget.Latest,
|
|
60
|
+
true,
|
|
61
|
+
ts.ScriptKind.TSX,
|
|
62
|
+
);
|
|
63
|
+
if (memo) memo.blockSourceFiles.set(block, sf);
|
|
64
|
+
return sf;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSourceMemoized(
|
|
68
|
+
memo: ScanMemo | undefined,
|
|
69
|
+
realPath: string,
|
|
70
|
+
): string {
|
|
71
|
+
if (memo) {
|
|
72
|
+
const cached = memo.files.get(realPath);
|
|
73
|
+
if (cached !== undefined) return cached;
|
|
74
|
+
}
|
|
75
|
+
const source = readFileSync(realPath, "utf-8");
|
|
76
|
+
if (memo) memo.files.set(realPath, source);
|
|
77
|
+
return source;
|
|
78
|
+
}
|
|
79
|
+
|
|
25
80
|
// ---------------------------------------------------------------------------
|
|
26
81
|
// AST-based include() parsing
|
|
27
82
|
// ---------------------------------------------------------------------------
|
|
@@ -47,7 +102,10 @@ function extractNamePrefixFromInclude(node: ts.CallExpression): string | null {
|
|
|
47
102
|
* Returns both resolved includes (identifier second args) and unresolvable
|
|
48
103
|
* includes (factory calls, etc.) with reasons.
|
|
49
104
|
*/
|
|
50
|
-
export function extractIncludesWithDiagnostics(
|
|
105
|
+
export function extractIncludesWithDiagnostics(
|
|
106
|
+
code: string,
|
|
107
|
+
sourceFileArg?: ts.SourceFile,
|
|
108
|
+
): {
|
|
51
109
|
resolved: Array<{
|
|
52
110
|
pathPrefix: string;
|
|
53
111
|
variableName: string;
|
|
@@ -60,13 +118,16 @@ export function extractIncludesWithDiagnostics(code: string): {
|
|
|
60
118
|
detail: string;
|
|
61
119
|
}>;
|
|
62
120
|
} {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ts.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
121
|
+
// Reuse a caller-provided SourceFile (parsed once per scan) when given.
|
|
122
|
+
const sourceFile =
|
|
123
|
+
sourceFileArg ??
|
|
124
|
+
ts.createSourceFile(
|
|
125
|
+
"input.tsx",
|
|
126
|
+
code,
|
|
127
|
+
ts.ScriptTarget.Latest,
|
|
128
|
+
true,
|
|
129
|
+
ts.ScriptKind.TSX,
|
|
130
|
+
);
|
|
70
131
|
const resolved: Array<{
|
|
71
132
|
pathPrefix: string;
|
|
72
133
|
variableName: string;
|
|
@@ -206,14 +267,18 @@ export function resolveImportPath(
|
|
|
206
267
|
function extractUrlsBlockForVariable(
|
|
207
268
|
code: string,
|
|
208
269
|
varName: string,
|
|
270
|
+
sourceFileArg?: ts.SourceFile,
|
|
209
271
|
): string | null {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
ts.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
272
|
+
// Reuse a caller-provided full-source SourceFile (parsed once per scan).
|
|
273
|
+
const sourceFile =
|
|
274
|
+
sourceFileArg ??
|
|
275
|
+
ts.createSourceFile(
|
|
276
|
+
"input.tsx",
|
|
277
|
+
code,
|
|
278
|
+
ts.ScriptTarget.Latest,
|
|
279
|
+
true,
|
|
280
|
+
ts.ScriptKind.TSX,
|
|
281
|
+
);
|
|
217
282
|
let result: string | null = null;
|
|
218
283
|
|
|
219
284
|
function visit(node: ts.Node) {
|
|
@@ -249,11 +314,15 @@ function buildRouteMapFromBlock(
|
|
|
249
314
|
visited: Set<string>,
|
|
250
315
|
searchSchemasOut?: Record<string, Record<string, string>>,
|
|
251
316
|
diagnosticsOut?: UnresolvableInclude[],
|
|
317
|
+
memo?: ScanMemo,
|
|
252
318
|
): Record<string, string> {
|
|
253
319
|
const routeMap: Record<string, string> = {};
|
|
254
320
|
|
|
321
|
+
// Parse the block once and share the SourceFile between both extractors.
|
|
322
|
+
const blockSourceFile = parseBlock(memo, block);
|
|
323
|
+
|
|
255
324
|
// Extract local path() routes
|
|
256
|
-
const localRoutes = extractRoutesFromSource(block);
|
|
325
|
+
const localRoutes = extractRoutesFromSource(block, blockSourceFile);
|
|
257
326
|
for (const { name, pattern, search } of localRoutes) {
|
|
258
327
|
routeMap[name] = pattern;
|
|
259
328
|
if (search && searchSchemasOut) {
|
|
@@ -262,8 +331,10 @@ function buildRouteMapFromBlock(
|
|
|
262
331
|
}
|
|
263
332
|
|
|
264
333
|
// Extract include() calls with diagnostics for unresolvable ones
|
|
265
|
-
const { resolved: includes, unresolvable } =
|
|
266
|
-
|
|
334
|
+
const { resolved: includes, unresolvable } = extractIncludesWithDiagnostics(
|
|
335
|
+
block,
|
|
336
|
+
blockSourceFile,
|
|
337
|
+
);
|
|
267
338
|
|
|
268
339
|
if (diagnosticsOut) {
|
|
269
340
|
for (const entry of unresolvable) {
|
|
@@ -298,12 +369,17 @@ function buildRouteMapFromBlock(
|
|
|
298
369
|
imported.exportedName,
|
|
299
370
|
visited,
|
|
300
371
|
diagnosticsOut,
|
|
372
|
+
undefined,
|
|
373
|
+
memo,
|
|
301
374
|
);
|
|
302
375
|
} else {
|
|
303
|
-
// Check if variable exists as a same-file urls() definition
|
|
376
|
+
// Check if variable exists as a same-file urls() definition. Share the
|
|
377
|
+
// full-source SourceFile parse via the memo (block === fullSource hits the
|
|
378
|
+
// same cache entry already used above).
|
|
304
379
|
const sameFileBlock = extractUrlsBlockForVariable(
|
|
305
380
|
fullSource,
|
|
306
381
|
variableName,
|
|
382
|
+
parseBlock(memo, fullSource),
|
|
307
383
|
);
|
|
308
384
|
if (!sameFileBlock) {
|
|
309
385
|
if (diagnosticsOut) {
|
|
@@ -322,6 +398,8 @@ function buildRouteMapFromBlock(
|
|
|
322
398
|
variableName,
|
|
323
399
|
visited,
|
|
324
400
|
diagnosticsOut,
|
|
401
|
+
undefined,
|
|
402
|
+
memo,
|
|
325
403
|
);
|
|
326
404
|
}
|
|
327
405
|
|
|
@@ -361,6 +439,9 @@ function buildRouteMapFromBlock(
|
|
|
361
439
|
* @param inlineBlock - Optional pre-extracted code block (e.g. from an inline
|
|
362
440
|
* builder function). When provided, variableName is ignored and the block
|
|
363
441
|
* is parsed directly for path()/include() calls.
|
|
442
|
+
* @param memo - Per-scan readFileSync/parse memo. A fresh one is created at the
|
|
443
|
+
* top-level entry and threaded through the recursion so each file is read and
|
|
444
|
+
* parsed once per scan. Behavior-preserving (same input -> same output).
|
|
364
445
|
*/
|
|
365
446
|
export function buildCombinedRouteMapWithSearch(
|
|
366
447
|
filePath: string,
|
|
@@ -368,11 +449,13 @@ export function buildCombinedRouteMapWithSearch(
|
|
|
368
449
|
visited?: Set<string>,
|
|
369
450
|
diagnosticsOut?: UnresolvableInclude[],
|
|
370
451
|
inlineBlock?: string,
|
|
452
|
+
memo?: ScanMemo,
|
|
371
453
|
): {
|
|
372
454
|
routes: Record<string, string>;
|
|
373
455
|
searchSchemas: Record<string, Record<string, string>>;
|
|
374
456
|
} {
|
|
375
457
|
visited = visited ?? new Set();
|
|
458
|
+
memo = memo ?? createScanMemo();
|
|
376
459
|
const realPath = resolve(filePath);
|
|
377
460
|
const key = variableName ? `${realPath}:${variableName}` : realPath;
|
|
378
461
|
if (visited.has(key)) {
|
|
@@ -383,7 +466,7 @@ export function buildCombinedRouteMapWithSearch(
|
|
|
383
466
|
|
|
384
467
|
let source: string;
|
|
385
468
|
try {
|
|
386
|
-
source =
|
|
469
|
+
source = readSourceMemoized(memo, realPath);
|
|
387
470
|
} catch {
|
|
388
471
|
return { routes: {}, searchSchemas: {} };
|
|
389
472
|
}
|
|
@@ -392,7 +475,11 @@ export function buildCombinedRouteMapWithSearch(
|
|
|
392
475
|
if (inlineBlock) {
|
|
393
476
|
block = inlineBlock;
|
|
394
477
|
} else if (variableName) {
|
|
395
|
-
const extracted = extractUrlsBlockForVariable(
|
|
478
|
+
const extracted = extractUrlsBlockForVariable(
|
|
479
|
+
source,
|
|
480
|
+
variableName,
|
|
481
|
+
parseBlock(memo, source),
|
|
482
|
+
);
|
|
396
483
|
if (!extracted) return { routes: {}, searchSchemas: {} };
|
|
397
484
|
block = extracted;
|
|
398
485
|
} else {
|
|
@@ -407,6 +494,7 @@ export function buildCombinedRouteMapWithSearch(
|
|
|
407
494
|
visited,
|
|
408
495
|
searchSchemas,
|
|
409
496
|
diagnosticsOut,
|
|
497
|
+
memo,
|
|
410
498
|
);
|
|
411
499
|
|
|
412
500
|
// Remove from visited so sibling branches can include the same variable
|