@rangojs/router 0.0.0-experimental.130 → 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.
Files changed (54) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +56 -14
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/fresh.ts +8 -9
  36. package/src/router/segment-resolution/helpers.ts +11 -10
  37. package/src/router/segment-resolution/loader-cache.ts +13 -0
  38. package/src/router/segment-resolution/revalidation.ts +4 -4
  39. package/src/router/segment-wrappers.ts +3 -0
  40. package/src/router/trie-matching.ts +74 -20
  41. package/src/router.ts +2 -2
  42. package/src/rsc/progressive-enhancement.ts +20 -0
  43. package/src/rsc/server-action.ts +124 -47
  44. package/src/search-params.ts +8 -6
  45. package/src/segment-system.tsx +7 -1
  46. package/src/server/cookie-parse.ts +32 -0
  47. package/src/server/handle-store.ts +14 -14
  48. package/src/server/request-context.ts +5 -26
  49. package/src/ssr/index.tsx +5 -4
  50. package/src/testing/render-handler.ts +11 -0
  51. package/src/vite/plugins/expose-id-utils.ts +77 -2
  52. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  53. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  54. 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 { filterSegmentOrder } from "./react/filter-segment-order.js";
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.filter(
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
- const streamComplete = rawStreamComplete.then(() => {
195
- streamingToken.end();
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
- const newTree = await (signal
404
- ? Promise.race([
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
- new Promise<never>((_, reject) => {
407
- if (signal.aborted) {
408
- reject(new DOMException("Navigation aborted", "AbortError"));
409
- }
410
- signal.addEventListener("abort", () => {
411
- reject(new DOMException("Navigation aborted", "AbortError"));
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(async () => {
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
- // After 60s of no user interaction, marks connection as "cold".
255
- // On next interaction or visibility change, sends a HEAD request to warm TLS
256
- // before the user actually clicks a link.
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
- return ctx.eventController.subscribe(() => {
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
- return ctx.eventController.subscribe(() => {
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(code: string): Array<{
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
- const sourceFile = ts.createSourceFile(
25
- "input.tsx",
26
- code,
27
- ts.ScriptTarget.Latest,
28
- true,
29
- ts.ScriptKind.TSX,
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(code: string): {
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
- const sourceFile = ts.createSourceFile(
64
- "input.tsx",
65
- code,
66
- ts.ScriptTarget.Latest,
67
- true,
68
- ts.ScriptKind.TSX,
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
- const sourceFile = ts.createSourceFile(
211
- "input.tsx",
212
- code,
213
- ts.ScriptTarget.Latest,
214
- true,
215
- ts.ScriptKind.TSX,
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
- extractIncludesWithDiagnostics(block);
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 = readFileSync(realPath, "utf-8");
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(source, variableName);
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