@bractjs/bractjs 0.1.26 → 0.1.27

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
@@ -34,7 +34,7 @@ This README is a **step-by-step guide to every function and feature** BractJS ex
34
34
  15. [Sessions: `createCookieSession`](#15-sessions)
35
35
  16. [Lifecycle hooks: `defineLifecycle`](#16-lifecycle-hooks)
36
36
  17. [Environment variables & `*.server.ts`](#17-environment-variables)
37
- 18. [Typed routes codegen](#18-typed-routes-codegen)
37
+ 18. [Typed routes](#18-typed-routes)
38
38
  19. [Internationalization (i18n) utilities](#19-internationalization-utilities)
39
39
  20. [Image optimization (`<Image>` + `/_image`)](#20-image-optimization)
40
40
  21. [Build & run: CLI + programmatic API (`createDevServer`, `runBuild`, `loadUserConfig`)](#21-build--run)
@@ -353,10 +353,12 @@ const result = useActionData<{ error?: string }>();
353
353
  ```
354
354
 
355
355
  ### `useParams<T>()` → `T`
356
- URL dynamic params. Pass a generic for typed params.
356
+ URL dynamic params. Pass the **route pattern** as a generic to type the result against your codegen'd routes (see §18); an object shape also works.
357
357
  ```ts
358
- const { id } = useParams<{ id: string }>();
358
+ const { id } = useParams<"/blog/:id">(); // { id: string } — typed from routes
359
+ const { id } = useParams<{ id: string }>(); // or a hand-written shape
359
360
  ```
361
+ > The pattern is supplied by the caller because the framework can't infer the active route at the type level (React Router's `useParams` works the same way).
360
362
 
361
363
  ### `useNavigation()` → `{ state }`
362
364
  `"idle" | "loading" | "submitting"`.
@@ -365,12 +367,21 @@ const { state } = useNavigation();
365
367
  if (state === "loading") return <Spinner />;
366
368
  ```
367
369
 
370
+ ### `useNavigate()` → `(to, { params? }) => Promise<void>`
371
+ Imperative soft navigation — the counterpart to `<Link>`. `to` autocompletes your routes (after codegen, §18) and `params` is typed per route; any string is still accepted.
372
+ ```ts
373
+ const navigate = useNavigate();
374
+ await navigate("/blog/:id", { params: { id: "42" } }); // typed
375
+ await navigate("/about"); // static
376
+ await navigate(`/blog/${id}`); // built string (also fine)
377
+ ```
378
+
368
379
  ### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
369
- Read/write URL query params; writing triggers a soft-nav loader re-run.
380
+ Read/write URL query params; writing triggers a soft-nav loader re-run. Pass the route pattern as a generic to type the result against `RouteSearchParamsMap` (augment it per route, §18); an object shape also works.
370
381
  ```ts
371
- const { searchParams, getParam, setSearchParams } = useSearchParams<{ q: string }>();
372
- const q = getParam("q"); // string | null
373
- setSearchParams({ q: "bun" }); // replace all params
382
+ const { searchParams, getParam, setSearchParams } = useSearchParams<"/blog/:id">();
383
+ const q = getParam("q"); // string | null
384
+ setSearchParams({ q: "bun" }); // replace all params
374
385
  setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
375
386
  ```
376
387
 
@@ -415,12 +426,13 @@ export default function BlogLayout() {
415
426
  }
416
427
  ```
417
428
 
418
- ### `<Link to prefetch? viewTransition?>`
419
- Soft-navigates without a full reload.
429
+ ### `<Link to params? prefetch? viewTransition?>`
430
+ Soft-navigates without a full reload. After codegen (§18), `to` autocompletes your routes; for a dynamic route pass typed `params`. Building the URL yourself still works, so existing links need no changes.
420
431
  ```tsx
421
- <Link to="/blog/42">Read</Link>
422
- <Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
423
- <Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
432
+ <Link to="/blog/:id" params={{ id: "42" }}>Read</Link> {/* typed route + params */}
433
+ <Link to={`/blog/${id}`}>Read</Link> {/* built string also fine */}
434
+ <Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
435
+ <Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
424
436
  ```
425
437
  Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
426
438
 
@@ -738,48 +750,55 @@ On the server, read env via `Bun.env.*` directly.
738
750
 
739
751
  ---
740
752
 
741
- ## 18. Typed routes codegen
753
+ ## 18. Typed routes
742
754
 
743
- Generate per-route param types and a type-safe URL builder from your route files.
755
+ Generate type-safe routing from your route files one command wires `<Link>`, `useNavigate`, `useParams`, and `useSearchParams` to your actual routes.
744
756
 
745
757
  ```sh
746
758
  bractjs codegen # ./app → ./app/route-types.gen.ts
747
759
  bractjs codegen ./app ./app/types.ts # explicit paths
748
760
  ```
749
761
 
750
- Runs automatically during `bractjs build`. The generated file provides:
762
+ Runs automatically during `bractjs build`. Make sure the generated file is part of your TypeScript program (it is, if your `tsconfig.json` `include`s `app/`). It augments BractJS's `Register` interface, after which the runtime components and hooks become type-safe — **no per-route imports needed**:
751
763
 
752
- ```ts
753
- export type AppRoutes = "/" | "/blog/:id" | "/org/:orgId/repo/:repoId";
754
-
755
- export type RouteParams<T extends AppRoutes> =
756
- T extends "/blog/:id" ? { id: string } : Record<never, never>;
764
+ ```tsx
765
+ <Link to="/blog/:id" params={{ id }} /> // ✅ "/blog/:id" autocompletes; params typed
766
+ <Link to="/blgo/:id" params={{ id }} /> // ❌ typo'd route — compile error
767
+ <Link to="/blog/:id" params={{ x: id }} /> // ❌ wrong param key — compile error
757
768
 
758
- export type TypedLoaderArgs<T extends AppRoutes> = { request: Request; params: RouteParams<T>; context: Record<string, unknown> };
759
- export type TypedActionArgs<T extends AppRoutes> = TypedLoaderArgs<T> & { formData: FormData };
769
+ const navigate = useNavigate();
770
+ navigate("/blog/:id", { params: { id } }); // same typing as <Link>
760
771
 
761
- export const routes = {
762
- "/": () => "/",
763
- "/blog/:id": (p: { id: string }) => `/blog/${p.id}`,
764
- } as const;
772
+ const { id } = useParams<"/blog/:id">(); // id: string
765
773
  ```
766
774
 
767
- Use them for typed loaders and safe navigation:
775
+ Building the URL yourself (`<Link to={`/blog/${id}`}>`) still type-checks, so adopting codegen never breaks existing links.
776
+
777
+ The generated file also exports types/helpers for typed loaders and explicit URL building:
768
778
 
769
779
  ```ts
770
- import type { TypedLoaderArgs, RouteParams } from "../route-types.gen.ts";
780
+ import type { TypedLoaderArgs } from "../route-types.gen.ts";
771
781
  import { routes } from "../route-types.gen.ts";
772
782
 
773
783
  export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
774
- return db.post.findById(params.id); // params.id: string
784
+ return db.post.findById(params.id); // params.id: string
775
785
  }
786
+ routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
787
+ ```
788
+
789
+ **Type a route's search params or context** by augmenting the package interfaces — `SearchParams<T>` / `Context<T>` and `useSearchParams<T>()` pick it up:
776
790
 
777
- const { id } = useParams<RouteParams<"/blog/:id">>();
778
- routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
791
+ ```ts
792
+ declare module "@bractjs/bractjs" {
793
+ interface RouteSearchParamsMap { "/blog": { page: string; sort: string } }
794
+ interface RouteContextMap { "/admin": { user: { id: string; role: "admin" } } }
795
+ }
779
796
  ```
780
797
 
781
798
  You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
782
799
 
800
+ > **Heads up:** earlier versions documented `useParams<RouteParams<"/blog/:id">>()` and untyped `<Link to={string}>`. The route-literal form (`useParams<"/blog/:id">()`) and typed `<Link>`/`useNavigate` are the current API; the old forms still compile.
801
+
783
802
  ---
784
803
 
785
804
  ## 19. Internationalization utilities
@@ -1098,7 +1117,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
1098
1117
  - **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
1099
1118
  - **File-based routing** — drop a file in `app/routes/`.
1100
1119
  - **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
1101
- - **Typed routes** — codegen produces per-route params and a type-safe URL builder.
1120
+ - **Typed routes** — codegen wires `<Link>`, `useNavigate`, and `useParams` to your routes (autocompleted paths, typed params), plus a type-safe URL builder.
1102
1121
  - **Single-binary** — `bun build --compile` to one executable.
1103
1122
 
1104
1123
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/bractjs/bractjs#readme",
@@ -0,0 +1,29 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildPath } from "../client/build-path.ts";
3
+
4
+ describe("buildPath", () => {
5
+ test("substitutes a single :param", () => {
6
+ expect(buildPath("/blog/:id", { id: "42" })).toBe("/blog/42");
7
+ });
8
+
9
+ test("substitutes multiple params", () => {
10
+ expect(buildPath("/u/:user/post/:post", { user: "ann", post: "7" })).toBe("/u/ann/post/7");
11
+ });
12
+
13
+ test("passes static patterns through untouched", () => {
14
+ expect(buildPath("/about", {})).toBe("/about");
15
+ expect(buildPath("/", {})).toBe("/");
16
+ });
17
+
18
+ test("URL-encodes param values", () => {
19
+ expect(buildPath("/search/:q", { q: "a b/c" })).toBe("/search/a%20b%2Fc");
20
+ });
21
+
22
+ test("coerces numbers to strings", () => {
23
+ expect(buildPath("/n/:id", { id: 7 })).toBe("/n/7");
24
+ });
25
+
26
+ test("leaves an absent param's segment intact (surfaces as an obvious bad URL)", () => {
27
+ expect(buildPath("/blog/:id", {})).toBe("/blog/:id");
28
+ });
29
+ });
@@ -29,6 +29,42 @@ describe("route-codegen — output shape", () => {
29
29
  expect(out).toMatch(/\| "\/users\/:id"/);
30
30
  });
31
31
 
32
+ test("wires the Register augmentation for typed routing", async () => {
33
+ const regApp = join(tmpdir(), `bract-codegen-register-${Date.now()}`);
34
+ await mkdir(join(regApp, "routes", "users"), { recursive: true });
35
+ await writeFile(join(regApp, "routes", "about.tsx"), "export default () => null;");
36
+ await writeFile(join(regApp, "routes", "users", "[id].tsx"), "export default () => null;");
37
+
38
+ const out = await generateRouteTypes(regApp);
39
+
40
+ // Bug 1 regression: the augmentation must target the real package name.
41
+ expect(out).toContain('declare module "@bractjs/bractjs"');
42
+ expect(out).not.toMatch(/declare module ['"]bractjs['"]/);
43
+
44
+ // Bug 2 regression: the customization maps are AUGMENTED on the package, not
45
+ // re-declared as bare top-level interfaces in the app file.
46
+ expect(out).not.toMatch(/^export interface RouteSearchParamsMap/m);
47
+ expect(out).not.toMatch(/^export interface RouteContextMap/m);
48
+ expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs"');
49
+
50
+ // The Register seam carries the route union and a per-route params map.
51
+ expect(out).toContain("interface Register {");
52
+ expect(out).toContain("routes: AppRoutes;");
53
+ expect(out).toMatch(/"\/users\/:id": \{ id: string \};/); // dynamic route → typed params
54
+ expect(out).toMatch(/"\/about": \{\};/); // static route → no params
55
+
56
+ await rm(regApp, { recursive: true, force: true });
57
+ });
58
+
59
+ test("emits no Register augmentation when there are no routes", async () => {
60
+ const emptyApp = join(tmpdir(), `bract-codegen-empty-${Date.now()}`);
61
+ await mkdir(join(emptyApp, "routes"), { recursive: true });
62
+ const out = await generateRouteTypes(emptyApp);
63
+ expect(out).toContain("export type AppRoutes =\n never;");
64
+ expect(out).not.toContain("interface Register {");
65
+ await rm(emptyApp, { recursive: true, force: true });
66
+ });
67
+
32
68
  test("rejects hostile filenames at codegen time", async () => {
33
69
  const hostileApp = join(tmpdir(), `bract-codegen-hostile-${Date.now()}`);
34
70
  await mkdir(join(hostileApp, "routes"), { recursive: true });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Type-level guardrail for end-to-end typed routing.
3
+ *
4
+ * The runtime helpers (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`)
5
+ * gain their type-safety from a declaration-merging seam: `bractjs codegen`
6
+ * augments the package's `Register` interface, and the helpers resolve route
7
+ * types from it. None of that is observable at runtime — only `tsc` proves it.
8
+ * This test writes a fixture, generates its `route-types.gen.ts`, and runs
9
+ * `tsc --noEmit` over:
10
+ *
11
+ * - positive usage (typed <Link>/useNavigate/useParams must compile), and
12
+ * - negative usage guarded by `@ts-expect-error` (bad params/routes MUST error,
13
+ * so a directive that goes unused fails the build), and
14
+ * - a SECOND fixture with NO codegen, proving un-registered apps still compile
15
+ * with the loose `string` fallback (the backwards-compat contract).
16
+ *
17
+ * It guards the subtle failure mode that nearly shipped: a too-clever resolution
18
+ * type silently falling back to loose `string`, which compiles but enforces
19
+ * nothing.
20
+ *
21
+ * The fixture lives inside the repo (`.tmp-types-*`, gitignored) so its
22
+ * `@bractjs/bractjs` import self-resolves to the in-repo framework via the root
23
+ * tsconfig `paths` mapping.
24
+ */
25
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
26
+ import { mkdir, rm, writeFile } from "node:fs/promises";
27
+ import { resolve, join } from "node:path";
28
+ import { generateRouteTypes } from "../codegen/route-codegen.ts";
29
+
30
+ const REPO_ROOT = resolve(import.meta.dir, "../..");
31
+ const TMP = resolve(import.meta.dir, `.tmp-types-${Date.now()}`);
32
+
33
+ // Invoke TypeScript via `bunx tsc` — it works whether tsc is installed locally
34
+ // or fetched on demand. (A direct node_modules/.bin/tsc path is unreliable here:
35
+ // it may be a dangling symlink to an un-installed package.)
36
+ const TSC_CMD = ["bunx", "tsc"] as const;
37
+
38
+ // A fixture tsconfig that resolves `@bractjs/bractjs` → the in-repo entry, mirrors
39
+ // the example apps' compiler options, and type-checks only this fixture's files.
40
+ function tsconfig(includeGlobDir: string): string {
41
+ return JSON.stringify(
42
+ {
43
+ compilerOptions: {
44
+ target: "ESNext",
45
+ module: "ESNext",
46
+ moduleResolution: "bundler",
47
+ lib: ["ESNext", "DOM", "DOM.Iterable"],
48
+ jsx: "react-jsx",
49
+ jsxImportSource: "react",
50
+ strict: true,
51
+ noEmit: true,
52
+ skipLibCheck: true,
53
+ allowImportingTsExtensions: true,
54
+ types: ["react", "react-dom"],
55
+ // `paths` with absolute targets needs no `baseUrl` (and baseUrl is
56
+ // deprecated as of TS6, which would fail the compile here).
57
+ paths: { "@bractjs/bractjs": [join(REPO_ROOT, "src/index.ts")] },
58
+ },
59
+ include: [join(includeGlobDir, "**/*.ts"), join(includeGlobDir, "**/*.tsx")],
60
+ },
61
+ null,
62
+ 2,
63
+ );
64
+ }
65
+
66
+ async function runTsc(projectDir: string): Promise<{ code: number; output: string }> {
67
+ const proc = Bun.spawn([...TSC_CMD, "--noEmit", "-p", join(projectDir, "tsconfig.json")], {
68
+ cwd: projectDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+ const [stdout, stderr, code] = await Promise.all([
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ proc.exited,
76
+ ]);
77
+ return { code, output: stdout + stderr };
78
+ }
79
+
80
+ let tscAvailable = false;
81
+
82
+ beforeAll(async () => {
83
+ await mkdir(TMP, { recursive: true });
84
+ // Real availability probe: actually run the compiler. Earlier this checked a
85
+ // symlink's existence and silently skipped when it dangled — making the whole
86
+ // suite a no-op that still "passed". Run `tsc --version` and trust the exit.
87
+ try {
88
+ const probe = Bun.spawn([...TSC_CMD, "--version"], { stdout: "pipe", stderr: "pipe" });
89
+ const out = await new Response(probe.stdout).text();
90
+ tscAvailable = (await probe.exited) === 0 && /Version/.test(out);
91
+ } catch {
92
+ tscAvailable = false;
93
+ }
94
+ });
95
+
96
+ afterAll(async () => {
97
+ await rm(TMP, { recursive: true, force: true });
98
+ });
99
+
100
+ describe("typed routing (type-level)", () => {
101
+ test("tsc is available (else the type-level assertions below are skipped)", () => {
102
+ // Surfaced as its own test so a skipped type-check is visible in the report
103
+ // rather than masquerading as a silent pass.
104
+ if (!tscAvailable) console.warn("[typed-routing] tsc unavailable — type-level checks skipped");
105
+ expect(typeof tscAvailable).toBe("boolean");
106
+ });
107
+
108
+ test("registered app: typed Link/useNavigate/useParams compile; bad usage errors", async () => {
109
+ if (!tscAvailable) return; // tsc not available in this environment — skip gracefully
110
+
111
+ const app = join(TMP, "registered");
112
+ await mkdir(join(app, "routes", "blog"), { recursive: true });
113
+ await writeFile(join(app, "routes", "_index.tsx"), "export default () => null;\n");
114
+ await writeFile(join(app, "routes", "blog", "[id].tsx"), "export default () => null;\n");
115
+
116
+ // Generate the registration file (augments Register on the package).
117
+ await writeFile(join(app, "route-types.gen.ts"), await generateRouteTypes(app));
118
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
119
+
120
+ await writeFile(
121
+ join(app, "usage.tsx"),
122
+ `import { Link, useNavigate, useParams, useSearchParams } from "@bractjs/bractjs";\n` +
123
+ `import "./route-types.gen.ts";\n` +
124
+ `export function Ok() {\n` +
125
+ ` const navigate = useNavigate();\n` +
126
+ ` const p = useParams<"/blog/:id">();\n` +
127
+ ` const id: string = p.id;\n` +
128
+ ` useSearchParams<"/blog/:id">();\n` +
129
+ ` return (<>\n` +
130
+ ` <Link to="/blog/:id" params={{ id }}>typed</Link>\n` +
131
+ ` <Link to="/">static literal</Link>\n` +
132
+ ` <Link to={\`/\${id}\`}>built string (BC)</Link>\n` +
133
+ ` <button onClick={() => { void navigate("/blog/:id", { params: { id } }); }}>go</button>\n` +
134
+ ` <button onClick={() => { void navigate("/"); }}>home</button>\n` +
135
+ ` </>);\n` +
136
+ `}\n` +
137
+ `export function Bad() {\n` +
138
+ ` const navigate = useNavigate();\n` +
139
+ ` const p = useParams<"/blog/:id">();\n` +
140
+ ` return (<>\n` +
141
+ ` {/* @ts-expect-error wrong param key */}\n` +
142
+ ` <Link to="/blog/:id" params={{ wrong: "1" }}>x</Link>\n` +
143
+ ` {/* @ts-expect-error missing required param */}\n` +
144
+ ` <Link to="/blog/:id" params={{}}>x</Link>\n` +
145
+ ` <button onClick={() => {\n` +
146
+ ` // @ts-expect-error wrong param key in navigate\n` +
147
+ ` void navigate("/blog/:id", { params: { wrong: "1" } });\n` +
148
+ ` }}>go</button>\n` +
149
+ ` {/* @ts-expect-error /blog/:id has no \`nope\` param */}\n` +
150
+ ` <span>{p.nope}</span>\n` +
151
+ ` </>);\n` +
152
+ `}\n`,
153
+ );
154
+
155
+ const { code, output } = await runTsc(app);
156
+ // Exit 0 means: positives compiled AND every @ts-expect-error was satisfied
157
+ // by a real error. A silently-loose type would leave directives unused → TS2578.
158
+ // (`output` may carry bunx "Resolving dependencies" noise — key on TS errors.)
159
+ expect(output).not.toContain("TS2578"); // unused @ts-expect-error → typing too loose
160
+ expect(output).not.toMatch(/error TS/);
161
+ expect(code).toBe(0);
162
+ }, 60_000);
163
+
164
+ test("un-registered app (no codegen): loose string fallback still compiles", async () => {
165
+ if (!tscAvailable) return;
166
+
167
+ const app = join(TMP, "loose");
168
+ await mkdir(app, { recursive: true });
169
+ await writeFile(join(app, "tsconfig.json"), tsconfig("."));
170
+ // No route-types.gen.ts → Register stays empty → everything falls back to string.
171
+ await writeFile(
172
+ join(app, "usage.tsx"),
173
+ `import { Link, useNavigate, useParams } from "@bractjs/bractjs";\n` +
174
+ `export function App({ href }: { href: string }) {\n` +
175
+ ` const navigate = useNavigate();\n` +
176
+ ` const p = useParams();\n` +
177
+ ` return (<>\n` +
178
+ ` <Link to={href}>any string</Link>\n` +
179
+ ` <Link to="/anything/at/all">arbitrary literal</Link>\n` +
180
+ ` <button onClick={() => { void navigate("/wherever"); }}>{p.x}</button>\n` +
181
+ ` </>);\n` +
182
+ `}\n`,
183
+ );
184
+
185
+ const { code, output } = await runTsc(app);
186
+ expect(output).not.toMatch(/error TS/);
187
+ expect(code).toBe(0);
188
+ }, 60_000);
189
+ });
@@ -0,0 +1,24 @@
1
+ // Substitute `:name` segments in a colon-style route pattern with param values.
2
+ //
3
+ // Mirrors the substitution `bractjs codegen` bakes into the generated `routes`
4
+ // builder (`src/codegen/route-codegen.ts`). The framework's own `<Link>` /
5
+ // `useNavigate` can't import that app-local generated object, so they share this
6
+ // helper instead. Values are URL-encoded; an absent param leaves its `:name`
7
+ // segment intact (surfaced as an obviously-wrong URL rather than silently dropped).
8
+ //
9
+ // Patterns without a `:` (static routes, or already-built hrefs) pass straight
10
+ // through, so this is safe to call unconditionally.
11
+ export function buildPath(
12
+ pattern: string,
13
+ params: Record<string, string | number>,
14
+ ): string {
15
+ if (!pattern.includes(":")) return pattern;
16
+ return pattern
17
+ .split("/")
18
+ .map((seg) => {
19
+ if (!seg.startsWith(":")) return seg;
20
+ const value = params[seg.slice(1)];
21
+ return value === undefined ? seg : encodeURIComponent(String(value));
22
+ })
23
+ .join("/");
24
+ }
@@ -1,16 +1,28 @@
1
1
  import { useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
2
2
  import { NavigationContext, RouterContext } from "../router.tsx";
3
3
  import { prefetchRoute } from "../prefetch.ts";
4
+ import { buildPath } from "../build-path.ts";
5
+ import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
4
6
 
5
7
  // ── Types ──────────────────────────────────────────────────────────────────
6
8
 
7
- interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
8
- to: string;
9
+ // `to` accepts any registered route literal (autocomplete + typed `params`) but
10
+ // also any string via `(string & {})`, so existing call sites that build the URL
11
+ // themselves — `to={`/posts/${slug}`}`, `to={item.href}` — keep compiling. Run
12
+ // `bractjs codegen` to register the app's routes and unlock autocomplete; until
13
+ // then `RegisteredRoutes` is `string` and this is just today's loose prop.
14
+ type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = Omit<
15
+ AnchorHTMLAttributes<HTMLAnchorElement>,
16
+ "href"
17
+ > & {
18
+ to: TTo | (string & {});
19
+ /** Path params for a dynamic `to` (e.g. `params={{ id }}` for `/blog/:id`). */
20
+ params?: ParamsFor<TTo>;
9
21
  prefetch?: "hover" | "none";
10
22
  /** Opt in to View Transitions API for this navigation (E1). */
11
23
  viewTransition?: boolean;
12
24
  children: ReactNode;
13
- }
25
+ };
14
26
 
15
27
  // ── Component ──────────────────────────────────────────────────────────────
16
28
 
@@ -19,11 +31,22 @@ const supportsViewTransitions =
19
31
  typeof document !== "undefined" &&
20
32
  typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
21
33
 
22
- export function Link({ to, prefetch = "none", viewTransition = false, children, ...rest }: LinkProps) {
34
+ export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
35
+ to,
36
+ params,
37
+ prefetch = "none",
38
+ viewTransition = false,
39
+ children,
40
+ ...rest
41
+ }: LinkProps<TTo>) {
23
42
  const navCtx = useContext(NavigationContext);
24
43
  const routerCtx = useContext(RouterContext);
25
44
  const isLoading = navCtx?.state === "loading";
26
45
 
46
+ // Resolve the final href once: substitute params into a dynamic pattern, or
47
+ // pass an already-built string straight through.
48
+ const href = params ? buildPath(to as string, params as Record<string, string>) : (to as string);
49
+
27
50
  function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
28
51
  if (!navCtx) return; // SSR: let browser handle naturally
29
52
  if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
@@ -31,20 +54,20 @@ export function Link({ to, prefetch = "none", viewTransition = false, children,
31
54
 
32
55
  if (viewTransition && supportsViewTransitions) {
33
56
  (document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
34
- () => { void navCtx.navigate(to); },
57
+ () => { void navCtx.navigate(href); },
35
58
  );
36
59
  } else {
37
- void navCtx.navigate(to);
60
+ void navCtx.navigate(href);
38
61
  }
39
62
  }
40
63
 
41
64
  function handleMouseEnter() {
42
- if (prefetch === "hover" && routerCtx) prefetchRoute(to, routerCtx.manifest);
65
+ if (prefetch === "hover" && routerCtx) prefetchRoute(href, routerCtx.manifest);
43
66
  }
44
67
 
45
68
  return (
46
69
  <a
47
- href={to}
70
+ href={href}
48
71
  onClick={handleClick}
49
72
  onMouseEnter={handleMouseEnter}
50
73
  aria-disabled={isLoading || undefined}
@@ -0,0 +1,46 @@
1
+ import { useContext, useCallback } from "react";
2
+ import { NavigationContext } from "../router.tsx";
3
+ import { buildPath } from "../build-path.ts";
4
+ import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────────────
7
+
8
+ export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> {
9
+ /** Path params for a dynamic `to` (e.g. `{ params: { id } }` for `/blog/:id`). */
10
+ params?: ParamsFor<TTo>;
11
+ }
12
+
13
+ export interface NavigateFn {
14
+ <TTo extends RegisteredRoutes>(
15
+ to: TTo | (string & {}),
16
+ options?: NavigateOptions<TTo>,
17
+ ): Promise<void>;
18
+ }
19
+
20
+ // ── Hook ───────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Returns a typed `navigate(to, { params })` for programmatic soft navigation —
24
+ * the imperative counterpart to `<Link>`. Mirrors `<Link>`'s `to`/`params` API:
25
+ * `to` autocompletes registered routes (after `bractjs codegen`) while still
26
+ * accepting any string, and `params` is typed per route.
27
+ *
28
+ * SSR-safe and safe outside a `ClientRouter`: with no NavigationContext it
29
+ * resolves to a no-op (same guard as `<Link>`), so it never throws during render.
30
+ *
31
+ * Note: navigation always pushes a history entry today; a `replace` option will
32
+ * follow once the underlying navigate contract supports it.
33
+ */
34
+ export function useNavigate(): NavigateFn {
35
+ const navCtx = useContext(NavigationContext);
36
+ return useCallback<NavigateFn>(
37
+ (to, options) => {
38
+ const href = options?.params
39
+ ? buildPath(to as string, options.params as Record<string, string>)
40
+ : (to as string);
41
+ if (!navCtx) return Promise.resolve();
42
+ return navCtx.navigate(href);
43
+ },
44
+ [navCtx],
45
+ );
46
+ }
@@ -1,14 +1,25 @@
1
1
  import { useContext } from "react";
2
2
  import { RouterContext } from "../router.tsx";
3
3
  import { BractJSContext } from "../../shared/context.ts";
4
+ import type { ParamsFor } from "../registry.ts";
4
5
 
5
6
  /**
6
- * Returns the current route's URL params (e.g. { id: "42" }).
7
- * Pass a RouteParams<T> generic for typed params: useParams<RouteParams<"/blog/:id">>()
7
+ * Returns the current route's URL params (e.g. `{ id: "42" }`).
8
+ *
9
+ * Pass the route pattern as a generic to type the result against your codegen'd
10
+ * routes: `useParams<"/blog/:id">()` → `{ id: string }`. The pattern is supplied
11
+ * by the caller because the framework can't infer the active route at the type
12
+ * level (React Router's `useParams` has the same limitation). An object generic
13
+ * — `useParams<{ id: string }>()` — also works for hand-typed shapes.
14
+ *
8
15
  * Works in both SSR and client contexts.
9
16
  */
10
- export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
17
+ // Overload 1: a route literal params resolved from the registry.
18
+ export function useParams<TTo extends string>(): ParamsFor<TTo>;
19
+ // Overload 2: an explicit object shape (back-compat with the old generic form).
20
+ export function useParams<T extends Record<string, string> = Record<string, string>>(): T;
21
+ export function useParams(): Record<string, string> {
11
22
  const router = useContext(RouterContext);
12
23
  const bract = useContext(BractJSContext);
13
- return (router?.params ?? bract?.params ?? {}) as T;
24
+ return router?.params ?? bract?.params ?? {};
14
25
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useEffect, useRef, startTransition } from "react";
2
2
  import { NavigationContext } from "../router.tsx";
3
3
  import { useContext } from "react";
4
+ import type { SearchFor } from "../registry.ts";
4
5
 
5
6
  // ── Types ──────────────────────────────────────────────────────────────────
6
7
 
@@ -17,13 +18,22 @@ export interface SearchParamsResult<T extends Record<string, string>> {
17
18
  // ── Hook ───────────────────────────────────────────────────────────────────
18
19
 
19
20
  /**
20
- * Reads and writes URL search params, typed per-route via generic T.
21
- * Triggers a loader re-run (soft-nav fetch) when params change.
21
+ * Reads and writes URL search params. Triggers a loader re-run (soft-nav fetch)
22
+ * when params change.
23
+ *
24
+ * Pass the route pattern as a generic to type the result against your codegen'd
25
+ * routes: `useSearchParams<"/posts">()`. Augment `RouteSearchParamsMap` to give a
26
+ * route a concrete shape (defaults to `Record<string, string>`). The pattern is
27
+ * supplied by the caller — the framework can't infer the active route at the type
28
+ * level. An object generic — `useSearchParams<{ page: string }>()` — also works.
22
29
  *
23
- * T is the route's SearchParams shape (e.g. { page: string; sort: string }).
24
30
  * This hook is SSR-safe: on the server window is absent, so it returns empty params.
25
31
  */
26
- export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
32
+ // Overload 1: a route literal search shape resolved from the registry.
33
+ export function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
34
+ // Overload 2: an explicit object shape (back-compat with the old generic form).
35
+ export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
36
+ export function useSearchParams(): SearchParamsResult<Record<string, string>> {
27
37
  const navCtx = useContext(NavigationContext);
28
38
 
29
39
  function readCurrent(): URLSearchParams {
@@ -66,8 +76,8 @@ export function useSearchParams<T extends Record<string, string> = Record<string
66
76
  }
67
77
  }, [navCtx]);
68
78
 
69
- const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
70
- return (searchParams.get(key) as T[K] | null);
79
+ const getParam = useCallback((key: string): string | null => {
80
+ return searchParams.get(key);
71
81
  }, [searchParams]);
72
82
 
73
83
  return { searchParams, getParam, setSearchParams };
@@ -0,0 +1,107 @@
1
+ // Type-registration seam for end-to-end typed routing (TanStack-Router style).
2
+ //
3
+ // This file is RUNTIME-FREE — it contributes only types. The runtime helpers
4
+ // (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`) resolve their route
5
+ // types from `Register` declared here.
6
+ //
7
+ // HOW IT WORKS
8
+ // ------------
9
+ // `Register` is an empty, augmentable interface. Running `bractjs codegen`
10
+ // writes `app/route-types.gen.ts`, which augments it:
11
+ //
12
+ // declare module "@bractjs/bractjs" {
13
+ // interface Register {
14
+ // routes: {
15
+ // routes: "/" | "/blog/:id";
16
+ // params: { "/blog/:id": { id: string } };
17
+ // search: RouteSearchParamsMap;
18
+ // };
19
+ // }
20
+ // }
21
+ //
22
+ // Once augmented, `<Link to="...">` autocompletes the app's routes and type-
23
+ // checks `params`; `useNavigate`, `useParams`, `useSearchParams` follow suit.
24
+ //
25
+ // GRACEFUL FALLBACK
26
+ // -----------------
27
+ // Un-augmented (no codegen, or codegen not yet run), `Register` is `{}`, so the
28
+ // `Register extends { routes: ... } ? ... : <loose>` conditionals below resolve
29
+ // to the loose `string` / `Record<string, string>` types BractJS used before.
30
+ // Existing apps therefore keep compiling unchanged — the typed surface only
31
+ // activates after the generated augmentation lands.
32
+ //
33
+ // NOTE: this seam is mirrored verbatim in `types/index.d.ts` (the published
34
+ // declaration surface). Keep the two in sync — a divergence silently disables
35
+ // typed routing for either monorepo or published consumers.
36
+
37
+ // ── The augmentable seam ─────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Augmentable registration interface. Empty by default; `route-types.gen.ts`
41
+ * augments it with this app's `RouteRegistry`. See file header.
42
+ */
43
+ export interface Register {}
44
+
45
+ /** The shape the generated file plugs into `Register["routes"]`. */
46
+ export interface RouteRegistry {
47
+ /** Union of all route patterns, colon-style — e.g. `"/" | "/blog/:id"`. */
48
+ routes: string;
49
+ /** Map of pattern → params object — e.g. `{ "/blog/:id": { id: string } }`. */
50
+ params: Record<string, Record<string, string>>;
51
+ /** Map of pattern → search-params object. */
52
+ search: Record<string, Record<string, string>>;
53
+ }
54
+
55
+ // ── Package-level customization maps (stable augmentation targets) ───────────
56
+ //
57
+ // Users type search params / context per route by augmenting THESE interfaces
58
+ // on the package, e.g.:
59
+ //
60
+ // declare module "@bractjs/bractjs" {
61
+ // interface RouteSearchParamsMap { "/posts": { page: string } }
62
+ // }
63
+ //
64
+ // The generated file seeds every route with a permissive default; user
65
+ // augmentations merge on top.
66
+
67
+ /** Per-route search-params shapes. Augment to type a route's search params. */
68
+ export interface RouteSearchParamsMap {}
69
+
70
+ /** Per-route context shapes. Augment to type a route's `context`. */
71
+ export interface RouteContextMap {}
72
+
73
+ // ── Resolution helpers (drive the runtime hooks/components) ──────────────────
74
+
75
+ // NOTE: these conditionals deliberately do NOT use `infer R extends RouteRegistry`.
76
+ // A constrained `infer` here silently fails to match the generated registry (its
77
+ // `search` member is an empty interface, which trips the constraint check) and
78
+ // falls back to the loose branch — defeating the whole feature. We instead infer
79
+ // each member's shape directly. `RouteRegistry` remains the documented contract
80
+ // the generated file targets.
81
+
82
+ /**
83
+ * The app's route union when registered, else `string`. The fallback is what
84
+ * keeps un-codegen'd apps compiling: every `to` still accepts any string.
85
+ */
86
+ export type RegisteredRoutes =
87
+ Register extends { routes: { routes: infer R } } ? R : string;
88
+
89
+ /** Pattern → params map when registered, else a permissive map. */
90
+ export type RegisteredParamsMap =
91
+ Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
92
+
93
+ /** Pattern → search map when registered, else a permissive map. */
94
+ export type RegisteredSearchMap =
95
+ Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
96
+
97
+ /** Params object for a specific route literal (`{}` for static routes). */
98
+ export type ParamsFor<TTo> =
99
+ TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
100
+
101
+ /** Search-params object for a specific route literal. */
102
+ export type SearchFor<TTo> =
103
+ TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
104
+
105
+ /** Whether a route literal carries any path params. Reserved for a future strict `<Link>` mode. */
106
+ export type HasParams<TTo> =
107
+ keyof ParamsFor<TTo> extends never ? false : true;
@@ -78,29 +78,32 @@ const HEADER =
78
78
  "\n" +
79
79
  "/* eslint-disable */\n";
80
80
 
81
+ // `RouteSearchParamsMap` / `RouteContextMap` are owned by the package
82
+ // (`@bractjs/bractjs`) so users have a single stable module to augment. We do
83
+ // NOT seed per-route keys here: seeding `"/posts": Record<string,string>` would
84
+ // conflict with a user augmentation declaring `"/posts": { page: string }`
85
+ // (duplicate-property error). Instead `SearchParams<T>` / `Context<T>` fall back
86
+ // to the permissive default for any route the user hasn't augmented.
81
87
  function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
82
- // Route files may declare `export type SearchParams = { page: string }`.
83
- // We emit a mapped type that falls back to Record<string,string> per route.
84
- // Users augment via module augmentation or via their route file's export.
85
88
  if (routes.length === 0) {
86
89
  return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
87
90
  }
88
91
  const branches = routes
89
92
  .map((r) => {
90
93
  assertSafePattern(r.pattern);
91
- return " T extends " + JSON.stringify(r.pattern) + " ? RouteSearchParamsMap[" + JSON.stringify(r.pattern) + "] :";
94
+ const key = JSON.stringify(r.pattern);
95
+ // Use `extends Record<K, infer V>` rather than `keyof` + index access:
96
+ // RouteSearchParamsMap may be `{}` (no user augmentation), and indexing an
97
+ // empty interface — even inside a `K extends keyof M` guard — trips
98
+ // TS2538 "cannot be used as an index type". The Record-infer form resolves
99
+ // V only when the route is augmented, and falls back otherwise.
100
+ return " T extends " + key +
101
+ " ? (RouteSearchParamsMap extends Record<" + key + ", infer V> ? V : Record<string, string>) :";
92
102
  })
93
103
  .join("\n");
94
- const mapEntries = routes
95
- .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
96
- .join("\n");
97
104
  return [
98
- "// Augment RouteSearchParamsMap to type search params per route:",
99
- "// declare module 'bractjs' { interface RouteSearchParamsMap { '/blog': { page: string } } }",
100
- "export interface RouteSearchParamsMap {",
101
- mapEntries,
102
- "}",
103
- "",
105
+ "// Augment RouteSearchParamsMap (on the package) to type a route's search params:",
106
+ "// declare module \"@bractjs/bractjs\" { interface RouteSearchParamsMap { \"/blog\": { page: string } } }",
104
107
  "export type SearchParams<T extends AppRoutes> =",
105
108
  branches,
106
109
  " Record<string, string>;",
@@ -114,25 +117,51 @@ function contextTypeLines(routes: Array<{ pattern: string }>): string {
114
117
  const branches = routes
115
118
  .map((r) => {
116
119
  assertSafePattern(r.pattern);
117
- return " T extends " + JSON.stringify(r.pattern) + " ? RouteContextMap[" + JSON.stringify(r.pattern) + "] :";
120
+ const key = JSON.stringify(r.pattern);
121
+ // See SearchParams above for why this uses `extends Record<K, infer V>`.
122
+ return " T extends " + key +
123
+ " ? (RouteContextMap extends Record<" + key + ", infer V> ? V : Record<string, unknown>) :";
118
124
  })
119
125
  .join("\n");
120
- const mapEntries = routes
121
- .map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
122
- .join("\n");
123
126
  return [
124
- "// Augment RouteContextMap to type context per route:",
125
- "// declare module 'bractjs' { interface RouteContextMap { '/blog': { user: User } } }",
126
- "export interface RouteContextMap {",
127
- mapEntries,
128
- "}",
129
- "",
127
+ "// Augment RouteContextMap (on the package) to type a route's context:",
128
+ "// declare module \"@bractjs/bractjs\" { interface RouteContextMap { \"/blog\": { user: User } } }",
130
129
  "export type Context<T extends AppRoutes> =",
131
130
  branches,
132
131
  " Record<string, unknown>;",
133
132
  ].join("\n");
134
133
  }
135
134
 
135
+ // The `Register` augmentation: this is what wires the app's routes into the
136
+ // package's runtime helpers (<Link>, useNavigate, useParams, useSearchParams).
137
+ function registerAugmentationLines(routes: Array<{ pattern: string; params: string[] }>): string {
138
+ const paramEntries = routes
139
+ .map((r) => {
140
+ assertSafePattern(r.pattern);
141
+ r.params.forEach(assertSafeParam);
142
+ const shape = r.params.length === 0
143
+ ? "{}"
144
+ : "{ " + r.params.map((p) => p + ": string").join("; ") + " }";
145
+ return " " + JSON.stringify(r.pattern) + ": " + shape + ";";
146
+ })
147
+ .join("\n");
148
+ return [
149
+ "// Registers this app's routes with BractJS. After this augmentation, <Link>,",
150
+ "// useNavigate, useParams, and useSearchParams are type-safe against AppRoutes.",
151
+ "declare module \"@bractjs/bractjs\" {",
152
+ " interface Register {",
153
+ " routes: {",
154
+ " routes: AppRoutes;",
155
+ " params: {",
156
+ paramEntries,
157
+ " };",
158
+ " search: RouteSearchParamsMap;",
159
+ " };",
160
+ " }",
161
+ "}",
162
+ ].join("\n");
163
+ }
164
+
136
165
  export async function generateRouteTypes(appDir: string): Promise<string> {
137
166
  const routeFiles = await scanRoutes(appDir);
138
167
  const routes = routeFiles.map((r) => ({
@@ -149,8 +178,15 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
149
178
 
150
179
  const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
151
180
 
181
+ // `RouteSearchParamsMap` / `RouteContextMap` are imported from the package so
182
+ // the local `SearchParams<T>` / `Context<T>` reference the same interfaces the
183
+ // user augments via `declare module "@bractjs/bractjs"`.
184
+ const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs";';
185
+
152
186
  return [
153
187
  HEADER,
188
+ IMPORTS,
189
+ "",
154
190
  "export type AppRoutes =",
155
191
  union + ";",
156
192
  "",
@@ -175,6 +211,9 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
175
211
  builderEntries,
176
212
  "} as const;",
177
213
  "",
214
+ // No routes → AppRoutes is `never`; nothing to register.
215
+ routes.length > 0 ? registerAugmentationLines(routes) : "",
216
+ "",
178
217
  ].join("\n");
179
218
  }
180
219
 
package/src/index.ts CHANGED
@@ -108,6 +108,8 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
108
108
  export { useActionData } from "./client/hooks/useActionData.ts";
109
109
  export { useParams } from "./client/hooks/useParams.ts";
110
110
  export { useNavigation } from "./client/hooks/useNavigation.ts";
111
+ export { useNavigate } from "./client/hooks/useNavigate.ts";
112
+ export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
111
113
  export { useFetcher } from "./client/hooks/useFetcher.ts";
112
114
  export { useSearchParams } from "./client/hooks/useSearchParams.ts";
113
115
  export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
@@ -115,6 +117,21 @@ export { useBlocker } from "./client/hooks/useBlocker.ts";
115
117
  export { useLocale } from "./client/hooks/useLocale.ts";
116
118
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
117
119
 
120
+ // Typed-routing registration seam. Augment `Register` (done by `bractjs codegen`
121
+ // in app/route-types.gen.ts) to make <Link>, useNavigate, useParams, and
122
+ // useSearchParams type-safe. Augment RouteSearchParamsMap / RouteContextMap to
123
+ // type a route's search params / context.
124
+ export type {
125
+ Register,
126
+ RouteRegistry,
127
+ RegisteredRoutes,
128
+ ParamsFor,
129
+ SearchFor,
130
+ RouteSearchParamsMap,
131
+ RouteContextMap,
132
+ } from "./client/registry.ts";
133
+ export { buildPath } from "./client/build-path.ts";
134
+
118
135
  // i18n utilities (server-side)
119
136
  export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
120
137
  export type { I18nConfig } from "./server/serve.ts";
package/types/index.d.ts CHANGED
@@ -129,13 +129,49 @@ export declare function validate<T>(
129
129
  input: FormData | Record<string, unknown>,
130
130
  ): Promise<T>;
131
131
 
132
+ // ── Typed-routing registration seam ───────────────────────────────────────
133
+ // Mirror of src/client/registry.ts. Augment `Register` (done by `bractjs codegen`
134
+ // in app/route-types.gen.ts) to make <Link>/useNavigate/useParams/useSearchParams
135
+ // type-safe. Un-augmented, everything falls back to loose `string` / Record so
136
+ // apps that never run codegen keep compiling. Keep in sync with registry.ts.
137
+ export interface Register {}
138
+ export interface RouteRegistry {
139
+ routes: string;
140
+ params: Record<string, Record<string, string>>;
141
+ search: Record<string, Record<string, string>>;
142
+ }
143
+ export interface RouteSearchParamsMap {}
144
+ export interface RouteContextMap {}
145
+ // Infer each member directly (NOT `infer R extends RouteRegistry` — a constrained
146
+ // infer fails to match the generated registry and falls back to loose). Keep in
147
+ // sync with src/client/registry.ts.
148
+ export type RegisteredRoutes =
149
+ Register extends { routes: { routes: infer R } } ? R : string;
150
+ export type RegisteredParamsMap =
151
+ Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
152
+ export type RegisteredSearchMap =
153
+ Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
154
+ export type ParamsFor<TTo> =
155
+ TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
156
+ export type SearchFor<TTo> =
157
+ TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
158
+ export declare function buildPath(pattern: string, params: Record<string, string | number>): string;
159
+
132
160
  // ── Client components ─────────────────────────────────────────────────────
133
161
  export declare function Scripts(): null;
134
162
  export declare function LiveReload(): ReactNode;
135
163
  export declare function Outlet(): ReactNode;
136
164
 
137
- export interface LinkProps { to: string; prefetch?: "hover" | "none"; viewTransition?: boolean; children?: ReactNode; className?: string; [key: string]: unknown; }
138
- export declare function Link(props: LinkProps): ReactNode;
165
+ export type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = {
166
+ to: TTo | (string & {});
167
+ params?: ParamsFor<TTo>;
168
+ prefetch?: "hover" | "none";
169
+ viewTransition?: boolean;
170
+ children?: ReactNode;
171
+ className?: string;
172
+ [key: string]: unknown;
173
+ };
174
+ export declare function Link<TTo extends RegisteredRoutes = RegisteredRoutes>(props: LinkProps<TTo>): ReactNode;
139
175
 
140
176
  export interface FormProps { method?: "post" | "put" | "delete"; action?: string; children?: ReactNode; [key: string]: unknown; }
141
177
  export declare function Form(props: FormProps): ReactNode;
@@ -163,9 +199,16 @@ export declare function Image(props: ImageProps): ReactNode;
163
199
  // ── Client hooks ──────────────────────────────────────────────────────────
164
200
  export declare function useLoaderData<T = unknown>(): T;
165
201
  export declare function useActionData<T = unknown>(): T | null;
166
- export declare function useParams(): Record<string, string>;
202
+ export declare function useParams<TTo extends string>(): ParamsFor<TTo>;
203
+ export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
167
204
  export type NavigationState = "idle" | "loading" | "submitting";
168
205
  export declare function useNavigation(): { state: NavigationState };
206
+
207
+ export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> { params?: ParamsFor<TTo>; }
208
+ export interface NavigateFn {
209
+ <TTo extends RegisteredRoutes>(to: TTo | (string & {}), options?: NavigateOptions<TTo>): Promise<void>;
210
+ }
211
+ export declare function useNavigate(): NavigateFn;
169
212
  export interface FetcherResult {
170
213
  data: unknown;
171
214
  state: NavigationState;
@@ -184,6 +227,7 @@ export interface SearchParamsResult<T extends Record<string, string> = Record<st
184
227
  getParam<K extends keyof T & string>(key: K): T[K] | null;
185
228
  setSearchParams(updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams)): void;
186
229
  }
230
+ export declare function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
187
231
  export declare function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
188
232
 
189
233
  // ── Typed route context ───────────────────────────────────────────────────