@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 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, always generate on the server and pass the string in.
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
 
@@ -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.99",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.99",
3
+ "version": "0.0.0-experimental.9b7a7784",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -694,7 +694,27 @@ function MountInfo() {
694
694
  }
695
695
  ```
696
696
 
697
- See `/links` for full URL generation guide. The default server API is `ctx.reverse()`; in client components, receive URLs as props, loader data, or server-action return values — `reverse()` is not available in the browser.
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 |
@@ -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
- **`reverse()` is server-only.** It depends on the route manifest and handler context, neither of which are available in the browser. Client components receive URLs as props, loader data, or server-action return values — they never call `reverse` directly.
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 and no route manifest in the browser bundle. Generate the URL on the server and hand it to the client component.
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
- ## When to use what
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
- | Context | API | Resolves | Use for |
262
- | ---------------- | -------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- |
263
- | Server handler | `ctx.reverse("name")` | Named routes (local + absolute) | **Default** server-side URL generation |
264
- | Server handler | `scopedReverse<T>(ctx.reverse)` | Same, with type safety | Type-safe server URLs |
265
- | Client component | (URL passed as prop / loader data / action return) | Named routes | Any URL derived from a named route — generate on server, pass in |
266
- | Client component | `href("/path")` | Absolute paths (static strings) | Static navigation where no named-route lookup is needed |
267
- | Client component | `useHref()` | Mount-prefixed paths | Local navigation inside `include()` |
268
- | Client component | `useMount()` | Raw mount path | Custom mount-aware logic |
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
- > `reverse()` is server-only. Client components never import or call it — they receive the already-resolved string.
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
- window.addEventListener("popstate", handlePopstate);
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
- window.removeEventListener("popstate", handlePopstate);
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 after React commits and browser paints.
298
- // startTransition defers the DOM commit, so scrolling synchronously
299
- // would be overwritten when React replaces the content.
300
- deferToNextPaint(() => {
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
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
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
- // Defer hash and scroll-to-top to after React paints the new content,
378
- // so the user doesn't see the current page jump before the new route appears.
379
- deferToNextPaint(() => {
380
- // Re-check: the deferred callback may fire after environment teardown
381
- if (typeof window === "undefined") return;
382
-
383
- // Try hash scrolling first
384
- if (scrollToHash()) {
385
- return;
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
  /**
@@ -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";
@@ -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
- return path;
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 { encodePathSegment } from "./router/url-params.js";
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 = pattern;
306
- if (params) {
307
- // Replace :param placeholders with actual values
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 { encodePathSegment } from "./url-params.js";
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
- // Substitute params (strip constraint and optional syntax: :param(a|b)? -> value)
171
- // Optional params (:param?) are omitted when not provided
172
- if (effectiveParams) {
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
+ }