@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.
- package/dist/bin/rango.js +73 -2
- package/dist/vite/index.js +193 -19
- package/package.json +18 -17
- package/skills/hooks/SKILL.md +3 -3
- package/skills/links/SKILL.md +10 -10
- package/skills/rango/SKILL.md +1 -0
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/handle.ts +3 -5
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +2 -5
- package/src/missing-id-error.ts +68 -0
- package/src/reverse.ts +16 -13
- package/src/route-definition/dsl-helpers.ts +5 -2
- package/src/route-definition/helpers-types.ts +31 -19
- package/src/router/router-options.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +17 -4
- package/src/router/segment-resolution/revalidation.ts +17 -4
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +2 -0
- package/src/segment-system.tsx +18 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/vite/debug.ts +1 -0
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/rango.ts +12 -0
- package/src/vite/router-discovery.ts +14 -2
- package/src/vite/utils/client-chunks.ts +156 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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("
|
|
54
|
-
* <Link to={reverse("
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|