@bractjs/bractjs 0.1.26 → 0.1.28

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.
Files changed (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
@@ -1,27 +1,57 @@
1
- import { useState } from "react";
1
+ import {
2
+ createElement, useCallback, useEffect, useId, useMemo, useSyncExternalStore,
3
+ type FormEvent, type FormHTMLAttributes, type FunctionComponent, type ReactNode,
4
+ } from "react";
2
5
  import { toSamePath } from "../nav-utils.ts";
6
+ import { fetcherStore, type FetcherState } from "../fetcher-store.ts";
7
+ import { triggerRevalidation } from "../revalidation.ts";
3
8
 
4
9
  // ── Types ──────────────────────────────────────────────────────────────────
5
10
 
6
- type FetcherState = "idle" | "loading" | "submitting";
7
-
8
11
  interface SubmitOptions {
9
12
  method: string;
10
13
  body: FormData | Record<string, string>;
11
14
  }
12
15
 
13
- interface FetcherResult {
16
+ export interface FetcherFormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, "method" | "onSubmit"> {
17
+ method?: "post" | "put" | "delete";
18
+ action?: string;
19
+ /** Renders a hidden `intent` input (pairs with `defineActions()`). */
20
+ intent?: string;
21
+ children: ReactNode;
22
+ }
23
+
24
+ export interface FetcherResult {
14
25
  data: unknown;
15
26
  state: FetcherState;
27
+ /** The submitted FormData while a submission is in flight — the optimistic-UI source. */
28
+ formData?: FormData;
29
+ /** Uppercase method of the in-flight/last submission. */
30
+ formMethod?: string;
31
+ /** This fetcher's identity (explicit `key` option, or component-bound). */
32
+ key: string;
16
33
  load(path: string): Promise<void>;
17
34
  submit(path: string, opts: SubmitOptions): Promise<void>;
35
+ /** A `<fetcher.Form>` that submits through this fetcher (no navigation, no history). */
36
+ Form: FunctionComponent<FetcherFormProps>;
18
37
  }
19
38
 
20
39
  interface StreamFetcherResult<T = unknown> {
40
+ /** @deprecated Never emitted — call `connect(actionId)` instead. Removed in 0.2. */
21
41
  events: AsyncGenerator<T>;
22
42
  connect(actionId: string): AsyncGenerator<T>;
23
43
  }
24
44
 
45
+ export interface UseFetcherOptions {
46
+ /**
47
+ * Give the fetcher a stable identity. Keyed fetchers persist across
48
+ * unmounts and are shared by every component using the same key; unkeyed
49
+ * fetchers are removed from `useFetchers()` when their component unmounts.
50
+ */
51
+ key?: string;
52
+ stream?: boolean;
53
+ }
54
+
25
55
  // ── SSE async generator ────────────────────────────────────────────────────
26
56
 
27
57
  async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
@@ -66,46 +96,60 @@ async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
66
96
 
67
97
  // ── Hook ───────────────────────────────────────────────────────────────────
68
98
 
69
- export function useFetcher(): FetcherResult;
99
+ const EMPTY_ENTRY = undefined;
100
+
101
+ export function useFetcher(opts?: { key?: string }): FetcherResult;
70
102
  export function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
71
- export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | StreamFetcherResult<T> {
72
- if (opts?.stream) {
73
- return {
74
- events: (null as unknown) as AsyncGenerator<T>,
75
- connect(actionId: string): AsyncGenerator<T> {
76
- return sseStream<T>(actionId);
77
- },
78
- } satisfies StreamFetcherResult<T>;
79
- }
103
+ export function useFetcher<T = unknown>(opts?: UseFetcherOptions): FetcherResult | StreamFetcherResult<T> {
104
+ // All hooks run unconditionally — branching on opts.stream happens only in
105
+ // the returned value, so the rules of hooks hold for every variant.
106
+ const autoKey = useId();
107
+ const key = opts?.key ?? `__fetcher${autoKey}`;
108
+
109
+ const entry = useSyncExternalStore(
110
+ fetcherStore.subscribe,
111
+ () => fetcherStore.get(key),
112
+ () => EMPTY_ENTRY,
113
+ );
80
114
 
81
- // eslint-disable-next-line react-hooks/rules-of-hooks
82
- const [data, setData] = useState<unknown>(undefined);
83
- // eslint-disable-next-line react-hooks/rules-of-hooks
84
- const [state, setState] = useState<FetcherState>("idle");
115
+ // Unkeyed fetchers disappear from useFetchers() with their component; keyed
116
+ // ones persist so optimistic state survives remounts.
117
+ const isKeyed = opts?.key !== undefined;
118
+ useEffect(() => {
119
+ if (isKeyed) return;
120
+ return () => fetcherStore.remove(key);
121
+ }, [key, isKeyed]);
85
122
 
86
- async function load(path: string): Promise<void> {
87
- setState("loading");
123
+ const load = useCallback(async (path: string): Promise<void> => {
124
+ fetcherStore.update(key, { state: "loading" });
88
125
  try {
89
126
  const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
90
127
  const json = (await res.json()) as { route?: unknown };
91
- setData(json.route);
128
+ fetcherStore.update(key, { data: json.route });
92
129
  } finally {
93
- setState("idle");
130
+ fetcherStore.update(key, { state: "idle" });
94
131
  }
95
- }
132
+ }, [key]);
96
133
 
97
- async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
98
- setState("submitting");
134
+ const submit = useCallback(async (path: string, submitOpts: SubmitOptions): Promise<void> => {
135
+ const body =
136
+ submitOpts.body instanceof FormData
137
+ ? submitOpts.body
138
+ : new URLSearchParams(submitOpts.body as Record<string, string>);
139
+ const formMethod = submitOpts.method.toUpperCase();
140
+ // Expose the submission BEFORE the fetch — this is what optimistic UI
141
+ // renders while the mutation is in flight.
142
+ fetcherStore.update(key, {
143
+ state: "submitting",
144
+ formData: submitOpts.body instanceof FormData ? submitOpts.body : undefined,
145
+ formMethod,
146
+ });
99
147
  try {
100
- const body =
101
- submitOpts.body instanceof FormData
102
- ? submitOpts.body
103
- : new URLSearchParams(submitOpts.body as Record<string, string>);
104
148
  // Send the custom header so the server's CSRF gate accepts this
105
149
  // same-origin mutation (browsers block it cross-origin without a CORS
106
150
  // preflight). Without it every fetcher submit 403s.
107
151
  const res = await fetch(path, {
108
- method: submitOpts.method,
152
+ method: formMethod,
109
153
  body,
110
154
  headers: { "X-BractJS-Action": "1" },
111
155
  });
@@ -117,11 +161,50 @@ export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | Stre
117
161
  window.location.assign(to ?? res.url);
118
162
  return;
119
163
  }
120
- setData(await res.json());
164
+ fetcherStore.update(key, { data: await res.json() });
165
+ // Mutations invalidate loader data — re-run the active route's loaders
166
+ // (gated by its shouldRevalidate) so the page reflects the change.
167
+ fetcherStore.update(key, { state: "loading" });
168
+ await triggerRevalidation({ formMethod, actionStatus: res.status });
121
169
  } finally {
122
- setState("idle");
170
+ fetcherStore.update(key, { state: "idle", formData: undefined });
123
171
  }
172
+ }, [key]);
173
+
174
+ // Stable component identity across renders (remounting a form on every
175
+ // render would drop focus/IME state).
176
+ const FetcherForm = useMemo<FunctionComponent<FetcherFormProps>>(() => {
177
+ return function FetcherFormImpl({ method = "post", action, intent, children, ...rest }: FetcherFormProps) {
178
+ function handleSubmit(e: FormEvent<HTMLFormElement>) {
179
+ e.preventDefault();
180
+ const target = e.currentTarget;
181
+ const url = action ?? window.location.pathname + window.location.search;
182
+ void submit(url, { method, body: new FormData(target) });
183
+ }
184
+ const intentInput = intent !== undefined
185
+ ? createElement("input", { key: "__bract_intent", type: "hidden", name: "intent", value: intent })
186
+ : null;
187
+ return createElement("form", { method, onSubmit: handleSubmit, ...rest }, intentInput, children);
188
+ };
189
+ }, [submit]);
190
+
191
+ if (opts?.stream) {
192
+ return {
193
+ events: (null as unknown) as AsyncGenerator<T>,
194
+ connect(actionId: string): AsyncGenerator<T> {
195
+ return sseStream<T>(actionId);
196
+ },
197
+ } satisfies StreamFetcherResult<T>;
124
198
  }
125
199
 
126
- return { data, state, load, submit };
200
+ return {
201
+ data: entry?.data,
202
+ state: entry?.state ?? "idle",
203
+ formData: entry?.formData,
204
+ formMethod: entry?.formMethod,
205
+ key,
206
+ load,
207
+ submit,
208
+ Form: FetcherForm,
209
+ };
127
210
  }
@@ -0,0 +1,23 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { fetcherStore, EMPTY_FETCHERS, type FetcherEntry } from "../fetcher-store.ts";
3
+
4
+ /**
5
+ * Every active fetcher (keyed and mounted-unkeyed alike) — the cross-component
6
+ * view for optimistic UI. Example: a table dims each row whose keyed delete
7
+ * fetcher (`useFetcher({ key: "delete-" + id })`) is currently submitting:
8
+ *
9
+ * const deleting = new Set(
10
+ * useFetchers()
11
+ * .filter((f) => f.state === "submitting" && f.key.startsWith("delete-"))
12
+ * .map((f) => f.key.slice("delete-".length)),
13
+ * );
14
+ *
15
+ * SSR-safe: renders an empty list on the server.
16
+ */
17
+ export function useFetchers(): FetcherEntry[] {
18
+ return useSyncExternalStore(
19
+ fetcherStore.subscribe,
20
+ fetcherStore.getSnapshot,
21
+ () => EMPTY_FETCHERS,
22
+ );
23
+ }
@@ -1,14 +1,18 @@
1
1
  import { useContext } from "react";
2
2
  import { RouterContext } from "../router.tsx";
3
3
  import { BractJSContext } from "../../shared/context.ts";
4
+ import type { LoaderData } from "../../shared/route-types.ts";
4
5
 
5
6
  /**
6
- * Returns the current route's loader data, typed as T.
7
- * Works in both SSR and client contexts.
7
+ * Returns the current route's loader data. Works in both SSR and client contexts.
8
+ *
9
+ * Prefer passing the loader function type — `useLoaderData<typeof loader>()` —
10
+ * so the data type is inferred from the loader's return (no hand-written type to
11
+ * keep in sync). An explicit object type still works: `useLoaderData<HomeData>()`.
8
12
  */
9
- export function useLoaderData<T = unknown>(): T {
13
+ export function useLoaderData<T = unknown>(): LoaderData<T> {
10
14
  const router = useContext(RouterContext);
11
15
  const bract = useContext(BractJSContext);
12
16
  const loaderData = router?.loaderData ?? bract?.loaderData ?? {};
13
- return loaderData.route as T;
17
+ return loaderData.route as LoaderData<T>;
14
18
  }
@@ -0,0 +1,27 @@
1
+ import { useContext } from "react";
2
+ import { RouterContext } from "../router.tsx";
3
+ import { BractJSContext } from "../../shared/context.ts";
4
+ import type { RouterLocation } from "../../shared/route-types.ts";
5
+
6
+ /**
7
+ * The current location: `{ pathname, search, hash, state, key }`.
8
+ *
9
+ * Reactive on the client — re-renders on every navigation (including
10
+ * back/forward). SSR-safe: during server rendering it reflects the request URL
11
+ * (`hash` is always `""` there, since fragments never reach the server), so
12
+ * components rendering `pathname`/`search` hydrate without mismatch. Never
13
+ * reads `window.location` during render.
14
+ */
15
+ export function useLocation(): RouterLocation {
16
+ const routerCtx = useContext(RouterContext);
17
+ const bractCtx = useContext(BractJSContext);
18
+ if (routerCtx?.location) return routerCtx.location;
19
+ if (bractCtx?.location) return bractCtx.location;
20
+ return {
21
+ pathname: bractCtx?.pathname ?? "/",
22
+ search: "",
23
+ hash: "",
24
+ state: null,
25
+ key: "default",
26
+ };
27
+ }
@@ -0,0 +1,51 @@
1
+ import { useContext, useCallback } from "react";
2
+ import { NavigationContext } from "../router.tsx";
3
+ import { buildPath } from "../build-path.ts";
4
+ import { withSearch } from "../search-serializer.ts";
5
+ import type { RegisteredRoutes, ParamsFor, SearchOutputFor } from "../registry.ts";
6
+
7
+ // ── Types ──────────────────────────────────────────────────────────────────
8
+
9
+ export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> {
10
+ /** Path params for a dynamic `to` (e.g. `{ params: { id } }` for `/blog/:id`). */
11
+ params?: ParamsFor<TTo>;
12
+ /** Search params for the target, typed by its `searchSchema` (replaces any query in `to`). */
13
+ search?: Partial<SearchOutputFor<TTo>>;
14
+ /** Replace the current history entry instead of pushing a new one. */
15
+ replace?: boolean;
16
+ /** Arbitrary history state, readable via `useLocation().state` after navigating. */
17
+ state?: unknown;
18
+ }
19
+
20
+ export interface NavigateFn {
21
+ <TTo extends RegisteredRoutes>(
22
+ to: TTo | (string & {}),
23
+ options?: NavigateOptions<TTo>,
24
+ ): Promise<void>;
25
+ }
26
+
27
+ // ── Hook ───────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Returns a typed `navigate(to, { params })` for programmatic soft navigation —
31
+ * the imperative counterpart to `<Link>`. Mirrors `<Link>`'s `to`/`params` API:
32
+ * `to` autocompletes registered routes (after `bractjs codegen`) while still
33
+ * accepting any string, and `params` is typed per route.
34
+ *
35
+ * SSR-safe and safe outside a `ClientRouter`: with no NavigationContext it
36
+ * resolves to a no-op (same guard as `<Link>`), so it never throws during render.
37
+ */
38
+ export function useNavigate(): NavigateFn {
39
+ const navCtx = useContext(NavigationContext);
40
+ return useCallback<NavigateFn>(
41
+ (to, options) => {
42
+ const base = options?.params
43
+ ? buildPath(to as string, options.params as Record<string, string>)
44
+ : (to as string);
45
+ const href = withSearch(base, options?.search as Record<string, unknown> | undefined);
46
+ if (!navCtx) return Promise.resolve();
47
+ return navCtx.navigate(href, { replace: options?.replace, state: options?.state });
48
+ },
49
+ [navCtx],
50
+ );
51
+ }
@@ -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
  }
@@ -0,0 +1,26 @@
1
+ import { useContext } from "react";
2
+ import { RouterContext } from "../router.tsx";
3
+
4
+ export interface Revalidator {
5
+ /** Re-run the active route's loaders and commit fresh data. */
6
+ revalidate(): Promise<void>;
7
+ state: "idle" | "loading";
8
+ }
9
+
10
+ const noop = async (): Promise<void> => {};
11
+
12
+ /**
13
+ * Manually revalidate the current route's loader data — for "Refresh" buttons,
14
+ * window-focus refetching, polling, or after out-of-band mutations (e.g. a
15
+ * WebSocket message). Respects the route's `shouldRevalidate` export.
16
+ *
17
+ * `state` tracks only revalidation — it does not flip during navigations
18
+ * (that's `useNavigation()`).
19
+ *
20
+ * SSR-safe: without a ClientRouter it returns an idle no-op.
21
+ */
22
+ export function useRevalidator(): Revalidator {
23
+ const routerCtx = useContext(RouterContext);
24
+ if (!routerCtx) return { revalidate: noop, state: "idle" };
25
+ return { revalidate: routerCtx.revalidate, state: routerCtx.revalidationState };
26
+ }
@@ -0,0 +1,73 @@
1
+ import { useContext, useCallback } from "react";
2
+ import { RouterContext, NavigationContext } from "../router.tsx";
3
+ import { BractJSContext } from "../../shared/context.ts";
4
+ import { serializeSearch } from "../search-serializer.ts";
5
+ import type { SearchOutputFor } from "../registry.ts";
6
+
7
+ // ── useSearch ──────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * The current route's VALIDATED search params — the output of its
11
+ * `searchSchema` export (numbers/booleans/arrays/defaults already applied),
12
+ * or the raw string record for routes without a schema.
13
+ *
14
+ * Validation happens once, on the server; this hook reads the validated object
15
+ * that rode in with the page (`__BRACTJS_DATA__`) or the last soft-nav
16
+ * (`/_data`) — the client never re-runs the schema.
17
+ *
18
+ * Type it against your codegen'd routes with a route literal —
19
+ * `useSearch<"/posts">()` → `{ page: number }` — or pass an explicit shape:
20
+ * `useSearch<{ page: number }>()`. SSR-safe.
21
+ */
22
+ // Overload 1: a route literal → schema-output shape from the registry.
23
+ export function useSearch<TTo extends string>(): SearchOutputFor<TTo>;
24
+ // Overload 2: an explicit object shape.
25
+ export function useSearch<T extends Record<string, unknown>>(): T;
26
+ export function useSearch(): Record<string, unknown> {
27
+ const routerCtx = useContext(RouterContext);
28
+ const bractCtx = useContext(BractJSContext);
29
+ return routerCtx?.search ?? bractCtx?.search ?? {};
30
+ }
31
+
32
+ // ── useSetSearch ───────────────────────────────────────────────────────────
33
+
34
+ export interface SetSearchOptions {
35
+ /** Replace the current history entry instead of pushing a new one. */
36
+ replace?: boolean;
37
+ }
38
+
39
+ export type SetSearchFn<T extends Record<string, unknown>> = (
40
+ updater: Partial<T> | ((prev: T) => Partial<T>),
41
+ options?: SetSearchOptions,
42
+ ) => Promise<void>;
43
+
44
+ /**
45
+ * Returns a setter that merges a patch into the current search params,
46
+ * serializes the result back into the URL, and soft-navigates — so loaders
47
+ * re-run and the route's `searchSchema` re-validates server-side. Set a key
48
+ * to `undefined` to delete it.
49
+ *
50
+ * const setSearch = useSetSearch<"/posts">();
51
+ * setSearch({ page: 2 }); // patch
52
+ * setSearch((prev) => ({ page: prev.page + 1 })); // functional
53
+ *
54
+ * SSR-safe: without a ClientRouter it resolves to a no-op.
55
+ */
56
+ export function useSetSearch<TTo extends string>(): SetSearchFn<SearchOutputFor<TTo>>;
57
+ export function useSetSearch<T extends Record<string, unknown>>(): SetSearchFn<T>;
58
+ export function useSetSearch(): SetSearchFn<Record<string, unknown>> {
59
+ const routerCtx = useContext(RouterContext);
60
+ const navCtx = useContext(NavigationContext);
61
+
62
+ return useCallback<SetSearchFn<Record<string, unknown>>>(
63
+ (updater, options) => {
64
+ if (!routerCtx || !navCtx) return Promise.resolve();
65
+ const prev = routerCtx.search;
66
+ const patch = typeof updater === "function" ? updater(prev) : updater;
67
+ const next = { ...prev, ...patch };
68
+ const target = routerCtx.location.pathname + serializeSearch(next);
69
+ return navCtx.navigate(target, { replace: options?.replace });
70
+ },
71
+ [routerCtx, navCtx],
72
+ );
73
+ }
@@ -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,27 @@ 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
+ * Low-level read/write of raw `URLSearchParams` (string values only). Triggers a
22
+ * loader re-run (soft-nav fetch) when params change.
23
+ *
24
+ * Prefer `useSearch()` / `useSetSearch()` when the route has a `searchSchema`:
25
+ * those return the VALIDATED, coerced object (numbers stay numbers, defaults
26
+ * applied) and accept typed patches. Reach for `useSearchParams` only when you
27
+ * want raw string access or the route has no schema.
28
+ *
29
+ * Pass the route pattern as a generic to type the result against your codegen'd
30
+ * routes: `useSearchParams<"/posts">()`. Augment `RouteSearchParamsMap` to give a
31
+ * route a concrete shape (defaults to `Record<string, string>`). The pattern is
32
+ * supplied by the caller — the framework can't infer the active route at the type
33
+ * level. An object generic — `useSearchParams<{ page: string }>()` — also works.
22
34
  *
23
- * T is the route's SearchParams shape (e.g. { page: string; sort: string }).
24
35
  * This hook is SSR-safe: on the server window is absent, so it returns empty params.
25
36
  */
26
- export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
37
+ // Overload 1: a route literal search shape resolved from the registry.
38
+ export function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
39
+ // Overload 2: an explicit object shape (back-compat with the old generic form).
40
+ export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
41
+ export function useSearchParams(): SearchParamsResult<Record<string, string>> {
27
42
  const navCtx = useContext(NavigationContext);
28
43
 
29
44
  function readCurrent(): URLSearchParams {
@@ -66,8 +81,8 @@ export function useSearchParams<T extends Record<string, string> = Record<string
66
81
  }
67
82
  }, [navCtx]);
68
83
 
69
- const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
70
- return (searchParams.get(key) as T[K] | null);
84
+ const getParam = useCallback((key: string): string | null => {
85
+ return searchParams.get(key);
71
86
  }, [searchParams]);
72
87
 
73
88
  return { searchParams, getParam, setSearchParams };
@@ -22,6 +22,32 @@ export function toSamePath(loc: string): string | null {
22
22
  }
23
23
  }
24
24
 
25
+ // ── Navigation target parsing ──────────────────────────────────────────────
26
+
27
+ /**
28
+ * Split an internal navigation target ("/path", "/path?q", "/path#h",
29
+ * "/path?q#h") into its parts. Callers must normalize absolute URLs through
30
+ * `toSamePath()` first — this is a pure string split, not a URL parser.
31
+ */
32
+ export function parseTo(to: string): { pathname: string; search: string; hash: string } {
33
+ const hashIdx = to.indexOf("#");
34
+ const hash = hashIdx === -1 ? "" : to.slice(hashIdx);
35
+ const beforeHash = hashIdx === -1 ? to : to.slice(0, hashIdx);
36
+ const searchIdx = beforeHash.indexOf("?");
37
+ const search = searchIdx === -1 ? "" : beforeHash.slice(searchIdx);
38
+ const pathname = searchIdx === -1 ? beforeHash : beforeHash.slice(0, searchIdx);
39
+ return { pathname: pathname || "/", search, hash };
40
+ }
41
+
42
+ /** Random short key identifying a history entry (scroll restoration identity). */
43
+ export function createLocationKey(): string {
44
+ try {
45
+ return crypto.randomUUID().slice(0, 8);
46
+ } catch {
47
+ return Math.random().toString(36).slice(2, 10);
48
+ }
49
+ }
50
+
25
51
  // ── Pattern Matching ───────────────────────────────────────────────────────
26
52
 
27
53
  /**