@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
@@ -68,7 +68,10 @@ createRouter({
68
68
 
69
69
  - `"use cache"` (no name) resolves to `default`.
70
70
  - `"use cache: short"` resolves to the `short` profile.
71
- - Unknown profile names throw at build/boot time.
71
+ - Unknown profile names throw at runtime, on the first invocation of the cached
72
+ function (the Vite transform does not validate names at build/boot). The error
73
+ is actionable -- it names the missing profile and shows the `createRouter({
74
+ cacheProfiles: { ... } })` entry to add.
72
75
 
73
76
  ## Cache Key
74
77
 
@@ -77,18 +80,33 @@ use-cache:{functionId}:{serializedArgs}
77
80
  ```
78
81
 
79
82
  - `functionId` -- stable ID from Vite transform (module path + export name).
80
- - `serializedArgs` -- non-tainted arguments serialized via RSC `encodeReply()`.
83
+ - `serializedArgs` -- key-generating arguments serialized via RSC `encodeReply()`.
84
+
85
+ When there are no key-generating arguments, the key has no trailing colon -- it is
86
+ just `use-cache:{functionId}`.
81
87
 
82
88
  Different functions always produce different cache keys, even for the same route.
83
89
  This is important for intercepted routes -- the path handler and intercept handler
84
90
  each have their own `functionId` and therefore their own cache entries.
85
91
 
92
+ ### Route context is folded into the key
93
+
94
+ The tainted `ctx` object is excluded from arg serialization (see below), but
95
+ route-identifying fields read off it are extracted into `serializedArgs`:
96
+ `url.host`, route name (`_routeName`), `pathname`, `params`, response type
97
+ (`_responseType`), and the user-facing sorted search params (internal `_rsc*`/`__`
98
+ params excluded). The same cached function called with `ctx` on different routes,
99
+ param combinations, hosts, response types, or query variants therefore produces
100
+ distinct cache entries -- not one shared entry.
101
+
86
102
  ## Tainted Arguments (ctx, env, req)
87
103
 
88
104
  Request-scoped objects are branded with `Symbol.for('rango:nocache')` at creation.
89
105
  When detected:
90
106
 
91
107
  1. **Excluded from cache key** -- request-scoped, not meaningful for keying.
108
+ (The route-identifying fields read off `ctx` are still folded in -- see
109
+ "Route context is folded into the key" above.)
92
110
  2. **Handle data captured on miss** -- side effects via `ctx.use(Handle)` are recorded.
93
111
  3. **Handle data replayed on hit** -- restored into the current request's HandleStore.
94
112
 
@@ -122,12 +140,16 @@ const data = await getCachedData(locale); // locale is now in the cache key
122
140
  These ctx methods **throw** inside a `"use cache"` function because their effects
123
141
  are lost on cache hit (the function body is skipped):
124
142
 
125
- - `ctx.set()` / `ctx.get()` for passing values to children
143
+ - `ctx.set()` for passing values to children
126
144
  - `ctx.header()`
127
145
  - `ctx.setTheme()`
128
146
  - `ctx.setLocationState()`
129
147
  - `ctx.onResponse()`
130
148
 
149
+ `ctx.get()` is **not** exec-guarded inside `"use cache"` -- it is a read, so it is
150
+ safe. (It only throws when reading a non-cacheable variable inside the separate
151
+ route-level `cache()` DSL boundary.)
152
+
131
153
  The error message recommends two alternatives:
132
154
 
133
155
  1. Extract the data fetch into a separate cached function and call ctx methods outside it.
@@ -304,8 +326,15 @@ export async function getProducts() {
304
326
  ## Backing Store
305
327
 
306
328
  Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
307
- One store, one configuration, one invalidation API. Tag-based invalidation
308
- (`revalidateTag`) works across all mechanisms.
329
+ One store, one configuration.
330
+
331
+ Cache entries (and `cacheProfiles`) accept an optional `tags` field, but the
332
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`) do not yet index or
333
+ invalidate by tag -- tags are passed through to the store and otherwise ignored.
334
+ Tag-based invalidation (`revalidateTag`) is a forward-looking API that requires a
335
+ custom store with secondary indices. Today entries expire by TTL/SWR. The separate
336
+ `revalidate()` export is the client-update axis (which segments re-render on a
337
+ navigation or action), not a cache bust.
309
338
 
310
339
  ## Interaction with Other Caching
311
340
 
@@ -6,11 +6,33 @@ argument-hint: [layout|route|parallel|intercept]
6
6
 
7
7
  # View Transitions
8
8
 
9
- Rango wires React's experimental `<ViewTransition>` into the segment tree via the `transition()` helper. Each segment can declare its own transition config; rango wraps it at the right tree position so navigations morph the right pieces and modals do not.
9
+ `transition()` opts a route (or group of routes) into transition-driven navigation. It does two things, and you choose how far to go:
10
10
 
11
- > Requires React experimental (the build that exports `<ViewTransition>` and `addTransitionType`). With stable React, `transition()` is a no-opyour routes still render, just without view-transition wrappers.
11
+ 1. **`startTransition` (the foundation).** The navigation commit is driven through React's `startTransition`. That holds the previous content across a same-route navigation (stale-while-revalidate no loading-skeleton flash) and is the **precondition** for any view-transition animation. Works on **all** React versions.
12
+ 2. **`<ViewTransition>` (the animation, layered on top).** On experimental React, rango also wraps the segment content in React's `<ViewTransition>` so the swap cross-fades/morphs. This is the only part that needs experimental React; pass `viewTransition: false` to keep #1 without it (and place your own `<ViewTransition>` where you want it).
12
13
 
13
- ## What `transition()` does
14
+ > The `<ViewTransition>` layer requires React experimental (the build that exports `<ViewTransition>` / `addTransitionType`). On stable React that layer is a no-op — but the `startTransition` driving (content hold) still applies.
15
+
16
+ ## Purpose: `startTransition` vs `<ViewTransition>`
17
+
18
+ These are two **independent** mechanisms. `startTransition` controls _fallbacks_ (hold the old content vs. flash the Suspense skeleton) and is what lets a view transition fire at all; the `<ViewTransition>` boundary is the _visual cross-fade_.
19
+
20
+ | | `startTransition` **OFF** | `startTransition` **ON** |
21
+ | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
22
+ | **`<ViewTransition>` OFF** | plain nav — remount on param change, skeleton flash, no animation | **hold** content (no skeleton flash); a consumer-placed `<ViewTransition>` still morphs; no router cross-fade |
23
+ | **`<ViewTransition>` ON** | **impossible** — React never activates `<ViewTransition>` outside a Transition | hold + router cross-fade |
24
+
25
+ The bottom-left cell is the key constraint: a view transition cannot exist without a `startTransition`. So once you reach for `transition()`, the only real choice is _startTransition_ vs _startTransition + ViewTransition_:
26
+
27
+ | What you want | Config | Effect |
28
+ | -------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
29
+ | nothing (default nav) | no `transition()` | remount + skeleton on param change |
30
+ | `startTransition` only | `transition({ viewTransition: false })` | hold content; place your own `<ViewTransition>` where you want it |
31
+ | `startTransition` + `<ViewTransition>` | `transition({})` / `transition({ enter, exit, … })` | hold + router cross-fade (experimental React; on stable it degrades to the `startTransition`-only row) |
32
+
33
+ `createRouter({ viewTransition: "auto" \| false })` sets the app-wide default for the third row; a per-segment `viewTransition` wins. See [Opting out of the router boundary](#opting-out-of-the-router-boundary-place-your-own-viewtransition) for the full opt-out story.
34
+
35
+ ## What `transition()` does (wrap location)
14
36
 
15
37
  `transition(config)` attaches a [`TransitionConfig`](#transitionconfig) to the surrounding entry. Where the wrap actually lands in the rendered React tree depends on the segment type:
16
38
 
@@ -186,12 +208,72 @@ interface TransitionConfig {
186
208
  share?: string | Record<string, string>;
187
209
  default?: string | Record<string, string>; // fallback for any phase
188
210
  name?: string; // explicit view-transition-name
211
+ viewTransition?: "auto" | false; // boundary opt-out (see below)
189
212
  }
190
213
  ```
191
214
 
192
215
  - `default` is the catch-all if a phase-specific prop is unset.
193
216
  - The object form keys are React transition types tagged by rango: `"navigation"` (forward navigations), `"navigation-back"` (popstate cache restores), and `"action"` (partial-update action/refetch paths only — see the caveat in "Direction-aware transitions").
194
217
  - `name` lets you participate in cross-page morphs by name (advanced; you usually don't need this on a layout/route-level wrap).
218
+ - `viewTransition` toggles whether rango places its own `<ViewTransition>` boundary. `"auto"` (default) wraps as described above; `false` opts out — see the next section.
219
+
220
+ ## Opting out of the router boundary (place your own `<ViewTransition>`)
221
+
222
+ By default a `transition()` segment gets a rango-placed `<ViewTransition>` boundary — a cross-fade of the whole outlet/route. If you'd rather animate specific elements yourself (place `<ViewTransition name="...">` in your components), set `viewTransition: false`. The router then contributes **no boundary of its own** but still:
223
+
224
+ - drives the navigation commit through `startTransition` (so React runs `document.startViewTransition`, and your own `<ViewTransition>` elements animate on navigation — driving is what they need, not a router boundary), and
225
+ - holds same-route content (stale-while-revalidate; no skeleton flash).
226
+
227
+ ```tsx
228
+ // Router drives the transition + holds content, but places NO cross-fade.
229
+ // Only your <ViewTransition name="hero"> morphs.
230
+ urls(({ path, transition }) => [
231
+ path("/product/:id", ProductPage, { name: "product" }, () => [
232
+ transition({ viewTransition: false }),
233
+ ]),
234
+ ]);
235
+
236
+ // ProductPage renders the boundary itself, exactly where it's wanted:
237
+ function ProductPage() {
238
+ return (
239
+ <ViewTransition name="hero">
240
+ <img src={cover} />
241
+ </ViewTransition>
242
+ );
243
+ }
244
+ ```
245
+
246
+ This is the rango analogue of the "router triggers, you place the names" model used by React Router / TanStack: rango guarantees navigations run inside a React transition; you own the boundaries.
247
+
248
+ **App-wide default.** Flip the default for every `transition()` segment at the router level. A per-segment `viewTransition` still overrides it.
249
+
250
+ ```ts
251
+ const router = createRouter<AppEnv>({ viewTransition: false });
252
+ // Now `transition({})` drives + holds but places no boundary anywhere.
253
+ // Re-enable a router boundary on one route with transition({ viewTransition: "auto" }).
254
+ ```
255
+
256
+ **Precedence (per-route vs router default).** A bare `transition({})` has no per-route `viewTransition`, so it inherits the router default (`"auto"` unless `createRouter({ viewTransition: false })`). An explicit per-route value always wins. The `viewTransition` flag only toggles the boundary — `startTransition` driving and content-hold are on in every row below (they key off `transition()` presence, not this flag):
257
+
258
+ | per-route (`transition(...)`) | router (`createRouter`) | resolved boundary | result |
259
+ | ---------------------------------------- | ----------------------- | ------------------------ | ----------- |
260
+ | `transition({})` (unset) | `"auto"` (default) | wrap | **ST + VT** |
261
+ | `transition({})` (unset) | `false` | no wrap | **ST only** |
262
+ | `transition({ viewTransition: "auto" })` | `"auto"` | wrap | ST + VT |
263
+ | `transition({ viewTransition: "auto" })` | `false` | wrap (per-route wins) | **ST + VT** |
264
+ | `transition({ viewTransition: false })` | `"auto"` | no wrap (per-route wins) | **ST only** |
265
+ | `transition({ viewTransition: false })` | `false` | no wrap | ST only |
266
+
267
+ On stable React the "VT" column is always a no-op (there is no `<ViewTransition>`), so every row collapses to its `startTransition`-only behavior there.
268
+
269
+ | Config | Router boundary | startTransition driving (no skeleton flash) | Your own `<ViewTransition name>` |
270
+ | ---------------------------------------------------- | ---------------- | ------------------------------------------- | ---------------------------------- |
271
+ | no `transition()` | — | no | does not fire on nav |
272
+ | `transition({})` / `{ viewTransition: "auto" }` | yes (cross-fade) | yes | fires, under the router cross-fade |
273
+ | `transition({ viewTransition: false })` | none | yes | fires alone |
274
+ | global `viewTransition: false`, route `transition()` | none | yes | fires alone |
275
+
276
+ > On **stable** React there is no `<ViewTransition>` at all, so `viewTransition: false` is visually a no-op there — but the startTransition driving and content-hold still apply, identical to `transition({})`.
195
277
 
196
278
  ## Recommendations
197
279
 
@@ -3,6 +3,8 @@
3
3
  * No "use client" directive so it can be imported from RSC
4
4
  */
5
5
 
6
+ import type { ReactElement } from "react";
7
+
6
8
  /**
7
9
  * Internal entry representing a state value with its unique key.
8
10
  * When __rsc_ls_lazy is true, __rsc_ls_value holds a getter function
@@ -22,6 +24,88 @@ export interface LocationStateOptions {
22
24
  flash?: boolean;
23
25
  }
24
26
 
27
+ type LocationStateUnsafeFn = (...args: never[]) => unknown;
28
+
29
+ // Broadest constructor signature (`abstract` covers both abstract and concrete
30
+ // classes). A class passed as state has a `new` signature, not a call signature,
31
+ // so it slips past LocationStateUnsafeFn; at runtime the lazy-getter path
32
+ // (`typeof value === "function"`) then mistakes it for a getter and throws.
33
+ type LocationStateUnsafeCtor = abstract new (...args: never[]) => unknown;
34
+
35
+ // `unknown` cannot be verified serializable, so it is rejected (callers must
36
+ // supply a concrete type). `any` deliberately defeats type checking and is NOT
37
+ // guardable — it is assignable to the branded error too, so the check always
38
+ // passes; it remains an explicit escape hatch.
39
+ type IsAny<T> = 0 extends 1 & T ? true : false;
40
+ type IsUnknown<T> =
41
+ IsAny<T> extends true ? false : unknown extends T ? true : false;
42
+
43
+ /**
44
+ * Branded error surfaced when a value that cannot live in location state is
45
+ * used. Location state is written into `history.state`, which uses the
46
+ * structured clone algorithm; React elements, functions, and symbols throw a
47
+ * `DataCloneError` at runtime. Carries a human-readable reason so the compile
48
+ * error explains the fix.
49
+ */
50
+ export type LocationStateUnsafe<Reason extends string> = {
51
+ readonly __rango_location_state_unsafe: Reason;
52
+ };
53
+
54
+ /**
55
+ * Maps `T` to itself when it is safe to store in location state, or to a branded
56
+ * {@link LocationStateUnsafe} error for the disallowed parts: `unknown`, React
57
+ * elements (RSC/JSX content), functions, class constructors, and symbols.
58
+ * Recurses through arrays, `Map`, `Set`, and plain objects; structured-clone
59
+ * built-ins (`Date`, `RegExp`, typed arrays, `Blob`, `File`, `FormData`) pass
60
+ * through. Consumed by {@link ValidateLocationState}, which is intersected into a
61
+ * definition's value parameter so posting RSC content is a COMPILE error, not a
62
+ * runtime `DataCloneError`. (`any` is unguardable and remains an escape hatch.)
63
+ */
64
+ export type LocationStateSafe<T> =
65
+ IsUnknown<T> extends true
66
+ ? LocationStateUnsafe<"location state needs an explicit, concrete type; `unknown` cannot be verified as serializable">
67
+ : T extends LocationStateUnsafeFn
68
+ ? LocationStateUnsafe<"functions cannot be stored in location state">
69
+ : T extends LocationStateUnsafeCtor
70
+ ? LocationStateUnsafe<"class constructors cannot be stored in location state">
71
+ : T extends symbol
72
+ ? LocationStateUnsafe<"symbols cannot be stored in location state">
73
+ : T extends ReactElement
74
+ ? LocationStateUnsafe<"React/RSC content cannot be stored in location state; store plain data and render it on arrival">
75
+ : T extends string | number | boolean | bigint | null | undefined
76
+ ? T
77
+ : T extends
78
+ | Date
79
+ | RegExp
80
+ | ArrayBuffer
81
+ | ArrayBufferView
82
+ | Blob
83
+ | File
84
+ | FormData
85
+ ? T
86
+ : T extends ReadonlyMap<infer K, infer V>
87
+ ? ReadonlyMap<LocationStateSafe<K>, LocationStateSafe<V>>
88
+ : T extends ReadonlySet<infer V>
89
+ ? ReadonlySet<LocationStateSafe<V>>
90
+ : T extends readonly unknown[]
91
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
92
+ : T extends object
93
+ ? { [K in keyof T]: LocationStateSafe<T[K]> }
94
+ : T;
95
+
96
+ /**
97
+ * `unknown` (a no-op) when `T` is safe to store in location state, otherwise a
98
+ * branded {@link LocationStateUnsafe} object. Intersected into the value
99
+ * parameter of a definition's call and `write()` so POSTING RSC content (or any
100
+ * non-serializable value) is a compile error whose text carries the reason —
101
+ * without a `TState extends ...` self-constraint, which TypeScript rejects as
102
+ * circular (TS2313). For safe `T`, `value & unknown` collapses back to `value`,
103
+ * so valid usage is unchanged.
104
+ */
105
+ export type ValidateLocationState<T> = [T] extends [LocationStateSafe<T>]
106
+ ? unknown
107
+ : LocationStateUnsafe<"location state must be serializable: React/RSC content, functions, and symbols cannot be stored — pass plain data and render it on arrival">;
108
+
25
109
  /**
26
110
  * Type-safe location state definition
27
111
  *
@@ -59,7 +143,7 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
59
143
  *
60
144
  * Client-only: throws when called on the server (no history available).
61
145
  */
62
- write(value: TState): void;
146
+ write(value: TState & ValidateLocationState<TState>): void;
63
147
  /**
64
148
  * Statically remove this definition's slot from the current history entry,
65
149
  * leaving any other keys on history.state untouched. Idempotent: removing
@@ -118,7 +202,10 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
118
202
  */
119
203
  export function createLocationState<TState>(
120
204
  options?: LocationStateOptions,
121
- ): LocationStateDefinition<[TState | (() => TState)], TState> {
205
+ ): LocationStateDefinition<
206
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
207
+ TState
208
+ > {
122
209
  const flash = options?.flash ?? false;
123
210
  let _key: string | undefined;
124
211
 
@@ -209,7 +296,10 @@ export function createLocationState<TState>(
209
296
  enumerable: true,
210
297
  });
211
298
 
212
- return fn as LocationStateDefinition<[TState | (() => TState)], TState>;
299
+ return fn as unknown as LocationStateDefinition<
300
+ [(TState | (() => TState)) & ValidateLocationState<TState>],
301
+ TState
302
+ >;
213
303
  }
214
304
 
215
305
  /**
@@ -34,11 +34,18 @@ function joinMount(mount: string, pattern: string): string {
34
34
  /**
35
35
  * Mount-aware reverse function for a locally-imported `routes` map.
36
36
  *
37
- * Resolves dot-prefixed route names against the passed `routes` (typically
38
- * a generated `routes` from a `urls()` module's `.gen.ts`), prefixes the
39
- * result with the surrounding `include()` mount path, and substitutes
40
- * params — auto-filling from the current matched route's params and
41
- * letting explicit params override.
37
+ * The `routes` map you pass IS the scope: `reverse("name")` looks the name up
38
+ * in that map (verbatim), prefixes the result with the surrounding `include()`
39
+ * mount path via `useMount()`, and substitutes params — auto-filling from the
40
+ * current matched route's params, with explicit params overriding. A module's
41
+ * components can therefore reverse their own routes without knowing where the
42
+ * module is mounted: include it under any prefix and the URLs resolve correctly.
43
+ *
44
+ * The leading dot is optional and cosmetic: `reverse("post")` and
45
+ * `reverse(".post")` resolve identically. The dot exists only as a readability
46
+ * convention and for parity with `ctx.reverse(".name")` on the server; here the
47
+ * passed map is the scope, so there is no separate global namespace to
48
+ * disambiguate and the dot carries no meaning.
42
49
  *
43
50
  * @example
44
51
  * ```tsx
@@ -50,8 +57,8 @@ function joinMount(mount: string, pattern: string): string {
50
57
  * const reverse = useReverse(blogRoutes);
51
58
  * return (
52
59
  * <>
53
- * <Link to={reverse(".index")}>Blog</Link>
54
- * <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
60
+ * <Link to={reverse("index")}>Blog</Link>
61
+ * <Link to={reverse("post", { postId: "hello" })}>Post</Link>
55
62
  * </>
56
63
  * );
57
64
  * }
@@ -69,14 +76,14 @@ export function useReverse<const TRoutes extends LocalRouteMap>(
69
76
  explicitParams?: Record<string, string | undefined>,
70
77
  search?: Record<string, unknown>,
71
78
  ): string => {
72
- if (!name.startsWith(".")) {
73
- throw new Error(`Local route names must start with ".": "${name}"`);
74
- }
75
- const lookupName = name.slice(1);
79
+ // The leading dot is optional. The passed map IS the scope, so a dot to
80
+ // signal "local" is unnecessary "detail" and ".detail" resolve the same.
81
+ // A dot is accepted (and stripped) for readability / ctx.reverse parity.
82
+ const lookupName = name.startsWith(".") ? name.slice(1) : name;
76
83
  const entry = (routes as LocalRouteMap)[lookupName];
77
84
  const pattern = getPattern(entry);
78
85
  if (pattern === undefined) {
79
- throw new Error(`Unknown local route: "${name}"`);
86
+ throw new Error(`Unknown route: "${name}"`);
80
87
  }
81
88
 
82
89
  const joined = joinMount(mount, pattern);
@@ -97,7 +97,10 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
97
97
  routes = extractRoutesFromSource(source);
98
98
  }
99
99
 
100
- const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
100
+ // Match .ts/.tsx/.js/.jsx (same as router-processing.ts / router-transform.ts).
101
+ // Without the jsx? branch a .jsx/.js source produced genPath === filePath,
102
+ // overwriting the source file instead of writing a sibling .gen.ts.
103
+ const genPath = filePath.replace(/\.(tsx?|jsx?)$/, ".gen.ts");
101
104
 
102
105
  // When a urls() variable was found but static resolution yields zero
103
106
  // routes, write an empty placeholder so generated imports stay
@@ -15,6 +15,7 @@ import {
15
15
  import ts from "typescript";
16
16
  import { generateRouteTypesSource } from "./codegen.js";
17
17
  import type { ScanFilter } from "./scan-filter.js";
18
+ import { firstCodeMatchIndex } from "./source-scan.js";
18
19
  import {
19
20
  resolveImportedVariable,
20
21
  resolveImportPath,
@@ -38,6 +39,8 @@ function countPublicRouteEntries(source: string): number {
38
39
  }
39
40
 
40
41
  const ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
42
+ // Global variant for the code-region scan (firstCodeMatchIndex sets lastIndex).
43
+ const ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
41
44
 
42
45
  function isRoutableSourceFile(name: string): boolean {
43
46
  return (
@@ -90,7 +93,17 @@ function findRouterFilesRecursive(
90
93
 
91
94
  try {
92
95
  const source = readFileSync(fullPath, "utf-8");
93
- if (ROUTER_CALL_PATTERN.test(source)) {
96
+ // Fast path: most files contain no `createRouter(` at all, so the cheap
97
+ // raw regex short-circuits before the code-region scan. Only a file that
98
+ // mentions the token (real call OR a comment/string mention) is rescanned
99
+ // over code regions — allocation-free, never building a stripped copy —
100
+ // so a mention inside a comment or string is not mistaken for a real
101
+ // router file (which previously triggered a spurious "Multiple routers
102
+ // found" error).
103
+ if (
104
+ ROUTER_CALL_PATTERN.test(source) &&
105
+ firstCodeMatchIndex(source, ROUTER_CALL_PATTERN_G) >= 0
106
+ ) {
94
107
  routerFilesInDir.push(fullPath);
95
108
  }
96
109
  } catch {
@@ -0,0 +1,118 @@
1
+ // Allocation-light, linear-time source scanning for the build-time scanners.
2
+ //
3
+ // The router-file scanner, the HMR relevance check, and the unsupported-shape
4
+ // warning all need to know whether a token like `createRouter(` / `createLoader(`
5
+ // appears in REAL code versus inside a comment or string literal. Rather than
6
+ // build a full comment/string-stripped copy of the source (which on a large
7
+ // file allocates an O(n) string plus, naively, a per-char array), these helpers
8
+ // run the regex over the whole source ONCE (the engine sweeps left-to-right,
9
+ // O(n)) and classify each match's offset with a forward, O(1)-memory cursor that
10
+ // advances monotonically across the source.
11
+ //
12
+ // Time: O(n) — one native regex sweep plus one forward classification pass.
13
+ // Memory: O(1) for the boolean check; O(#matches) for the index list. No
14
+ // stripped copy and no per-char array are ever materialized.
15
+ //
16
+ // Pragmatic scanner, not a full tokenizer: regex literals are not special-cased
17
+ // (a target token inside one is implausible) and template interpolations are
18
+ // treated as opaque string content. One intentional consequence: a token whose
19
+ // match would only complete by treating an interleaved comment as whitespace
20
+ // (e.g. `createRouter /* x */ (`) is not detected — real calls never interleave
21
+ // a comment between the callee and its arguments.
22
+
23
+ // JS line terminators end a `//` comment: LF, CR, LS (U+2028), PS (U+2029).
24
+ function isLineTerminator(ch: string): boolean {
25
+ const c = ch.charCodeAt(0);
26
+ // LF, CR, LS (U+2028), PS (U+2029)
27
+ return c === 10 || c === 13 || c === 0x2028 || c === 0x2029;
28
+ }
29
+
30
+ /**
31
+ * Build a classifier that answers "is offset `q` in code (not a comment or
32
+ * string)?" for STRICTLY INCREASING `q`. The internal cursor only moves forward,
33
+ * so a full left-to-right sequence of queries costs O(n) total with O(1) memory.
34
+ */
35
+ function makeCodeClassifier(code: string): (q: number) => boolean {
36
+ const n = code.length;
37
+ let i = 0; // forward cursor: everything before `i` is already classified
38
+ let skipStart = -1; // last detected comment/string region (cache)
39
+ let skipEnd = -1;
40
+
41
+ return (q: number): boolean => {
42
+ if (q >= skipStart && q < skipEnd) return false; // q in the cached region
43
+ while (i < n && i <= q) {
44
+ const c = code[i];
45
+ const d = i + 1 < n ? code[i + 1] : "";
46
+ let end = -1;
47
+ if (c === "/" && d === "/") {
48
+ let j = i + 2;
49
+ while (j < n && !isLineTerminator(code[j])) j++;
50
+ end = j;
51
+ } else if (c === "/" && d === "*") {
52
+ let j = i + 2;
53
+ while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
54
+ end = Math.min(n, j + 2);
55
+ } else if (c === '"' || c === "'" || c === "`") {
56
+ let j = i + 1;
57
+ while (j < n) {
58
+ if (code[j] === "\\") {
59
+ j += 2;
60
+ continue;
61
+ }
62
+ if (code[j] === c) {
63
+ j++;
64
+ break;
65
+ }
66
+ j++;
67
+ }
68
+ end = j;
69
+ }
70
+ if (end >= 0) {
71
+ // Comment/string region [i, end). `q >= i` here (loop condition).
72
+ if (q < end) {
73
+ skipStart = i;
74
+ skipEnd = end;
75
+ return false;
76
+ }
77
+ i = end;
78
+ } else {
79
+ i++;
80
+ }
81
+ }
82
+ return true; // reached q in code mode
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Index of the first match of `pattern` that occurs in code (not in a comment
88
+ * or string), or -1. `pattern` MUST be a global (`/g`) regex. Single native
89
+ * regex sweep with early-exit; O(1) extra memory.
90
+ */
91
+ export function firstCodeMatchIndex(code: string, pattern: RegExp): number {
92
+ const inCode = makeCodeClassifier(code);
93
+ pattern.lastIndex = 0;
94
+ let m: RegExpExecArray | null;
95
+ while ((m = pattern.exec(code)) !== null) {
96
+ if (inCode(m.index)) return m.index;
97
+ if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
98
+ }
99
+ return -1;
100
+ }
101
+
102
+ /**
103
+ * Byte offsets of every match of `pattern` that occurs in code (not in a
104
+ * comment or string). `pattern` MUST be a global (`/g`) regex. Each offset is
105
+ * the match start — the same byte offset a raw `pattern.exec` reports. O(n)
106
+ * time, O(#matches) memory.
107
+ */
108
+ export function codeMatchIndices(code: string, pattern: RegExp): number[] {
109
+ const inCode = makeCodeClassifier(code);
110
+ const indices: number[] = [];
111
+ pattern.lastIndex = 0;
112
+ let m: RegExpExecArray | null;
113
+ while ((m = pattern.exec(code)) !== null) {
114
+ if (inCode(m.index)) indices.push(m.index);
115
+ if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
116
+ }
117
+ return indices;
118
+ }
@@ -187,6 +187,32 @@ export class CacheScope {
187
187
  return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
188
188
  }
189
189
 
190
+ /**
191
+ * Evaluate the cache `condition` predicate. Returns false (skip the cache
192
+ * operation) when the predicate returns false or throws; returns true when
193
+ * there is no condition or no request context to evaluate it against.
194
+ */
195
+ private conditionAllows(op: "read" | "write"): boolean {
196
+ if (this.config === false || !this.config.condition) return true;
197
+ const requestCtx = getRequestContext();
198
+ if (!requestCtx) return true;
199
+ try {
200
+ if (!this.config.condition(requestCtx)) {
201
+ debugCacheLog(
202
+ `[CacheScope] condition returned false, skipping cache ${op}`,
203
+ );
204
+ return false;
205
+ }
206
+ return true;
207
+ } catch (error) {
208
+ console.error(
209
+ `[CacheScope] condition function threw, skipping cache ${op}:`,
210
+ error,
211
+ );
212
+ return false;
213
+ }
214
+ }
215
+
190
216
  /**
191
217
  * Lookup cached segments for a route (single cache entry per request).
192
218
  * Returns { segments, shouldRevalidate } or null if cache miss.
@@ -204,27 +230,7 @@ export class CacheScope {
204
230
  shouldRevalidate: boolean;
205
231
  } | null> {
206
232
  if (!this.enabled) return null;
207
-
208
- // Evaluate condition — skip cache read when condition returns false
209
- if (this.config !== false && this.config.condition) {
210
- const requestCtx = getRequestContext();
211
- if (requestCtx) {
212
- try {
213
- if (!this.config.condition(requestCtx)) {
214
- debugCacheLog(
215
- `[CacheScope] condition returned false, skipping cache read`,
216
- );
217
- return null;
218
- }
219
- } catch (error) {
220
- console.error(
221
- `[CacheScope] condition function threw, skipping cache read:`,
222
- error,
223
- );
224
- return null;
225
- }
226
- }
227
- }
233
+ if (!this.conditionAllows("read")) return null;
228
234
 
229
235
  const store = this.getStore();
230
236
  if (!store) return null;
@@ -284,27 +290,7 @@ export class CacheScope {
284
290
  isIntercept?: boolean,
285
291
  ): Promise<void> {
286
292
  if (!this.enabled || segments.length === 0) return;
287
-
288
- // Evaluate condition — skip cache write when condition returns false
289
- if (this.config !== false && this.config.condition) {
290
- const conditionCtx = getRequestContext();
291
- if (conditionCtx) {
292
- try {
293
- if (!this.config.condition(conditionCtx)) {
294
- debugCacheLog(
295
- `[CacheScope] condition returned false, skipping cache write`,
296
- );
297
- return;
298
- }
299
- } catch (error) {
300
- console.error(
301
- `[CacheScope] condition function threw, skipping cache write:`,
302
- error,
303
- );
304
- return;
305
- }
306
- }
307
- }
293
+ if (!this.conditionAllows("write")) return;
308
294
 
309
295
  const store = this.getStore();
310
296
  if (!store) return;