@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.
- package/dist/bin/rango.js +74 -3
- package/dist/vite/index.js +133 -18
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +35 -24
- package/skills/caching/SKILL.md +115 -7
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/hooks/SKILL.md +40 -22
- package/skills/links/SKILL.md +10 -10
- package/skills/loader/SKILL.md +3 -3
- package/skills/rango/SKILL.md +16 -10
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/location-state-shared.ts +93 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/per-module-writer.ts +4 -1
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/handle.ts +3 -5
- package/src/loader-store.ts +62 -25
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- 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 -10
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- 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 +59 -10
- package/src/server/context.ts +26 -0
- package/src/server/cookie-store.ts +28 -4
- package/src/types/handler-context.ts +5 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/use-loader.tsx +89 -42
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/plugins/use-cache-transform.ts +12 -10
- 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
|
|
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` --
|
|
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()`
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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<
|
|
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<
|
|
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
|
-
*
|
|
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);
|
|
@@ -97,7 +97,10 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
|
|
|
97
97
|
routes = extractRoutesFromSource(source);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
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
|
-
|
|
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/cache/cache-scope.ts
CHANGED
|
@@ -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;
|