@rangojs/router 0.0.0-experimental.99 → 0.0.0-experimental.9b7a7784
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/README.md +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/hooks/SKILL.md +22 -1
- package/skills/links/SKILL.md +160 -16
- package/src/browser/navigation-bridge.ts +59 -3
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/scroll-restoration.ts +23 -29
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +5 -1
- package/src/href-client.ts +4 -1
- package/src/reverse.ts +62 -41
- package/src/router/handler-context.ts +4 -42
- package/src/router/substitute-pattern-params.ts +56 -0
package/README.md
CHANGED
|
@@ -602,7 +602,7 @@ export function Nav({ home, post }: { home: string; post: string }) {
|
|
|
602
602
|
}
|
|
603
603
|
```
|
|
604
604
|
|
|
605
|
-
For client-side navigation to static paths (no named-route lookup), use `href()` — see below. For URLs tied to named routes,
|
|
605
|
+
For client-side navigation to static paths (no named-route lookup), use `href()` — see below. For URLs tied to named routes, you have two options: import the per-module generated `routes` map and use `useReverse(routes)` for in-module names (see [`/links` skill](./skills/links/SKILL.md)), or generate the URL on the server and pass the string in for cross-module URLs.
|
|
606
606
|
|
|
607
607
|
### `href()` for Path Validation (Client Components)
|
|
608
608
|
|
package/dist/vite/index.js
CHANGED
|
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
|
|
|
2040
2040
|
// package.json
|
|
2041
2041
|
var package_default = {
|
|
2042
2042
|
name: "@rangojs/router",
|
|
2043
|
-
version: "0.0.0-experimental.
|
|
2043
|
+
version: "0.0.0-experimental.9b7a7784",
|
|
2044
2044
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2045
2045
|
keywords: [
|
|
2046
2046
|
"react",
|
package/package.json
CHANGED
package/skills/hooks/SKILL.md
CHANGED
|
@@ -694,7 +694,27 @@ function MountInfo() {
|
|
|
694
694
|
}
|
|
695
695
|
```
|
|
696
696
|
|
|
697
|
-
|
|
697
|
+
### useReverse(routes)
|
|
698
|
+
|
|
699
|
+
Mount-aware local reverse for client components. Import the generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse(".name", params?)`. Auto-fills params from `useParams()`; explicit params override.
|
|
700
|
+
|
|
701
|
+
```tsx
|
|
702
|
+
"use client";
|
|
703
|
+
import { Link, useReverse } from "@rangojs/router/client";
|
|
704
|
+
import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
705
|
+
|
|
706
|
+
function BlogNav() {
|
|
707
|
+
const reverse = useReverse(blogRoutes);
|
|
708
|
+
return (
|
|
709
|
+
<nav>
|
|
710
|
+
<Link to={reverse(".index")}>Blog</Link>
|
|
711
|
+
<Link to={reverse(".post", { postId: "hello" })}>Post</Link>
|
|
712
|
+
</nav>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
See `/links` for the full URL generation guide. `ctx.reverse()` is server-only; on the client, prefer `useReverse(routes)` for in-module names and pass URLs as props for cross-module ones.
|
|
698
718
|
|
|
699
719
|
## Hook Summary
|
|
700
720
|
|
|
@@ -705,6 +725,7 @@ See `/links` for full URL generation guide. The default server API is `ctx.rever
|
|
|
705
725
|
| `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
|
|
706
726
|
| `useHref()` | Mount-aware href | `(path) => string` |
|
|
707
727
|
| `useMount()` | Current include() mount path | `string` |
|
|
728
|
+
| `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
|
|
708
729
|
| `useNavigation()` | Reactive navigation state | state, location, isStreaming |
|
|
709
730
|
| `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
|
|
710
731
|
| `useSegments()` | URL path & segment IDs | path, segmentIds, location |
|
package/skills/links/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: links
|
|
3
|
-
description: URL generation with ctx.reverse (server default), href (client), useHref (mounted), useMount, and scopedReverse
|
|
4
|
-
argument-hint: [ctx.reverse|href|useHref|useMount|scopedReverse]
|
|
3
|
+
description: URL generation with ctx.reverse (server default), href (client), useHref (mounted), useMount, useReverse, and scopedReverse
|
|
4
|
+
argument-hint: [ctx.reverse|href|useHref|useMount|useReverse|scopedReverse]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Links & URL Generation
|
|
@@ -10,7 +10,12 @@ argument-hint: [ctx.reverse|href|useHref|useMount|scopedReverse]
|
|
|
10
10
|
|
|
11
11
|
**Default server API: `ctx.reverse()`.** Generate URLs from the handler context — it's typed, auto-fills mount params, and resolves local (`.name`) and absolute (`name.sub`) names.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**On the client, two patterns:**
|
|
14
|
+
|
|
15
|
+
1. **Receive URLs as props / loader data / action return.** The default. The server has the full route manifest and handler context — generate URLs there and hand strings to client components.
|
|
16
|
+
2. **`useReverse(routes)`.** Import a generated `routes` map from a `urls()` module's `.gen.ts` and call `reverse(".name", params?)`. Mount-aware via `useMount()`, auto-fills params from `useParams()`, fully typed from the imported map. Use this when a client component needs to generate URLs into a known module without round-tripping through the server.
|
|
17
|
+
|
|
18
|
+
`ctx.reverse()` itself is **server-only** — it depends on the full route manifest and handler context. Client components never import or call it.
|
|
14
19
|
|
|
15
20
|
## Server: ctx.reverse()
|
|
16
21
|
|
|
@@ -127,9 +132,7 @@ path("/product/:slug", (ctx) => {
|
|
|
127
132
|
|
|
128
133
|
## Client components: receive URLs as props
|
|
129
134
|
|
|
130
|
-
`reverse()` is not available inside `"use client"` modules — there is no handler context
|
|
131
|
-
|
|
132
|
-
Three patterns, in order of preference:
|
|
135
|
+
`ctx.reverse()` is not available inside `"use client"` modules — there is no handler context in the browser bundle. For in-module names, prefer `useReverse(routes)` (see below) and import the relevant `urls/*.gen.js`. For cross-module URLs or one-off names, generate the URL on the server and hand it to the client component using one of these three patterns:
|
|
133
136
|
|
|
134
137
|
1. Pass as a prop from a server component:
|
|
135
138
|
|
|
@@ -256,18 +259,159 @@ function MountInfo() {
|
|
|
256
259
|
|
|
257
260
|
`useMount()` reads from `MountContext`, which is automatically set by `include()` in the segment tree.
|
|
258
261
|
|
|
259
|
-
##
|
|
262
|
+
## Client: useReverse(routes)
|
|
263
|
+
|
|
264
|
+
Hook that returns a typed local reverse function for a `routes` map imported from a generated `.gen.ts` next to a `urls()` module. The route map is the **exposure boundary** — `useReverse` only knows about names in that map, never the full app manifest.
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
"use client";
|
|
268
|
+
import { Link, useReverse } from "@rangojs/router/client";
|
|
269
|
+
import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
270
|
+
|
|
271
|
+
export function BlogNav() {
|
|
272
|
+
const reverse = useReverse(blogRoutes);
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<nav>
|
|
276
|
+
<Link to={reverse(".index")}>Blog</Link>
|
|
277
|
+
<Link to={reverse(".post", { postId: "hello" })}>Post</Link>
|
|
278
|
+
</nav>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### How it resolves
|
|
284
|
+
|
|
285
|
+
1. Strips the leading `.` and looks up the name in the imported `routes` map.
|
|
286
|
+
2. Joins the local pattern with the surrounding `useMount()` value — the include's URL pattern.
|
|
287
|
+
3. Substitutes params: explicit params from the call, then auto-filled from `useParams()` for anything still unresolved (mount params like `:tenantId` flow in this way).
|
|
288
|
+
4. Appends a query string if a search object is passed and the route has a `search` schema.
|
|
289
|
+
|
|
290
|
+
### Mount-relativity
|
|
291
|
+
|
|
292
|
+
Patterns in the generated `routes` map are **mount-relative** — they're the patterns as defined inside the `urls()` module, _not_ the full app paths. Mount-joining happens at runtime via `useMount()`, so the same component works under any include:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// urls/blog.tsx
|
|
296
|
+
export const blogPatterns = urls(({ path }) => [
|
|
297
|
+
path("/", BlogIndex, { name: "index" }),
|
|
298
|
+
path("/:postId", BlogPost, { name: "post" }),
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
// Generated urls/blog.gen.ts
|
|
302
|
+
// export const routes = { index: "/", post: "/:postId" } as const;
|
|
303
|
+
|
|
304
|
+
// urls.tsx — same module mounted twice
|
|
305
|
+
include("/news", blogPatterns, { name: "news" }), // <BlogNav> renders /news, /news/hello
|
|
306
|
+
include("/journal", blogPatterns, { name: "diary" }), // <BlogNav> renders /journal, /journal/hello
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The `/` pattern under a non-root mount collapses cleanly: under `/news`, `reverse(".index")` returns `/news` (no trailing slash), matching `ctx.reverse(".index")` on the server.
|
|
310
|
+
|
|
311
|
+
### Auto-filled params (mount params)
|
|
312
|
+
|
|
313
|
+
When the include itself carries `:params`, those are auto-filled from `useParams()` so the caller doesn't have to thread them through:
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// urls.tsx
|
|
317
|
+
include("/tenant/:tenantId", clientReversePatterns, { name: "tenant" });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
// At /tenant/acme/posts/p1, useParams() = { tenantId: "acme", postId: "p1" }
|
|
322
|
+
const reverse = useReverse(clientReverseRoutes);
|
|
323
|
+
|
|
324
|
+
reverse(".index"); // "/tenant/acme"
|
|
325
|
+
reverse(".post", { postId: "p2" }); // "/tenant/acme/posts/p2" (tenantId auto-filled)
|
|
326
|
+
reverse(".post", { tenantId: "other", postId: "p2" }); // "/tenant/other/posts/p2" (explicit override)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Auto-fill follows soft navigation — when the matched route changes, `useReverse` re-renders with the new params.
|
|
330
|
+
|
|
331
|
+
### Search schemas
|
|
332
|
+
|
|
333
|
+
Routes declared with a `search` schema accept a typed search object as the third argument:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// urls/blog.tsx
|
|
337
|
+
path("/search", SearchPage, {
|
|
338
|
+
name: "search",
|
|
339
|
+
search: { q: "string", page: "number?" },
|
|
340
|
+
}),
|
|
341
|
+
|
|
342
|
+
// Generated as: search: { path: "/search", search: { q: "string", page: "number?" } }
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
const reverse = useReverse(blogRoutes);
|
|
347
|
+
reverse(".search", {}, { q: "hello world", page: 2 });
|
|
348
|
+
// "/news/search?q=hello%20world&page=2"
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Errors
|
|
352
|
+
|
|
353
|
+
- Unknown name: throws `Unknown local route: ".not-a-route"`.
|
|
354
|
+
- Missing required param: throws `Missing param "postId" for route ".detail"`.
|
|
355
|
+
|
|
356
|
+
Both happen synchronously during `reverse()` — wrap calls in try/catch (or an ErrorBoundary if the throw happens during render) when you need to surface them as UI.
|
|
357
|
+
|
|
358
|
+
### Names are dot-only on the client
|
|
359
|
+
|
|
360
|
+
`useReverse` accepts only `.name` (and dotted variants like `.nested.index`). There is no global namespace on the client — the import IS the scope. To link into a different module, import that module's `routes`:
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
364
|
+
import { routes as shopRoutes } from "../urls/shop.gen.js";
|
|
260
365
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
366
|
+
function CrossNav() {
|
|
367
|
+
const blog = useReverse(blogRoutes);
|
|
368
|
+
const shop = useReverse(shopRoutes);
|
|
369
|
+
return (
|
|
370
|
+
<nav>
|
|
371
|
+
<Link to={blog(".index")}>Blog</Link>
|
|
372
|
+
<Link to={shop(".cart")}>Cart</Link>
|
|
373
|
+
</nav>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Codegen
|
|
379
|
+
|
|
380
|
+
Each `urls()` module gets a sibling `.gen.ts` with the local route names and patterns, produced by `rango generate`:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
pnpm exec rango generate src/urls/blog.tsx
|
|
384
|
+
# or generate everything under a directory:
|
|
385
|
+
pnpm exec rango generate src/urls --static
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Don't edit the file by hand — re-run codegen when patterns change.
|
|
389
|
+
|
|
390
|
+
**Today the Vite plugin only regenerates the router-level `*.named-routes.gen.ts`.** Per-module `urls/*.gen.ts` files are emitted only by the CLI (or `writePerModuleRouteTypesForFile` programmatically). Commit the generated files and re-run `rango generate` whenever a `urls()` module's `path()`/`include()` shape changes. A common workflow is to wire it into a `predev` script:
|
|
391
|
+
|
|
392
|
+
```jsonc
|
|
393
|
+
// package.json
|
|
394
|
+
{
|
|
395
|
+
"scripts": {
|
|
396
|
+
"predev": "rango generate src",
|
|
397
|
+
"dev": "vite",
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## When to use what
|
|
269
403
|
|
|
270
|
-
|
|
404
|
+
| Context | API | Resolves | Use for |
|
|
405
|
+
| ---------------- | -------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
|
|
406
|
+
| Server handler | `ctx.reverse("name")` | Named routes (local + absolute) | **Default** server-side URL generation |
|
|
407
|
+
| Server handler | `scopedReverse<T>(ctx.reverse)` | Same, with type safety | Type-safe server URLs |
|
|
408
|
+
| Client component | `useReverse(routes)` | Local names from an imported `routes` map | Typed in-module URL generation without round-tripping the server |
|
|
409
|
+
| Client component | (URL passed as prop / loader data / action return) | Named routes | Cross-module URLs or one-off names you don't want to import |
|
|
410
|
+
| Client component | `href("/path")` | Absolute paths (static strings) | Static navigation where no named-route lookup is needed |
|
|
411
|
+
| Client component | `useHref()` | Mount-prefixed paths | Local navigation inside `include()` |
|
|
412
|
+
| Client component | `useMount()` | Raw mount path | Custom mount-aware logic |
|
|
413
|
+
|
|
414
|
+
> `ctx.reverse()` is server-only. On the client, either generate URLs on the server and pass them in, or import the `routes` map and use `useReverse(routes)` for in-module names.
|
|
271
415
|
|
|
272
416
|
## Complete example: mounted module
|
|
273
417
|
|
|
@@ -675,6 +675,53 @@ export function createNavigationBridge(
|
|
|
675
675
|
this.handlePopstate();
|
|
676
676
|
};
|
|
677
677
|
|
|
678
|
+
// React's experimental ViewTransition integration deliberately skips
|
|
679
|
+
// animations for commits originating from a popstate event handler —
|
|
680
|
+
// popstate must finish synchronously to preserve scroll/form
|
|
681
|
+
// restoration, which conflicts with running a transition. The
|
|
682
|
+
// Navigation API's `navigate` event runs in an async-safe context
|
|
683
|
+
// (`event.intercept({ handler })` lets us await), so commits made
|
|
684
|
+
// inside the intercept handler are NOT popstate-originated from
|
|
685
|
+
// React's perspective and the VT walker fires normally for
|
|
686
|
+
// back-/forward-navigations. Falls back to popstate on browsers
|
|
687
|
+
// without Navigation API support (Firefox today); on those browsers
|
|
688
|
+
// back-nav view transitions won't fire — matching the React
|
|
689
|
+
// limitation and current behavior.
|
|
690
|
+
// See https://react.dev/reference/react/ViewTransition.
|
|
691
|
+
const navigationApi: any = (window as any).navigation;
|
|
692
|
+
const supportsNavigationApi =
|
|
693
|
+
!!navigationApi && typeof navigationApi.addEventListener === "function";
|
|
694
|
+
|
|
695
|
+
const handleNavigateEvent = (event: any): void => {
|
|
696
|
+
// Only handle browser history traversal (back/forward).
|
|
697
|
+
// Push/replace are still driven by setupLinkInterception →
|
|
698
|
+
// this.navigate(...) (which calls history.pushState/replaceState).
|
|
699
|
+
if (event.navigationType !== "traverse") return;
|
|
700
|
+
if (!event.canIntercept) return;
|
|
701
|
+
// canIntercept doesn't exclude every cross-document case (e.g., back
|
|
702
|
+
// to a previous same-origin non-Rango document, or a doc-level
|
|
703
|
+
// history.go() target). Without this guard, event.intercept() would
|
|
704
|
+
// forcibly turn that into a same-document navigation and route it
|
|
705
|
+
// through handlePopstate — silently breaking the destination page.
|
|
706
|
+
if (!event.destination?.sameDocument) return;
|
|
707
|
+
if (event.hashChange || event.downloadRequest) return;
|
|
708
|
+
// Snapshot the destination's history.state BEFORE running our async
|
|
709
|
+
// handler. The Navigation API's intercept treats the intercepted
|
|
710
|
+
// navigation as a fresh same-document commit at flush time —
|
|
711
|
+
// which has the side effect of replacing the entry's state with
|
|
712
|
+
// null, clobbering the scroll-restoration `key` rango stamps onto
|
|
713
|
+
// each history entry. Restore the original state synchronously
|
|
714
|
+
// before the handler runs so that subsequent reads of
|
|
715
|
+
// window.history.state inside the handler (and inside React's
|
|
716
|
+
// useLayoutEffect that fires from the resulting commit) see the
|
|
717
|
+
// entry's pre-intercept state.
|
|
718
|
+
event.intercept({
|
|
719
|
+
handler: async () => {
|
|
720
|
+
await this.handlePopstate();
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
|
|
678
725
|
// When the browser restores a page from bfcache (back-forward cache),
|
|
679
726
|
// any in-flight navigation state is stale. This happens when:
|
|
680
727
|
// 1. A navigation triggers X-RSC-Reload (e.g., response route hit via SPA)
|
|
@@ -700,13 +747,22 @@ export function createNavigationBridge(
|
|
|
700
747
|
this.refresh();
|
|
701
748
|
});
|
|
702
749
|
|
|
703
|
-
|
|
750
|
+
if (supportsNavigationApi) {
|
|
751
|
+
navigationApi.addEventListener("navigate", handleNavigateEvent);
|
|
752
|
+
debugLog("[Browser] Navigation bridge ready (Navigation API)");
|
|
753
|
+
} else {
|
|
754
|
+
window.addEventListener("popstate", handlePopstate);
|
|
755
|
+
debugLog("[Browser] Navigation bridge ready (popstate fallback)");
|
|
756
|
+
}
|
|
704
757
|
window.addEventListener("pageshow", handlePageShow);
|
|
705
|
-
debugLog("[Browser] Navigation bridge ready");
|
|
706
758
|
|
|
707
759
|
return () => {
|
|
708
760
|
cleanupLinks();
|
|
709
|
-
|
|
761
|
+
if (supportsNavigationApi) {
|
|
762
|
+
navigationApi.removeEventListener("navigate", handleNavigateEvent);
|
|
763
|
+
} else {
|
|
764
|
+
window.removeEventListener("popstate", handlePopstate);
|
|
765
|
+
}
|
|
710
766
|
window.removeEventListener("pageshow", handlePageShow);
|
|
711
767
|
};
|
|
712
768
|
},
|
|
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
|
|
|
20
20
|
// Handle data hook
|
|
21
21
|
export { useHandle } from "./use-handle.js";
|
|
22
22
|
|
|
23
|
+
// Mount-aware reverse hook
|
|
24
|
+
export { useReverse } from "./use-reverse.js";
|
|
25
|
+
|
|
23
26
|
// Client cache controls hook
|
|
24
27
|
export {
|
|
25
28
|
useClientCache,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import type { LocalReverseFunction } from "../../reverse.js";
|
|
5
|
+
import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
|
|
6
|
+
import { serializeSearchParams } from "../../search-params.js";
|
|
7
|
+
import { useMount } from "./use-mount.js";
|
|
8
|
+
import { useParams } from "./use-params.js";
|
|
9
|
+
|
|
10
|
+
type RouteEntry = string | { readonly path: string };
|
|
11
|
+
type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
|
|
12
|
+
|
|
13
|
+
function getPattern(entry: RouteEntry | undefined): string | undefined {
|
|
14
|
+
if (entry === undefined) return undefined;
|
|
15
|
+
return typeof entry === "string" ? entry : entry.path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Join an include mount prefix with a mount-relative pattern.
|
|
20
|
+
*
|
|
21
|
+
* `pattern === "/"` is the index of the local module — under a non-root
|
|
22
|
+
* mount it must collapse so `/` under `/blog` becomes `/blog`, not
|
|
23
|
+
* `/blog/`. This matches `ctx.reverse(".index")` on the server.
|
|
24
|
+
*/
|
|
25
|
+
function joinMount(mount: string, pattern: string): string {
|
|
26
|
+
if (pattern === "/") {
|
|
27
|
+
if (mount === "" || mount === "/") return "/";
|
|
28
|
+
return mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
29
|
+
}
|
|
30
|
+
const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
|
|
31
|
+
return normalizedMount + pattern;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount-aware reverse function for a locally-imported `routes` map.
|
|
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.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* "use client";
|
|
46
|
+
* import { Link, useReverse } from "@rangojs/router/client";
|
|
47
|
+
* import { routes as blogRoutes } from "../urls/blog.gen.js";
|
|
48
|
+
*
|
|
49
|
+
* function BlogNav() {
|
|
50
|
+
* const reverse = useReverse(blogRoutes);
|
|
51
|
+
* return (
|
|
52
|
+
* <>
|
|
53
|
+
* <Link to={reverse(".index")}>Blog</Link>
|
|
54
|
+
* <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
|
|
55
|
+
* </>
|
|
56
|
+
* );
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useReverse<const TRoutes extends LocalRouteMap>(
|
|
61
|
+
routes: TRoutes,
|
|
62
|
+
): LocalReverseFunction<TRoutes> {
|
|
63
|
+
const mount = useMount();
|
|
64
|
+
const currentParams = useParams();
|
|
65
|
+
|
|
66
|
+
return useCallback(
|
|
67
|
+
((
|
|
68
|
+
name: string,
|
|
69
|
+
explicitParams?: Record<string, string | undefined>,
|
|
70
|
+
search?: Record<string, unknown>,
|
|
71
|
+
): string => {
|
|
72
|
+
if (!name.startsWith(".")) {
|
|
73
|
+
throw new Error(`Local route names must start with ".": "${name}"`);
|
|
74
|
+
}
|
|
75
|
+
const lookupName = name.slice(1);
|
|
76
|
+
const entry = (routes as LocalRouteMap)[lookupName];
|
|
77
|
+
const pattern = getPattern(entry);
|
|
78
|
+
if (pattern === undefined) {
|
|
79
|
+
throw new Error(`Unknown local route: "${name}"`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const joined = joinMount(mount, pattern);
|
|
83
|
+
|
|
84
|
+
const mergedParams = explicitParams
|
|
85
|
+
? { ...currentParams, ...explicitParams }
|
|
86
|
+
: currentParams;
|
|
87
|
+
|
|
88
|
+
const substituted = substitutePatternParams(joined, mergedParams, name);
|
|
89
|
+
|
|
90
|
+
if (search) {
|
|
91
|
+
const qs = serializeSearchParams(search);
|
|
92
|
+
if (qs) return `${substituted}?${qs}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return substituted;
|
|
96
|
+
}) as LocalReverseFunction<TRoutes>,
|
|
97
|
+
[routes, mount, currentParams],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -10,15 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
import { debugLog } from "./logging.js";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* Defers a callback to the next animation frame.
|
|
15
|
-
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
-
*/
|
|
17
|
-
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
-
typeof requestAnimationFrame === "function"
|
|
19
|
-
? requestAnimationFrame
|
|
20
|
-
: (fn) => setTimeout(fn, 0);
|
|
21
|
-
|
|
22
13
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
23
14
|
|
|
24
15
|
/**
|
|
@@ -294,13 +285,19 @@ export function restoreScrollPosition(options?: {
|
|
|
294
285
|
return true;
|
|
295
286
|
}
|
|
296
287
|
|
|
297
|
-
// Not streaming — scroll
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
288
|
+
// Not streaming — scroll synchronously. handleNavigationEnd is invoked
|
|
289
|
+
// from NavigationProvider's useLayoutEffect, which runs after React's
|
|
290
|
+
// commit and before paint, so sync scrollTo is captured by the upcoming
|
|
291
|
+
// paint or the View Transition snapshot. Deferring to rAF here pushed
|
|
292
|
+
// the scrollTo past startViewTransition's snapshot capture, which made
|
|
293
|
+
// forward navigations skip scroll-to-top whenever a layout/route VT was
|
|
294
|
+
// active (the rAF callback ran during the animation, but the captured
|
|
295
|
+
// snapshot was taken at the pre-scroll position, leaving the live DOM
|
|
296
|
+
// visually clamped at the previous scroll).
|
|
297
|
+
if (typeof window.scrollTo === "function") {
|
|
301
298
|
window.scrollTo(0, savedY);
|
|
302
|
-
|
|
303
|
-
|
|
299
|
+
}
|
|
300
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
304
301
|
return true;
|
|
305
302
|
}
|
|
306
303
|
|
|
@@ -332,6 +329,8 @@ export function scrollToHash(): boolean {
|
|
|
332
329
|
* Scroll to top of page
|
|
333
330
|
*/
|
|
334
331
|
export function scrollToTop(): void {
|
|
332
|
+
if (typeof window === "undefined") return;
|
|
333
|
+
if (typeof window.scrollTo !== "function") return;
|
|
335
334
|
window.scrollTo(0, 0);
|
|
336
335
|
}
|
|
337
336
|
|
|
@@ -374,20 +373,15 @@ export function handleNavigationEnd(options: {
|
|
|
374
373
|
// Fall through to hash or top if no saved position
|
|
375
374
|
}
|
|
376
375
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Default: scroll to top
|
|
389
|
-
scrollToTop();
|
|
390
|
-
});
|
|
376
|
+
// Sync hash scroll / scroll-to-top — see the long comment in
|
|
377
|
+
// restoreScrollPosition above. handleNavigationEnd runs from
|
|
378
|
+
// NavigationProvider's useLayoutEffect (post-commit, pre-paint) and from
|
|
379
|
+
// bridge sites that don't trigger a React commit, so a synchronous
|
|
380
|
+
// scrollTo is captured by the next paint / startViewTransition snapshot.
|
|
381
|
+
if (scrollToHash()) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
scrollToTop();
|
|
391
385
|
}
|
|
392
386
|
|
|
393
387
|
/**
|
package/src/client.rsc.tsx
CHANGED
|
@@ -78,6 +78,9 @@ export {
|
|
|
78
78
|
// Re-export useHref - it's a "use client" hook
|
|
79
79
|
export { useHref } from "./browser/react/use-href.js";
|
|
80
80
|
|
|
81
|
+
// Re-export useReverse - it's a "use client" hook
|
|
82
|
+
export { useReverse } from "./browser/react/use-reverse.js";
|
|
83
|
+
|
|
81
84
|
// Re-export useHandle - it's a "use client" hook
|
|
82
85
|
export { useHandle } from "./browser/react/use-handle.js";
|
|
83
86
|
|
package/src/client.tsx
CHANGED
|
@@ -448,8 +448,12 @@ export { MountContext } from "./browser/react/mount-context.js";
|
|
|
448
448
|
// Mount-aware href hook - auto-prefixes paths with include() mount
|
|
449
449
|
export { useHref } from "./browser/react/use-href.js";
|
|
450
450
|
|
|
451
|
+
// Mount-aware reverse hook - resolves dot-prefixed names against an imported
|
|
452
|
+
// generated routes map (from a urls() module's .gen.ts).
|
|
453
|
+
export { useReverse } from "./browser/react/use-reverse.js";
|
|
454
|
+
|
|
451
455
|
// Type-safe scoped reverse function for scopedReverse<typeof patterns>()
|
|
452
|
-
export type { ScopedReverseFunction } from "./reverse.js";
|
|
456
|
+
export type { ScopedReverseFunction, LocalReverseFunction } from "./reverse.js";
|
|
453
457
|
|
|
454
458
|
// Loader definition type - for typing loader props in client components
|
|
455
459
|
export type { LoaderDefinition } from "./types.js";
|
package/src/href-client.ts
CHANGED
|
@@ -186,7 +186,10 @@ export function href<T extends ValidPaths>(path: T, mount?: string): string {
|
|
|
186
186
|
const normalizedMount = mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
187
187
|
return normalizedMount + path;
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
// ValidPaths is built from template literals so T does extend string at
|
|
190
|
+
// runtime, but the inference can fail past a certain route-union complexity
|
|
191
|
+
// and TypeScript reports T as not assignable to string.
|
|
192
|
+
return path as string;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
/**
|
package/src/reverse.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtractParams } from "./types.js";
|
|
2
2
|
import type { SearchSchema, ResolveSearchSchema } from "./search-params.js";
|
|
3
3
|
import { serializeSearchParams } from "./search-params.js";
|
|
4
|
-
import {
|
|
4
|
+
import { substitutePatternParams } from "./router/substitute-pattern-params.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Sanitize prefix string by removing leading slash
|
|
@@ -219,6 +219,64 @@ export type ExtractLocalRoutes<TPatterns> = TPatterns extends {
|
|
|
219
219
|
? TPatterns
|
|
220
220
|
: Record<string, string>;
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Params accepted by `useReverse(routes)`. The route's own params are
|
|
224
|
+
* required, and additional string keys are permitted so callers can
|
|
225
|
+
* override values that would otherwise be auto-filled from the matched
|
|
226
|
+
* route's `useParams()` (e.g. an enclosing `:tenantId` mount segment).
|
|
227
|
+
*/
|
|
228
|
+
export type LocalReverseParams<TPattern extends string> =
|
|
229
|
+
ExtractParams<TPattern> & {
|
|
230
|
+
readonly [extra: string]: string | undefined;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Type-safe local reverse function with dot-prefixed names only.
|
|
235
|
+
*
|
|
236
|
+
* Returned by `useReverse(routes)` on the client. The route map is the
|
|
237
|
+
* exposure boundary (a generated `routes` from a `urls()` module) and the
|
|
238
|
+
* scope is implicit from that import — there is no global namespace, so
|
|
239
|
+
* names must be dot-prefixed to mirror `ctx.reverse(".name")`.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```typescript
|
|
243
|
+
* const reverse = useReverse(blogRoutes);
|
|
244
|
+
* reverse(".index"); // ✓ no params
|
|
245
|
+
* reverse(".post", { postId: "hello" }); // ✓ with params
|
|
246
|
+
* reverse(".search", {}, { q: "hi" }); // ✓ with search schema
|
|
247
|
+
* reverse(".typo"); // ✗ compile error
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
export type LocalReverseFunction<TLocalRoutes> = {
|
|
251
|
+
/**
|
|
252
|
+
* Dot-prefixed local route without params
|
|
253
|
+
*/
|
|
254
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
255
|
+
name: IsEmptyObject<
|
|
256
|
+
ExtractParams<RoutePatternFor<TLocalRoutes, TName>>
|
|
257
|
+
> extends true
|
|
258
|
+
? `.${TName}`
|
|
259
|
+
: never,
|
|
260
|
+
): string;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Dot-prefixed local route with params
|
|
264
|
+
*/
|
|
265
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
266
|
+
name: `.${TName}`,
|
|
267
|
+
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
268
|
+
): string;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Dot-prefixed local route with params and search
|
|
272
|
+
*/
|
|
273
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
274
|
+
name: `.${TName}`,
|
|
275
|
+
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
276
|
+
search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
|
|
277
|
+
): string;
|
|
278
|
+
};
|
|
279
|
+
|
|
222
280
|
/**
|
|
223
281
|
* Extract the response data type for a named route from a UrlPatterns instance.
|
|
224
282
|
* Re-exported from urls.ts for consumer convenience.
|
|
@@ -302,46 +360,9 @@ export function createReverse<TRoutes extends Record<string, string>>(
|
|
|
302
360
|
throw new Error(`Unknown route: ${name}`);
|
|
303
361
|
}
|
|
304
362
|
|
|
305
|
-
let result =
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Strip constraint syntax: :param(a|b) -> use "param" as key
|
|
309
|
-
// Optional params (:param?) are omitted when not provided
|
|
310
|
-
let hadOmittedOptional = false;
|
|
311
|
-
result = result.replace(
|
|
312
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
313
|
-
(_, key, _constraint, optional) => {
|
|
314
|
-
const value = params[key];
|
|
315
|
-
// The matcher omits absent optional params (so `value` is
|
|
316
|
-
// `undefined` here), but caller-supplied params or `getParams()`
|
|
317
|
-
// shapes may still pass `""` explicitly. Treat both as the
|
|
318
|
-
// absent form so the segment collapses cleanly.
|
|
319
|
-
if (value === undefined || value === "") {
|
|
320
|
-
hadOmittedOptional = true;
|
|
321
|
-
return "";
|
|
322
|
-
}
|
|
323
|
-
return encodePathSegment(value);
|
|
324
|
-
},
|
|
325
|
-
);
|
|
326
|
-
// Second pass: required params (no trailing ?)
|
|
327
|
-
result = result.replace(
|
|
328
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
329
|
-
(_, key) => {
|
|
330
|
-
const value = params[key];
|
|
331
|
-
if (value === undefined) {
|
|
332
|
-
throw new Error(`Missing param "${key}" for route "${name}"`);
|
|
333
|
-
}
|
|
334
|
-
return encodePathSegment(value);
|
|
335
|
-
},
|
|
336
|
-
);
|
|
337
|
-
// Clean up slashes only when an optional param was actually omitted,
|
|
338
|
-
// so intentional trailing-slash patterns like "/blog/" are preserved.
|
|
339
|
-
if (hadOmittedOptional) {
|
|
340
|
-
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
341
|
-
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
342
|
-
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
343
|
-
}
|
|
344
|
-
}
|
|
363
|
+
let result = params
|
|
364
|
+
? substitutePatternParams(pattern, params, name)
|
|
365
|
+
: pattern;
|
|
345
366
|
|
|
346
367
|
// Append search params as query string
|
|
347
368
|
if (search) {
|
|
@@ -18,7 +18,7 @@ import { isInsideCacheScope } from "../server/context.js";
|
|
|
18
18
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
19
19
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
20
20
|
import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
21
|
-
import {
|
|
21
|
+
import { substitutePatternParams } from "./substitute-pattern-params.js";
|
|
22
22
|
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -160,52 +160,14 @@ export function createReverseFunction(
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
let result = pattern;
|
|
164
|
-
|
|
165
163
|
// Merge current request params as defaults, explicit params override
|
|
166
164
|
const effectiveParams = currentParams
|
|
167
165
|
? { ...currentParams, ...hrefParams }
|
|
168
166
|
: hrefParams;
|
|
169
167
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
let hadOmittedOptional = false;
|
|
174
|
-
// First pass: optional params (trailing ?)
|
|
175
|
-
result = result.replace(
|
|
176
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
177
|
-
(_, key) => {
|
|
178
|
-
const value = effectiveParams[key];
|
|
179
|
-
// The matcher omits absent optional params (so `value` is
|
|
180
|
-
// `undefined` here), but caller-supplied params or `getParams()`
|
|
181
|
-
// shapes may still pass `""` explicitly. Treat both as the
|
|
182
|
-
// absent form so the segment collapses cleanly.
|
|
183
|
-
if (value === undefined || value === "") {
|
|
184
|
-
hadOmittedOptional = true;
|
|
185
|
-
return "";
|
|
186
|
-
}
|
|
187
|
-
return encodePathSegment(value);
|
|
188
|
-
},
|
|
189
|
-
);
|
|
190
|
-
// Second pass: required params (no trailing ?)
|
|
191
|
-
result = result.replace(
|
|
192
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
193
|
-
(_, key) => {
|
|
194
|
-
const value = effectiveParams[key];
|
|
195
|
-
if (value === undefined) {
|
|
196
|
-
throw new Error(`Missing param "${key}" for route "${name}"`);
|
|
197
|
-
}
|
|
198
|
-
return encodePathSegment(value);
|
|
199
|
-
},
|
|
200
|
-
);
|
|
201
|
-
// Clean up slashes only when an optional param was actually omitted,
|
|
202
|
-
// so intentional trailing-slash patterns like "/blog/" are preserved.
|
|
203
|
-
if (hadOmittedOptional) {
|
|
204
|
-
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
205
|
-
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
206
|
-
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
207
|
-
}
|
|
208
|
-
}
|
|
168
|
+
let result = effectiveParams
|
|
169
|
+
? substitutePatternParams(pattern, effectiveParams, name)
|
|
170
|
+
: pattern;
|
|
209
171
|
|
|
210
172
|
// Append search params as query string
|
|
211
173
|
if (search) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { encodePathSegment } from "./url-params.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Substitute `:param` placeholders in a route pattern with values from
|
|
5
|
+
* `params`. Two-pass: optional params (`:name?`) first so absent values
|
|
6
|
+
* collapse cleanly, then required params (throws on missing). Constraint
|
|
7
|
+
* syntax (`:name(en|gb)`) is stripped from the result. Trailing-slash
|
|
8
|
+
* patterns like `/blog/` are preserved unless an optional segment was
|
|
9
|
+
* actually omitted.
|
|
10
|
+
*
|
|
11
|
+
* Shared by `ctx.reverse()` (server), `createReverse()` (typed runtime
|
|
12
|
+
* helper), and `useReverse()` (client hook). The behavior must stay
|
|
13
|
+
* identical across all three call sites.
|
|
14
|
+
*/
|
|
15
|
+
export function substitutePatternParams(
|
|
16
|
+
pattern: string,
|
|
17
|
+
params: Record<string, string | undefined>,
|
|
18
|
+
routeName: string,
|
|
19
|
+
): string {
|
|
20
|
+
let result = pattern;
|
|
21
|
+
let hadOmittedOptional = false;
|
|
22
|
+
|
|
23
|
+
result = result.replace(
|
|
24
|
+
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
25
|
+
(_match, key) => {
|
|
26
|
+
const value = params[key as string];
|
|
27
|
+
// The matcher omits absent optional params (so `value` is `undefined`
|
|
28
|
+
// here), but caller-supplied params or `getParams()` shapes may still
|
|
29
|
+
// pass `""` explicitly. Treat both as the absent form.
|
|
30
|
+
if (value === undefined || value === "") {
|
|
31
|
+
hadOmittedOptional = true;
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
return encodePathSegment(value);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
result = result.replace(
|
|
39
|
+
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
40
|
+
(_match, key) => {
|
|
41
|
+
const value = params[key as string];
|
|
42
|
+
if (value === undefined) {
|
|
43
|
+
throw new Error(`Missing param "${key}" for route "${routeName}"`);
|
|
44
|
+
}
|
|
45
|
+
return encodePathSegment(value);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (hadOmittedOptional) {
|
|
50
|
+
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
51
|
+
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
52
|
+
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|