@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.
- package/README.md +283 -58
- package/bin/cli.ts +18 -1
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +64 -1
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/integration.test.ts +56 -0
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +74 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +239 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +239 -47
- package/src/client/build-path.ts +24 -0
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +105 -11
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useNavigate.ts +51 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +21 -6
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +131 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +28 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/route-codegen.ts +201 -29
- package/src/config/load.ts +21 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +44 -3
- package/src/server/action-handler.ts +12 -3
- package/src/server/action-registry.ts +35 -0
- package/src/server/csp.ts +10 -1
- package/src/server/csrf.ts +26 -0
- package/src/server/env.ts +26 -5
- package/src/server/layout.ts +31 -1
- package/src/server/loader.ts +14 -8
- package/src/server/render.ts +18 -3
- package/src/server/request-handler.ts +50 -8
- package/src/server/search.ts +43 -0
- package/src/server/serve.ts +88 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +85 -13
- package/src/shared/context.ts +5 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +83 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +21 -0
- package/types/index.d.ts +210 -10
- package/types/route.d.ts +62 -2
|
@@ -1,27 +1,57 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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?:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
128
|
+
fetcherStore.update(key, { data: json.route });
|
|
92
129
|
} finally {
|
|
93
|
-
|
|
130
|
+
fetcherStore.update(key, { state: "idle" });
|
|
94
131
|
}
|
|
95
|
-
}
|
|
132
|
+
}, [key]);
|
|
96
133
|
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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(
|
|
70
|
-
return
|
|
84
|
+
const getParam = useCallback((key: string): string | null => {
|
|
85
|
+
return searchParams.get(key);
|
|
71
86
|
}, [searchParams]);
|
|
72
87
|
|
|
73
88
|
return { searchParams, getParam, setSearchParams };
|
package/src/client/nav-utils.ts
CHANGED
|
@@ -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
|
/**
|