@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114

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 (48) hide show
  1. package/dist/bin/rango.js +74 -3
  2. package/dist/vite/index.js +133 -18
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +35 -24
  5. package/skills/caching/SKILL.md +115 -7
  6. package/skills/document-cache/SKILL.md +78 -55
  7. package/skills/hooks/SKILL.md +40 -22
  8. package/skills/links/SKILL.md +10 -10
  9. package/skills/loader/SKILL.md +3 -3
  10. package/skills/rango/SKILL.md +16 -10
  11. package/skills/react-compiler/SKILL.md +168 -0
  12. package/skills/use-cache/SKILL.md +34 -5
  13. package/skills/view-transitions/SKILL.md +85 -3
  14. package/src/browser/react/location-state-shared.ts +93 -3
  15. package/src/browser/react/use-reverse.ts +19 -12
  16. package/src/build/route-types/per-module-writer.ts +4 -1
  17. package/src/build/route-types/router-processing.ts +14 -1
  18. package/src/build/route-types/source-scan.ts +118 -0
  19. package/src/cache/cache-scope.ts +28 -42
  20. package/src/cache/cf/cf-cache-store.ts +49 -6
  21. package/src/handle.ts +3 -5
  22. package/src/loader-store.ts +62 -25
  23. package/src/loader.rsc.ts +2 -5
  24. package/src/loader.ts +3 -10
  25. package/src/missing-id-error.ts +68 -0
  26. package/src/reverse.ts +16 -13
  27. package/src/route-definition/dsl-helpers.ts +5 -2
  28. package/src/route-definition/helpers-types.ts +31 -10
  29. package/src/router/loader-resolution.ts +16 -2
  30. package/src/router/match-middleware/cache-lookup.ts +44 -91
  31. package/src/router/match-middleware/cache-store.ts +3 -2
  32. package/src/router/router-options.ts +24 -0
  33. package/src/router/segment-resolution/fresh.ts +17 -4
  34. package/src/router/segment-resolution/revalidation.ts +17 -4
  35. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  36. package/src/router/types.ts +8 -0
  37. package/src/router.ts +2 -0
  38. package/src/segment-system.tsx +59 -10
  39. package/src/server/context.ts +26 -0
  40. package/src/server/cookie-store.ts +28 -4
  41. package/src/types/handler-context.ts +5 -2
  42. package/src/types/segments.ts +18 -1
  43. package/src/urls/path-helper-types.ts +9 -1
  44. package/src/use-loader.tsx +89 -42
  45. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  46. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  47. package/src/vite/plugins/use-cache-transform.ts +12 -10
  48. package/src/vite/router-discovery.ts +14 -2
@@ -187,17 +187,20 @@ export interface UseLoaderOptions {
187
187
  */
188
188
  key?: string;
189
189
  /**
190
- * Cross-loader refresh group. Tag reads of DIFFERENT loaders with the same
191
- * `refreshGroup` name, then call `useRefreshLoaders(name)()` to refresh the
192
- * whole group at once. Each member is refreshed with a plain GET against the
193
- * current route URL no params, no body, no mutation methods because a
194
- * group spans heterogeneous loaders with different param/return shapes.
190
+ * Cross-loader refresh group tag(s). Tag reads of DIFFERENT loaders with a
191
+ * shared name, then call `useRefreshLoaders()(name)` to refresh the whole group
192
+ * at once. Pass an array to tag one read into several groups — it is refreshed
193
+ * when ANY of its groups is refreshed, so a coarse tag can cover the whole set
194
+ * while a finer tag targets a subset. Each member is refreshed with a plain GET
195
+ * against the current route URL — no params, no body, no mutation methods —
196
+ * because a group spans heterogeneous loaders with different param/return
197
+ * shapes.
195
198
  *
196
199
  * For parameterized sharing of a SINGLE loader, use `key` instead; group
197
200
  * members should be registered or non-parameterized-keyed reads (a plain-GET
198
201
  * group refresh would drop any per-call params).
199
202
  */
200
- refreshGroup?: string;
203
+ refreshGroup?: string | string[];
201
204
  }
202
205
 
203
206
  /**
@@ -254,29 +257,42 @@ function useLoaderInternal<T>(
254
257
  // only hooks with the same `key` refresh together. Default (no key) keeps the
255
258
  // historical behavior: one bucket per loader id.
256
259
  const key = options?.key;
257
- const refreshGroup = options?.refreshGroup;
260
+ // Normalize the refresh-group tag(s) to a stable, deduped, sorted list. The
261
+ // joined `groupKey` string is the subscribe effect's dependency, so passing an
262
+ // inline array literal (`refreshGroup={["a", "b"]}`) does not force a
263
+ // resubscribe on every render. An empty list means "no groups" — identical to
264
+ // omitting the option (`hasGroups` stays false, no private bucket is created).
265
+ const refreshGroupOption = options?.refreshGroup;
266
+ const groupKey =
267
+ refreshGroupOption === undefined
268
+ ? ""
269
+ : JSON.stringify(
270
+ typeof refreshGroupOption === "string"
271
+ ? [refreshGroupOption]
272
+ : [...new Set(refreshGroupOption)].sort(),
273
+ );
274
+ const groupList = useMemo<string[]>(
275
+ () => (groupKey === "" ? [] : (JSON.parse(groupKey) as string[])),
276
+ [groupKey],
277
+ );
278
+ const hasGroups = groupList.length > 0;
258
279
  // A grouped reader with no explicit key gets a private per-hook bucket so a
259
280
  // cross-loader group refresh cannot leak into the bare `loader.$$id` bucket
260
281
  // shared by unrelated unkeyed readers. Sharing within a group is opt-in via
261
282
  // an explicit `key`.
262
283
  const privateBucketIdRef = useRef<string | null>(null);
263
- if (
264
- refreshGroup !== undefined &&
265
- key === undefined &&
266
- privateBucketIdRef.current === null
267
- ) {
284
+ if (hasGroups && key === undefined && privateBucketIdRef.current === null) {
268
285
  privateBucketIdRef.current = `__rg${privateGroupBucketSeq++}`;
269
286
  }
270
287
  const effectiveKey =
271
- key ??
272
- (refreshGroup !== undefined ? privateBucketIdRef.current! : undefined);
288
+ key ?? (hasGroups ? privateBucketIdRef.current! : undefined);
273
289
  const bucketKey =
274
290
  effectiveKey === undefined ? loaderId : `${loaderId}::${effectiveKey}`;
275
291
 
276
292
  // Plain-GET refresh thunk registered with the store for cross-loader group
277
293
  // refresh (useRefreshLoaders). Always shares into this hook's bucket, never
278
294
  // touches lastSharedRequestIdRef (so a group refresh never render-throws —
279
- // errors surface via `error` and reject the refreshGroup() promise instead),
295
+ // errors surface via `error` and reject the refreshGroups() promise instead),
280
296
  // and sends no params/body. Stable across navigations (depends only on
281
297
  // loaderId + bucketKey), so the store keeps one current thunk per bucket.
282
298
  const groupRefetch = useCallback(async (): Promise<void> => {
@@ -340,16 +356,17 @@ function useLoaderInternal<T>(
340
356
  {
341
357
  loaderId,
342
358
  ephemeral: !hasContextData,
343
- group: refreshGroup,
344
- refetch: refreshGroup !== undefined ? groupRefetch : undefined,
359
+ group: hasGroups ? groupList : undefined,
360
+ refetch: hasGroups ? groupRefetch : undefined,
345
361
  },
346
362
  );
347
363
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional:
348
364
  // sharedSnapshot is captured for the one-shot init sync; we don't want
349
365
  // to re-subscribe on every snapshot change. bucketKey, hasContextData,
350
- // refreshGroup, and groupRefetch are the only inputs that require a fresh
351
- // subscription (groupRefetch is stable per bucketKey).
352
- }, [bucketKey, hasContextData, refreshGroup, groupRefetch]);
366
+ // groupKey, and groupRefetch are the only inputs that require a fresh
367
+ // subscription (groupList is memoized on groupKey; groupRefetch is stable
368
+ // per bucketKey).
369
+ }, [bucketKey, hasContextData, groupKey, groupRefetch]);
353
370
 
354
371
  // Local state holds the result of:
355
372
  // - parameterized / mutation `load()` calls (load({ params }), POST,
@@ -360,9 +377,13 @@ function useLoaderInternal<T>(
360
377
  // prevents two unrelated components from accidentally sharing data
361
378
  // through the global store just because they reference the same
362
379
  // loader id.
363
- const [localFetchedData, setLocalFetchedData] = useState<T | undefined>(
364
- undefined,
365
- );
380
+ // `has` distinguishes a committed local result (including `null`/`undefined`)
381
+ // from "no local load yet", so a load() that resolves to a falsy value is not
382
+ // discarded in favor of the shared snapshot or the seeded context.
383
+ const [localFetchedData, setLocalFetchedData] = useState<{
384
+ has: boolean;
385
+ value: T | undefined;
386
+ }>({ has: false, value: undefined });
366
387
  const [localIsLoading, setLocalIsLoading] = useState(false);
367
388
  const [localError, setLocalError] = useState<Error | null>(null);
368
389
 
@@ -385,7 +406,7 @@ function useLoaderInternal<T>(
385
406
  const prevContextDataRef = useRef(contextData);
386
407
  useEffect(() => {
387
408
  if (prevContextDataRef.current !== contextData) {
388
- setLocalFetchedData(undefined);
409
+ setLocalFetchedData({ has: false, value: undefined });
389
410
  setLocalIsLoading(false);
390
411
  setLocalError(null);
391
412
  lastSharedRequestIdRef.current = null;
@@ -396,10 +417,14 @@ function useLoaderInternal<T>(
396
417
  }
397
418
  }, [contextData, loaderId]);
398
419
 
399
- // Read priority: a parameterized load() result overrides the shared
400
- // snapshot; the shared snapshot overrides the server-seeded context.
401
- const data =
402
- localFetchedData ?? (sharedSnapshot.value as T | undefined) ?? contextData;
420
+ // Read priority: a committed parameterized load() result overrides the shared
421
+ // snapshot; a committed shared snapshot overrides the server-seeded context.
422
+ // `has`/`hasValue` gate each level so a committed falsy value is not skipped.
423
+ const data = localFetchedData.has
424
+ ? localFetchedData.value
425
+ : sharedSnapshot.hasValue
426
+ ? (sharedSnapshot.value as T | undefined)
427
+ : contextData;
403
428
  const isLoading = localIsLoading || sharedSnapshot.isLoading;
404
429
  const error = localError ?? sharedSnapshot.error;
405
430
 
@@ -553,7 +578,7 @@ function useLoaderInternal<T>(
553
578
  // if a newer load() was issued from this hook before this one
554
579
  // resolved, drop the stale result.
555
580
  startTransition(() => {
556
- setLocalFetchedData(result);
581
+ setLocalFetchedData({ has: true, value: result });
557
582
  setLocalIsLoading(false);
558
583
  });
559
584
  }
@@ -712,14 +737,21 @@ export function useFetchLoader<T>(
712
737
  }
713
738
 
714
739
  /**
715
- * Refresh every loader tagged with a shared `refreshGroup` name.
740
+ * Get a stable function that refreshes loaders by cross-loader group tag.
716
741
  *
717
- * Returns a stable async function that refreshes all currently-mounted reads
718
- * in the group with a plain GET against the current route URL. This is the
719
- * cross-loader counterpart to the single-loader `key`: use it to refresh a set
720
- * of DIFFERENT loaders together (e.g. profile + orders after an account
721
- * switch). Members are tagged via `useLoader(Loader, { refreshGroup })` /
722
- * `useFetchLoader(Loader, { refreshGroup })`.
742
+ * The returned `refresh(groups)` takes one group name or an array of names and
743
+ * re-runs every currently-mounted read tagged with ANY of them, with a plain GET
744
+ * against the current route URL. This is the cross-loader counterpart to the
745
+ * single-loader `key`: use it to refresh a set of DIFFERENT loaders together
746
+ * (e.g. profile + orders after an account switch). Members are tagged via
747
+ * `useLoader(Loader, { refreshGroup })` / `useFetchLoader(Loader, { refreshGroup })`,
748
+ * where `refreshGroup` is one name or several.
749
+ *
750
+ * Passing the group(s) to the returned function rather than to the hook lets a
751
+ * single `useRefreshLoaders()` instance refresh different groups depending on
752
+ * context, and lets one call refresh several groups at once — their members are
753
+ * unioned and deduped, so a loader tagged into two of the named groups is fetched
754
+ * exactly once.
723
755
  *
724
756
  * Group refresh never render-throws: a failing member surfaces its error via
725
757
  * that read's `error` state, and the returned promise rejects with an
@@ -736,15 +768,30 @@ export function useFetchLoader<T>(
736
768
  * return <span>{data.name}</span>;
737
769
  * }
738
770
  * function Orders() {
739
- * const { data } = useLoader(OrdersLoader, { key: userId, refreshGroup: "account" });
771
+ * // Tagged into two groups: refreshed by "account" (the whole set) or "orders".
772
+ * const { data } = useLoader(OrdersLoader, {
773
+ * key: userId,
774
+ * refreshGroup: ["account", "orders"],
775
+ * });
740
776
  * return <span>{data.count} orders</span>;
741
777
  * }
742
- * function RefreshButton() {
743
- * const refreshAccount = useRefreshLoaders("account");
744
- * return <button onClick={() => refreshAccount()}>Refresh</button>;
778
+ * function RefreshButtons() {
779
+ * const refresh = useRefreshLoaders();
780
+ * return (
781
+ * <>
782
+ * <button onClick={() => refresh("account")}>Refresh account</button>
783
+ * <button onClick={() => refresh("orders")}>Refresh orders</button>
784
+ * <button onClick={() => refresh(["account", "orders"])}>Refresh both</button>
785
+ * </>
786
+ * );
745
787
  * }
746
788
  * ```
747
789
  */
748
- export function useRefreshLoaders(group: string): () => Promise<void> {
749
- return useCallback(() => loaderStore.refreshGroup(group), [group]);
790
+ export function useRefreshLoaders(): (
791
+ groups: string | string[],
792
+ ) => Promise<void> {
793
+ return useCallback(
794
+ (groups: string | string[]) => loaderStore.refreshGroups(groups),
795
+ [],
796
+ );
750
797
  }
@@ -6,6 +6,7 @@ import {
6
6
  buildExportMap,
7
7
  escapeRegExp,
8
8
  } from "../expose-id-utils.js";
9
+ import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
9
10
  import type { CreateExportBinding } from "./types.js";
10
11
 
11
12
  /**
@@ -59,19 +60,57 @@ export function isExportOnlyFile(
59
60
  return true;
60
61
  }
61
62
 
62
- // NOTE: This regex may over-count when the fn name appears inside strings or
63
- // comments, but it's only used for the warning heuristic (totalCalls >
64
- // supportedBindings) and the inline-extraction pre-check, so over-counting
65
- // triggers a harmless extra AST parse rather than affecting correctness.
63
+ function createCallPattern(fnNames: string[]): RegExp {
64
+ return new RegExp(
65
+ `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
66
+ "g",
67
+ );
68
+ }
69
+
70
+ // Counts real create*() call sites, ignoring occurrences inside comments and
71
+ // string literals. Used by the unsupported-shape warning heuristic and the
72
+ // inline-extraction pre-check.
66
73
  export function countCreateCallsForNames(
67
74
  code: string,
68
75
  fnNames: string[],
69
76
  ): number {
70
- const pattern = new RegExp(
71
- `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
72
- "g",
73
- );
74
- return (code.match(pattern) || []).length;
77
+ return codeMatchIndices(code, createCallPattern(fnNames)).length;
78
+ }
79
+
80
+ /** Convert a 0-based byte offset to a 1-based { line, column }. */
81
+ export function offsetToLineColumn(
82
+ code: string,
83
+ index: number,
84
+ ): { line: number; column: number } {
85
+ let line = 1;
86
+ let lineStart = 0;
87
+ const end = Math.min(index, code.length);
88
+ for (let i = 0; i < end; i++) {
89
+ if (code[i] === "\n") {
90
+ line++;
91
+ lineStart = i + 1;
92
+ }
93
+ }
94
+ return { line, column: index - lineStart + 1 };
95
+ }
96
+
97
+ /**
98
+ * Locate every real create*() call site (comment/string-free) that is NOT one
99
+ * of the supported, id-injectable export bindings, returning each as a 1-based
100
+ * { line, column }. The empty result means every call is in a supported shape.
101
+ * Both binding-collection paths anchor `callExprStart` at the start of the
102
+ * create* identifier — exactly where this pattern matches — so the set
103
+ * difference is exact.
104
+ */
105
+ export function findUnsupportedCreateCallSites(
106
+ code: string,
107
+ fnNames: string[],
108
+ supportedBindings: CreateExportBinding[],
109
+ ): Array<{ line: number; column: number }> {
110
+ const supported = new Set(supportedBindings.map((b) => b.callExprStart));
111
+ return codeMatchIndices(code, createCallPattern(fnNames))
112
+ .filter((index) => !supported.has(index))
113
+ .map((index) => offsetToLineColumn(code, index));
75
114
  }
76
115
 
77
116
  export function getImportedFnNames(
@@ -306,9 +345,25 @@ export function collectCreateExportBindings(
306
345
  export function buildUnsupportedShapeWarning(
307
346
  filePath: string,
308
347
  fnName: string,
348
+ sites: Array<{ line: number; column: number }> = [],
309
349
  ): string {
310
- return [
311
- `[rango] Unsupported ${fnName} shape in "${filePath}".`,
350
+ const lines = [`[rango] Unsupported ${fnName} shape in "${filePath}".`];
351
+
352
+ // Point at the exact call(s) so the location is clickable in the terminal/IDE
353
+ // (file:line:column) instead of leaving the user to scan the whole file.
354
+ if (sites.length === 1) {
355
+ const s = sites[0];
356
+ lines.push(
357
+ `The ${fnName}(...) call at ${filePath}:${s.line}:${s.column} has no stable $$id injected — it is not in a supported shape.`,
358
+ );
359
+ } else if (sites.length > 1) {
360
+ lines.push(
361
+ `These ${fnName}(...) calls have no stable $$id injected — they are not in a supported shape:`,
362
+ );
363
+ for (const s of sites) lines.push(` - ${filePath}:${s.line}:${s.column}`);
364
+ }
365
+
366
+ lines.push(
312
367
  `Supported shapes are:`,
313
368
  ` - export const X = ${fnName}(...)`,
314
369
  ` - const X = ${fnName}(...); export { X }`,
@@ -316,5 +371,6 @@ export function buildUnsupportedShapeWarning(
316
371
  `Potentially unsupported forms include:`,
317
372
  ` - export let/var X = ${fnName}(...)`,
318
373
  ` - inline ${fnName}(...) calls`,
319
- ].join("\n");
374
+ );
375
+ return lines.join("\n");
320
376
  }
@@ -28,6 +28,7 @@ import {
28
28
  getImportedFnNames,
29
29
  collectCreateExportBindings,
30
30
  buildUnsupportedShapeWarning,
31
+ findUnsupportedCreateCallSites,
31
32
  isExportOnlyFile,
32
33
  } from "./expose-ids/export-analysis.js";
33
34
  import {
@@ -370,14 +371,21 @@ ${lazyImports.join(",\n")}
370
371
  if (!hasCode) continue;
371
372
 
372
373
  const fnNames = getFnNames(cfg.fnName);
373
- const totalCalls = countCreateCallsForNames(code, fnNames);
374
- const supportedBindings = getBindings(code, fnNames).length;
375
- if (totalCalls <= supportedBindings) continue;
374
+ // Locate the real (comment/string-free) create* calls not covered by
375
+ // a supported, id-injectable export shape. Empty means every call is
376
+ // fine in particular, a create*() token in a comment or string no
377
+ // longer trips a spurious warning.
378
+ const sites = findUnsupportedCreateCallSites(
379
+ code,
380
+ fnNames,
381
+ getBindings(code, fnNames),
382
+ );
383
+ if (sites.length === 0) continue;
376
384
 
377
385
  const warnKey = `${id}::${cfg.fnName}`;
378
386
  if (unsupportedShapeWarnings.has(warnKey)) continue;
379
387
  unsupportedShapeWarnings.add(warnKey);
380
- this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName));
388
+ this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName, sites));
381
389
  }
382
390
 
383
391
  // --- Loader: track for manifest (RSC env only) ---
@@ -30,6 +30,13 @@ const CACHE_RUNTIME_IMPORT = "@rangojs/router/cache-runtime";
30
30
  // and should not be wrapped (children can't be cache-keyed).
31
31
  const LAYOUT_TEMPLATE_PATTERN = /\/(layout|template)\.(tsx?|jsx?)$/;
32
32
 
33
+ /**
34
+ * Grammar for a valid function-level directive: `use cache` optionally followed
35
+ * by `: <profile-name>`. The single source of truth for both the transform and
36
+ * the near-miss validator below.
37
+ */
38
+ export const USE_CACHE_DIRECTIVE_RE: RegExp = /^use cache(:\s*[\w-]+)?$/;
39
+
33
40
  export function useCacheTransform(): Plugin {
34
41
  let projectRoot = "";
35
42
  let isBuild = false;
@@ -116,9 +123,9 @@ export function useCacheTransform(): Plugin {
116
123
  transformHoistInlineDirective,
117
124
  );
118
125
 
119
- // Always check for near-miss directives, even when valid directives
120
- // exist. A file may contain both valid and invalid "use cache" directives
121
- // in different functions the invalid ones should still warn.
126
+ // Check for near-miss directives on the function-level path. The
127
+ // file-level branch above returns earlier (it wraps every export
128
+ // regardless), so this runs only when there is no file-level directive.
122
129
  warnOnNearMissDirectives(ast, id, this.warn.bind(this));
123
130
 
124
131
  if (functionResult) return functionResult;
@@ -219,7 +226,7 @@ function transformFunctionLevelUseCache(
219
226
  ) {
220
227
  try {
221
228
  const { output, names } = transformHoistInlineDirective(code, ast, {
222
- directive: /^use cache(:\s*[\w-]+)?$/,
229
+ directive: USE_CACHE_DIRECTIVE_RE,
223
230
  runtime: (
224
231
  value: string,
225
232
  name: string,
@@ -273,11 +280,6 @@ function findFileLevelDirective(
273
280
  return null;
274
281
  }
275
282
 
276
- /**
277
- * The valid directive regex (must stay in sync with transformFunctionLevelUseCache).
278
- */
279
- const VALID_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
280
-
281
283
  /**
282
284
  * Regex for near-miss: starts with "use cache:" but has invalid tokens.
283
285
  */
@@ -307,7 +309,7 @@ function warnOnNearMissDirectives(
307
309
  if (
308
310
  value.startsWith("use cache") &&
309
311
  NEAR_MISS_RE.test(value) &&
310
- !VALID_DIRECTIVE_RE.test(value)
312
+ !USE_CACHE_DIRECTIVE_RE.test(value)
311
313
  ) {
312
314
  const profilePart = value.slice("use cache:".length).trim();
313
315
  warn(
@@ -17,6 +17,7 @@ import {
17
17
  findNestedRouterConflict,
18
18
  findRouterFiles,
19
19
  } from "../build/generate-route-types.js";
20
+ import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
20
21
  import { createVersionPlugin } from "./plugins/version-plugin.js";
21
22
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
22
23
  import {
@@ -1184,8 +1185,19 @@ export function createRouterDiscoveryPlugin(
1184
1185
  trimmed.startsWith('"use client"') ||
1185
1186
  trimmed.startsWith("'use client'");
1186
1187
  if (!inRecoveryMode && isUseClient) return;
1187
- const hasUrls = source.includes("urls(");
1188
- const hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
1188
+ // Cheap raw pre-check first; only when a candidate token is present
1189
+ // do we confirm it occurs in real code (not a comment/string) via a
1190
+ // single allocation-free code-region scan. Most saved files contain
1191
+ // neither token and skip the scan entirely. This avoids a comment or
1192
+ // string mention spuriously marking a file relevant and triggering an
1193
+ // unnecessary re-discovery on save.
1194
+ let hasUrls = source.includes("urls(");
1195
+ let hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
1196
+ if (hasUrls) hasUrls = firstCodeMatchIndex(source, /urls\(/g) >= 0;
1197
+ if (hasCreateRouter) {
1198
+ hasCreateRouter =
1199
+ firstCodeMatchIndex(source, /\bcreateRouter\s*[<(]/g) >= 0;
1200
+ }
1189
1201
  if (!inRecoveryMode && !hasUrls && !hasCreateRouter) return;
1190
1202
  if (inRecoveryMode) {
1191
1203
  debugDiscovery?.(