@rangojs/router 0.0.0-experimental.113 → 0.0.0-experimental.115

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 (36) hide show
  1. package/dist/bin/rango.js +73 -2
  2. package/dist/vite/index.js +193 -19
  3. package/package.json +18 -17
  4. package/skills/hooks/SKILL.md +3 -3
  5. package/skills/links/SKILL.md +10 -10
  6. package/skills/rango/SKILL.md +1 -0
  7. package/skills/react-compiler/SKILL.md +168 -0
  8. package/skills/view-transitions/SKILL.md +85 -3
  9. package/src/browser/react/use-reverse.ts +19 -12
  10. package/src/build/route-types/router-processing.ts +14 -1
  11. package/src/build/route-types/source-scan.ts +118 -0
  12. package/src/handle.ts +3 -5
  13. package/src/loader.rsc.ts +2 -5
  14. package/src/loader.ts +2 -5
  15. package/src/missing-id-error.ts +68 -0
  16. package/src/reverse.ts +16 -13
  17. package/src/route-definition/dsl-helpers.ts +5 -2
  18. package/src/route-definition/helpers-types.ts +31 -19
  19. package/src/router/router-options.ts +24 -0
  20. package/src/router/segment-resolution/fresh.ts +17 -4
  21. package/src/router/segment-resolution/revalidation.ts +17 -4
  22. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  23. package/src/router/types.ts +8 -0
  24. package/src/router.ts +2 -0
  25. package/src/segment-system.tsx +18 -2
  26. package/src/types/segments.ts +18 -1
  27. package/src/urls/path-helper-types.ts +9 -1
  28. package/src/vite/debug.ts +1 -0
  29. package/src/vite/index.ts +2 -0
  30. package/src/vite/plugin-types.ts +67 -0
  31. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  32. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  33. package/src/vite/rango.ts +12 -0
  34. package/src/vite/router-discovery.ts +14 -2
  35. package/src/vite/utils/client-chunks.ts +156 -0
  36. package/src/vite/utils/shared-utils.ts +10 -3
@@ -0,0 +1,168 @@
1
+ ---
2
+ name: react-compiler
3
+ description: Enable the React Compiler in a Rango app the @vitejs/plugin-rsc way — a separate @rolldown/plugin-babel running reactCompilerPreset(), ordered after react() and before the plugin that supplies @vitejs/plugin-rsc. Use when a consumer wants to turn React Compiler on, hits the dead plugin-react v6 `react({ babel })` path, or is unsure why server components aren't being compiled.
4
+ argument-hint:
5
+ ---
6
+
7
+ # React Compiler
8
+
9
+ React Compiler is **opt-in** in Rango. The plugin pipeline is fully compatible —
10
+ you just add one more plugin. The catch on a current Rango stack (Vite 8 +
11
+ `@vitejs/plugin-react` v6) is that **v6 dropped its internal Babel for oxc**, so
12
+ the way the React docs and most blog posts show it — `react({ babel: { plugins:
13
+ [...] } })` — silently does nothing. The compiler has to be its own top-level
14
+ plugin.
15
+
16
+ ## The shape (read first)
17
+
18
+ - The compiler is a **Babel** plugin, run via
19
+ [`@rolldown/plugin-babel`](https://www.npmjs.com/package/@rolldown/plugin-babel)
20
+ with `reactCompilerPreset()` from `@vitejs/plugin-react`.
21
+ - **Ordering is load-bearing:** put `babel(...)` **after `react()`** and
22
+ **before the plugin that supplies `@vitejs/plugin-rsc`**. In a default Rango
23
+ app that plugin is `rango()` itself; in a Cloudflare app it is
24
+ `@cloudflare/vite-plugin`.
25
+ - **It is client-only.** `reactCompilerPreset()` gates itself to the client
26
+ environment. Server/RSC components are not compiled, and that is the upstream
27
+ example's behavior — not a Rango limitation. See
28
+ [What gets compiled](#what-gets-compiled-client-only).
29
+ - **Rango's build-time prerender is unaffected.** You do not need to do anything
30
+ special. See [Prerender](#interaction-with-build-time-prerender).
31
+
32
+ ## Step 1: Install
33
+
34
+ ```bash
35
+ pnpm add -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler
36
+ # TypeScript users also want the Babel core types:
37
+ pnpm add -D @types/babel__core
38
+ ```
39
+
40
+ React 19 ships `react/compiler-runtime` in-tree, so there is **no** extra runtime
41
+ to install and **no** `target` option to set. Only pass `target: '17' | '18'` to
42
+ `reactCompilerPreset()` if you are on an older React.
43
+
44
+ ## Step 2: Wire it in
45
+
46
+ ### Default (non-Cloudflare) app
47
+
48
+ ```ts
49
+ // vite.config.ts
50
+ import { defineConfig } from "vite";
51
+ import react, { reactCompilerPreset } from "@vitejs/plugin-react";
52
+ import babel from "@rolldown/plugin-babel";
53
+ import { rango } from "@rangojs/router/vite";
54
+
55
+ export default defineConfig({
56
+ plugins: [
57
+ react(),
58
+ babel({ presets: [reactCompilerPreset()] }),
59
+ rango(), // supplies @vitejs/plugin-rsc
60
+ ],
61
+ });
62
+ ```
63
+
64
+ ### Cloudflare app
65
+
66
+ ```ts
67
+ // vite.config.ts
68
+ import { cloudflare } from "@cloudflare/vite-plugin";
69
+ import react, { reactCompilerPreset } from "@vitejs/plugin-react";
70
+ import babel from "@rolldown/plugin-babel";
71
+ import { defineConfig } from "vite";
72
+ import { rango } from "@rangojs/router/vite";
73
+
74
+ export default defineConfig({
75
+ plugins: [
76
+ react(),
77
+ babel({ presets: [reactCompilerPreset()] }),
78
+ rango({ preset: "cloudflare" }),
79
+ cloudflare({
80
+ /* ... */
81
+ }), // supplies @vitejs/plugin-rsc
82
+ ],
83
+ });
84
+ ```
85
+
86
+ ## What gets compiled (client-only)
87
+
88
+ `reactCompilerPreset()` carries
89
+ `rolldown.applyToEnvironmentHook: (env) => env.config.consumer === "client"`, so
90
+ even though the babel plugin is top-level, the transform runs **only in the
91
+ `client` environment**:
92
+
93
+ | Environment | `consumer` | Compiled? |
94
+ | ----------- | ---------- | --------- |
95
+ | client | `client` | Yes |
96
+ | ssr | `server` | No |
97
+ | rsc | `server` | No |
98
+
99
+ This matches the upstream `@vitejs/plugin-rsc` example. If you genuinely need to
100
+ compile **server** components, you would have to invoke
101
+ `babel-plugin-react-compiler` yourself without the preset's
102
+ `applyToEnvironmentHook` — that is outside what the example does and is not
103
+ covered here.
104
+
105
+ ## Options
106
+
107
+ `reactCompilerPreset()` forwards to `babel-plugin-react-compiler`:
108
+
109
+ | Option | Effect |
110
+ | ------------------------------- | -------------------------------------------------------------------------------------- |
111
+ | `compilationMode: 'annotation'` | Compile only components marked with the `"use memo"` directive, not every eligible one |
112
+ | `target: '17' \| '18'` | Emit `react-compiler-runtime` calls for React < 19. Omit on React 19+. |
113
+
114
+ ## Interaction with build-time prerender
115
+
116
+ Nothing to configure. Rango's discovery/prerender step runs a throwaway temp Vite
117
+ server (`createTempRscServer`) that forwards only your **resolution** plugins
118
+ (`resolveId` / `load`). A pure transform plugin like `@rolldown/plugin-babel` is
119
+ intentionally **not** forwarded — and that is correct: the temp runner only
120
+ produces **data** (serialized Flight payloads + the route manifest), not shipped
121
+ code, and React Compiler is a memoization-only transform that does not change
122
+ rendered output. Your shipped client bundle still gets compiled, because the
123
+ babel plugin lives in your app's top-level plugin array alongside `react()`.
124
+
125
+ ## Step 3: Verify the compiler actually ran
126
+
127
+ A compiled module imports the cache allocator from `react/compiler-runtime` and
128
+ calls `_c(n)`. Those two appear in **every** compiled module, so they are the
129
+ reliable per-module signal in dev:
130
+
131
+ ```bash
132
+ pnpm dev
133
+ # fetch any client component module straight from Vite and look for the markers:
134
+ curl -s "http://localhost:5173/src/components/SomeClientComponent.tsx" \
135
+ | grep -E "compiler-runtime|_c\("
136
+ ```
137
+
138
+ For a production build, grep the built client bundle for the compiler's
139
+ input-independent cache check, which has a **zero baseline** without the compiler:
140
+
141
+ ```bash
142
+ pnpm build
143
+ grep -r "Symbol.for(\"react.memo_cache_sentinel\")" dist/client/assets/ | head
144
+ ```
145
+
146
+ Note the **comparison** form `$[i] === Symbol.for("react.memo_cache_sentinel")`
147
+ is only emitted for components with input-independent JSX, so it is reliable over
148
+ the **whole** client bundle, not necessarily in one chosen module. (React core
149
+ also defines that symbol once with a single `=` assignment, so count comparisons,
150
+ not the bare string.) Run the same grep over `dist/rsc` / `dist/ssr` and you
151
+ should find **none** — that is the client-only contract.
152
+
153
+ ## Troubleshooting
154
+
155
+ | Symptom | Cause / fix |
156
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | Nothing is compiled; no `compiler-runtime` import anywhere | You used `react({ babel: { plugins: [...] } })`. plugin-react v6 has no internal Babel — add `@rolldown/plugin-babel` as its own plugin. |
158
+ | Client compiled, but server/RSC components are not | Expected. `reactCompilerPreset()` is client-only (see the table). Not a bug. |
159
+ | `Cannot find module 'babel-plugin-react-compiler'` (or `@babel/core`) | Install the peer deps from Step 1; they are not bundled by `reactCompilerPreset()`. |
160
+ | Build pulls in `react-compiler-runtime` | You set `target: '17'`/`'18'` on React 19. Drop `target` — React 19 ships `react/compiler-runtime` in-tree. |
161
+ | Output looks compiled but a component misbehaves | The component likely breaks the Rules of React. Fix the component, or scope the compiler with `compilationMode: 'annotation'` while you do. |
162
+
163
+ ## Reference
164
+
165
+ A worked, tested wiring (dev + production e2e markers, incl. the client-only
166
+ contract) lives in the `@rangojs/router` repo: `docs/react-compiler.md` and the
167
+ `react-compiler.test.ts` files under `e2e/e2e-basic`, `tests/cloudflare-basic`,
168
+ and `tests/vite-rsc-demo`.
@@ -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
 
@@ -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);
@@ -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
+ }
package/src/handle.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { missingInjectedIdError } from "./missing-id-error.js";
2
+
1
3
  /**
2
4
  * Handle definition for accumulating data across route segments.
3
5
  *
@@ -96,11 +98,7 @@ export function createHandle<TData, TAccumulated = TData[]>(
96
98
  const handleId = __injectedId ?? "";
97
99
 
98
100
  if (!handleId && process.env.NODE_ENV === "development") {
99
- throw new Error(
100
- "[rango] Handle is missing $$id. " +
101
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
102
- "the handle is exported with: export const MyHandle = createHandle(...)",
103
- );
101
+ throw missingInjectedIdError("Handle", "createHandle");
104
102
  }
105
103
 
106
104
  const collectFn =
package/src/loader.rsc.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  registerFetchableLoader,
22
22
  getFetchableLoader,
23
23
  } from "./server/fetchable-loader-store.js";
24
+ import { missingInjectedIdError } from "./missing-id-error.js";
24
25
 
25
26
  export { getFetchableLoader };
26
27
 
@@ -53,11 +54,7 @@ export function createLoader<T>(
53
54
  const loaderId = __injectedId || "";
54
55
 
55
56
  if (!loaderId && process.env.NODE_ENV === "development") {
56
- throw new Error(
57
- "[rango] Loader is missing $$id. " +
58
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
59
- "the loader is exported with: export const MyLoader = createLoader(...)",
60
- );
57
+ throw missingInjectedIdError("Loader", "createLoader");
61
58
  }
62
59
 
63
60
  // If not fetchable, store fn in registry (for SSR ctx.use() resolution)
package/src/loader.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  LoaderDefinition,
19
19
  LoaderFn,
20
20
  } from "./types.js";
21
+ import { missingInjectedIdError } from "./missing-id-error.js";
21
22
 
22
23
  // Overload 1: With function only (not fetchable)
23
24
  export function createLoader<T>(
@@ -46,11 +47,7 @@ export function createLoader<T>(
46
47
  const loaderId = __injectedId || "";
47
48
 
48
49
  if (!loaderId && process.env.NODE_ENV === "development") {
49
- throw new Error(
50
- "[rango] Loader is missing $$id. " +
51
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
52
- "the loader is exported with: export const MyLoader = createLoader(...)",
53
- );
50
+ throw missingInjectedIdError("Loader", "createLoader");
54
51
  }
55
52
 
56
53
  return {
@@ -0,0 +1,68 @@
1
+ // Builds the error thrown when a create*() call (createLoader / createHandle)
2
+ // reaches runtime without an injected $$id. The exposeInternalIds Vite transform
3
+ // injects $$id only for an EXPORTED const declaration, so a non-exported const,
4
+ // an `export let/var`, or an inline create*() call gets none. Previously this
5
+ // failed with a terse message and no source location; this helper adds the
6
+ // offending call site (best-effort, from the stack) and actionable guidance.
7
+ //
8
+ // The "<Kind> is missing $$id" prefix is preserved so existing tests and any
9
+ // log scrapers keep matching. Dev-only: the call sites guard on
10
+ // process.env.NODE_ENV === "development", so production builds fold the branch
11
+ // away and tree-shake this module out.
12
+
13
+ // create*() implementation files to skip when locating the user's call site.
14
+ const SELF_FILES = new Set([
15
+ "missing-id-error",
16
+ "loader",
17
+ "loader.rsc",
18
+ "handle",
19
+ ]);
20
+
21
+ /**
22
+ * Best-effort "path:line:column" of the user's create*() call, parsed from the
23
+ * current stack. Skips @rangojs/router internals and node_modules. Returns
24
+ * undefined if nothing usable is found (stack parsing is inherently fragile).
25
+ */
26
+ function findUserCallSite(): string | undefined {
27
+ try {
28
+ const stack = new Error().stack;
29
+ if (!stack) return undefined;
30
+ for (const frame of stack.split("\n").slice(1)) {
31
+ const m = frame.match(
32
+ /(?:\(|@|\s)(?:file:\/\/)?((?:\/|[A-Za-z]:[\\/])[^()\s]+?\.(?:ts|tsx|js|jsx|mts|cts)):(\d+):(\d+)\)?/,
33
+ );
34
+ if (!m) continue;
35
+ const path = m[1];
36
+ if (path.includes("node_modules") || path.includes("@rangojs/router")) {
37
+ continue;
38
+ }
39
+ const base = path
40
+ .split(/[\\/]/)
41
+ .pop()!
42
+ .replace(/\.(?:ts|tsx|js|jsx|mts|cts)$/, "");
43
+ if (SELF_FILES.has(base)) continue;
44
+ return `${path}:${m[2]}:${m[3]}`;
45
+ }
46
+ } catch {
47
+ // best-effort only
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ export function missingInjectedIdError(
53
+ kind: "Loader" | "Handle",
54
+ fnName: "createLoader" | "createHandle",
55
+ ): Error {
56
+ const site = findUserCallSite();
57
+ const at = site ? ` (created at ${site})` : "";
58
+ return new Error(
59
+ `[rango] ${kind} is missing $$id${at}.\n` +
60
+ `The @rangojs/router:expose-internal-ids Vite transform injects ${fnName}()'s ` +
61
+ `stable $$id from an EXPORTED const declaration only:\n` +
62
+ ` export const X = ${fnName}(...)\n` +
63
+ ` const X = ${fnName}(...); export { X }\n` +
64
+ `A non-exported const, an \`export let/var\`, or an inline ${fnName}(...) ` +
65
+ `call gets no $$id — export it as \`export const\`. (A matching ` +
66
+ `"Unsupported ${fnName} shape" warning names the exact file:line.)`,
67
+ );
68
+ }