@bractjs/bractjs 0.1.27 → 0.1.29

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 (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
@@ -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,32 @@
1
+ import { useContext } from "react";
2
+ import { RouterContext } from "../router.tsx";
3
+ import { BractJSContext } from "../../shared/context.ts";
4
+ import type { RouteMatch } from "../../shared/route-types.ts";
5
+
6
+ /**
7
+ * Returns the matched route chain, outermost → innermost: the root, then each
8
+ * layout, then the leaf route. Each entry exposes `{ id, pathname, params,
9
+ * data, handle }`, where `handle` is that module's static `handle` export.
10
+ *
11
+ * Use it to build breadcrumbs or conditional chrome from `handle` without
12
+ * threading props through every layout. Works in both SSR and client contexts;
13
+ * the chain updates on soft navigation and revalidation.
14
+ *
15
+ * ```tsx
16
+ * // routes/blog/[id].tsx
17
+ * export const handle = { breadcrumb: "Post" };
18
+ *
19
+ * // some layout
20
+ * const crumbs = useMatches()
21
+ * .filter((m) => m.handle?.breadcrumb)
22
+ * .map((m) => m.handle!.breadcrumb as string);
23
+ * ```
24
+ *
25
+ * `handle` must be JSON-serializable — it travels in the SSR bootstrap and the
26
+ * `/_data` soft-nav payload, the same as loader data.
27
+ */
28
+ export function useMatches(): RouteMatch[] {
29
+ const router = useContext(RouterContext);
30
+ const bract = useContext(BractJSContext);
31
+ return router?.matches ?? bract?.matches ?? [];
32
+ }
@@ -1,13 +1,20 @@
1
1
  import { useContext, useCallback } from "react";
2
2
  import { NavigationContext } from "../router.tsx";
3
3
  import { buildPath } from "../build-path.ts";
4
- import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
4
+ import { withSearch } from "../search-serializer.ts";
5
+ import type { RegisteredRoutes, ParamsFor, SearchOutputFor } from "../registry.ts";
5
6
 
6
7
  // ── Types ──────────────────────────────────────────────────────────────────
7
8
 
8
9
  export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> {
9
10
  /** Path params for a dynamic `to` (e.g. `{ params: { id } }` for `/blog/:id`). */
10
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;
11
18
  }
12
19
 
13
20
  export interface NavigateFn {
@@ -27,19 +34,17 @@ export interface NavigateFn {
27
34
  *
28
35
  * SSR-safe and safe outside a `ClientRouter`: with no NavigationContext it
29
36
  * 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
37
  */
34
38
  export function useNavigate(): NavigateFn {
35
39
  const navCtx = useContext(NavigationContext);
36
40
  return useCallback<NavigateFn>(
37
41
  (to, options) => {
38
- const href = options?.params
42
+ const base = options?.params
39
43
  ? buildPath(to as string, options.params as Record<string, string>)
40
44
  : (to as string);
45
+ const href = withSearch(base, options?.search as Record<string, unknown> | undefined);
41
46
  if (!navCtx) return Promise.resolve();
42
- return navCtx.navigate(href);
47
+ return navCtx.navigate(href, { replace: options?.replace, state: options?.state });
43
48
  },
44
49
  [navCtx],
45
50
  );
@@ -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
+ }
@@ -18,8 +18,13 @@ export interface SearchParamsResult<T extends Record<string, string>> {
18
18
  // ── Hook ───────────────────────────────────────────────────────────────────
19
19
 
20
20
  /**
21
- * Reads and writes URL search params. Triggers a loader re-run (soft-nav fetch)
22
- * 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.
23
28
  *
24
29
  * Pass the route pattern as a generic to type the result against your codegen'd
25
30
  * routes: `useSearchParams<"/posts">()`. Augment `RouteSearchParamsMap` to give a
@@ -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
  /**
@@ -1,32 +1,127 @@
1
1
  import type { ServerManifest } from "../server/render.ts";
2
- import { matchPatternForPath } from "./nav-utils.ts";
2
+ import { matchPatternForPath, parseTo } from "./nav-utils.ts";
3
+ import { loaderCache, cacheKey } from "./cache.ts";
3
4
 
4
- // ── Cache ──────────────────────────────────────────────────────────────────
5
+ // ── State ──────────────────────────────────────────────────────────────────
5
6
 
6
- const prefetched = new Set<string>();
7
+ /** In-flight prefetches by path — concurrent callers share one task. */
8
+ const inflight = new Map<string, Promise<void>>();
9
+
10
+ /** Chunk hrefs already given a <link rel="modulepreload"> tag. */
11
+ const preloadedChunks = new Set<string>();
12
+
13
+ // Cap concurrent /_data prefetches so viewport-prefetching a long list of
14
+ // links cannot stampede the server. Chunk modulepreload is cheap (served
15
+ // immutable from cache) and stays uncapped.
16
+ const MAX_DATA_PREFETCHES = 6;
17
+ let activeDataPrefetches = 0;
18
+
19
+ // Prefetched data gets at least this freshness window so a hover→click within
20
+ // it commits straight from cache with zero extra requests (same idea as
21
+ // TanStack Router's preloadStaleTime). Routes with a longer `config.staleTime`
22
+ // keep their own.
23
+ const PREFETCH_STALE_TIME = 30_000;
24
+ const PREFETCH_GC_TIME = 60_000;
7
25
 
8
26
  // ── Implementation ─────────────────────────────────────────────────────────
9
27
 
10
28
  /**
11
- * Prefetches both the route chunk and loader data for the given path.
12
- * De-duplicates via a module-level Set safe to call on every hover.
29
+ * Prefetches the route chunk (via `<link rel="modulepreload">`) and warms the
30
+ * loader cache for the given path, under the same cache key the router
31
+ * computes — so the eventual navigation commits instantly. Best-effort and
32
+ * de-duplicated; safe to call on every hover.
13
33
  */
14
- export function prefetchRoute(path: string, manifest: ServerManifest): void {
15
- if (prefetched.has(path)) return;
16
- prefetched.add(path);
34
+ export function prefetchRoute(path: string, manifest: ServerManifest): Promise<void> {
35
+ const existing = inflight.get(path);
36
+ if (existing) return existing;
37
+
38
+ const task = doPrefetch(path, manifest)
39
+ .catch(() => { /* prefetch is best-effort — never surface errors */ })
40
+ .finally(() => { inflight.delete(path); });
41
+ inflight.set(path, task);
42
+ return task;
43
+ }
17
44
 
18
- // Prefetch route chunk via <link rel="modulepreload">
19
- const pattern = matchPatternForPath(path, manifest);
45
+ async function doPrefetch(path: string, manifest: ServerManifest): Promise<void> {
46
+ const { pathname, search } = parseTo(path);
47
+ const pattern = matchPatternForPath(pathname, manifest);
20
48
  const chunk = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
21
- if (chunk) {
49
+
50
+ // 1. Warm the route chunk.
51
+ if (chunk && !preloadedChunks.has(chunk)) {
52
+ preloadedChunks.add(chunk);
22
53
  const link = document.createElement("link");
23
54
  link.rel = "modulepreload";
24
55
  link.href = chunk;
25
56
  document.head.appendChild(link);
26
57
  }
27
58
 
28
- // Prefetch loader data at low priority
29
- void fetch(`/_data?path=${encodeURIComponent(path)}`, {
30
- priority: "low",
31
- } as RequestInit);
59
+ // 2. Warm the loader cache. Importing the (already preloaded) chunk yields
60
+ // config/loaderDeps, which the router uses to key the cache — without the
61
+ // module we fall back to the path-keyed default, matching loadRoute.
62
+ const mod = chunk
63
+ ? (await import(/* @vite-ignore */ chunk).catch(() => null)) as Record<string, unknown> | null
64
+ : null;
65
+ const routeConfig = mod?.config as { staleTime?: number; gcTime?: number } | undefined;
66
+ const loaderDepsFn = mod?.loaderDeps as
67
+ | ((args: { searchParams: URLSearchParams }) => unknown[])
68
+ | undefined;
69
+ const dataPath = pathname + search;
70
+ const deps = loaderDepsFn ? loaderDepsFn({ searchParams: new URLSearchParams(search) }) : [dataPath];
71
+ const key = cacheKey(pathname, deps);
72
+
73
+ if (loaderCache.get(key)?.fresh) return; // already warm
74
+ if (activeDataPrefetches >= MAX_DATA_PREFETCHES) return;
75
+
76
+ activeDataPrefetches++;
77
+ try {
78
+ const res = await fetch(`/_data?path=${encodeURIComponent(dataPath)}`, {
79
+ priority: "low",
80
+ } as RequestInit);
81
+ if (!res.ok) return;
82
+ const data = (await res.json()) as Record<string, unknown>;
83
+ loaderCache.set(
84
+ key,
85
+ data,
86
+ Math.max(routeConfig?.staleTime ?? 0, PREFETCH_STALE_TIME),
87
+ Math.max(routeConfig?.gcTime ?? 0, PREFETCH_GC_TIME),
88
+ );
89
+ } finally {
90
+ activeDataPrefetches--;
91
+ }
92
+ }
93
+
94
+ // ── Viewport observation (shared) ──────────────────────────────────────────
95
+
96
+ const observedCallbacks = new WeakMap<Element, () => void>();
97
+ let observer: IntersectionObserver | null = null;
98
+
99
+ /**
100
+ * Fire `callback` once when `el` first enters the viewport, via one shared
101
+ * IntersectionObserver (per-element observers are a perf trap on long lists).
102
+ * Returns an unsubscribe function. Environments without IntersectionObserver
103
+ * fire immediately.
104
+ */
105
+ export function observeOnce(el: Element, callback: () => void): () => void {
106
+ if (typeof IntersectionObserver === "undefined") {
107
+ callback();
108
+ return () => {};
109
+ }
110
+ if (!observer) {
111
+ observer = new IntersectionObserver((entries) => {
112
+ for (const entry of entries) {
113
+ if (!entry.isIntersecting) continue;
114
+ const fire = observedCallbacks.get(entry.target);
115
+ observedCallbacks.delete(entry.target);
116
+ observer!.unobserve(entry.target);
117
+ if (fire) fire();
118
+ }
119
+ });
120
+ }
121
+ observedCallbacks.set(el, callback);
122
+ observer.observe(el);
123
+ return () => {
124
+ observedCallbacks.delete(el);
125
+ observer?.unobserve(el);
126
+ };
32
127
  }
@@ -94,6 +94,15 @@ export type RegisteredParamsMap =
94
94
  export type RegisteredSearchMap =
95
95
  Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
96
96
 
97
+ /**
98
+ * Pattern → VALIDATED search shape (the output of each route's `searchSchema`),
99
+ * registered by codegen under `Register.routes.searchOutput`. Distinct from
100
+ * `RegisteredSearchMap`, which stays string-valued for the legacy
101
+ * `useSearchParams` surface.
102
+ */
103
+ export type RegisteredSearchOutputMap =
104
+ Register extends { routes: { searchOutput: infer S } } ? S : Record<string, Record<string, unknown>>;
105
+
97
106
  /** Params object for a specific route literal (`{}` for static routes). */
98
107
  export type ParamsFor<TTo> =
99
108
  TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
@@ -102,6 +111,21 @@ export type ParamsFor<TTo> =
102
111
  export type SearchFor<TTo> =
103
112
  TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
104
113
 
114
+ /** Validated (schema-output) search object for a specific route literal. */
115
+ export type SearchOutputFor<TTo> =
116
+ TTo extends keyof RegisteredSearchOutputMap ? RegisteredSearchOutputMap[TTo] : Record<string, unknown>;
117
+
118
+ /**
119
+ * Infer the output type of a Zod/Valibot-compatible schema — the duck-typed
120
+ * counterpart of `z.infer`. Used by the generated route types to derive each
121
+ * route's search shape from its `searchSchema` export.
122
+ */
123
+ export type InferSchemaOutput<S> =
124
+ S extends { parse(input: unknown): infer T } ? T :
125
+ S extends { safeParse(input: unknown): infer R }
126
+ ? (Awaited<R> extends { data?: infer T } ? NonNullable<T> : Record<string, unknown>)
127
+ : Record<string, unknown>;
128
+
105
129
  /** Whether a route literal carries any path params. Reserved for a future strict `<Link>` mode. */
106
130
  export type HasParams<TTo> =
107
131
  keyof ParamsFor<TTo> extends never ? false : true;
@@ -0,0 +1,25 @@
1
+ // Bridge between the router and fetchers. `useFetcher().submit` must trigger a
2
+ // loader revalidation after its mutation, but the hook module cannot import
3
+ // ClientRouter (circular); instead the router registers its revalidate
4
+ // function here on mount.
5
+
6
+ export interface RevalidationInfo {
7
+ /** The mutation's HTTP method, when revalidation follows an action. */
8
+ formMethod?: string;
9
+ /** The action response status, when mutation-triggered. */
10
+ actionStatus?: number;
11
+ }
12
+
13
+ type RevalidateFn = (info?: RevalidationInfo) => Promise<void>;
14
+
15
+ let currentRevalidator: RevalidateFn | null = null;
16
+
17
+ /** Called by ClientRouter on mount/unmount. Not part of the public API. */
18
+ export function registerRevalidator(fn: RevalidateFn | null): void {
19
+ currentRevalidator = fn;
20
+ }
21
+
22
+ /** Revalidate the active route's loaders, if a router is mounted. */
23
+ export function triggerRevalidation(info?: RevalidationInfo): Promise<void> {
24
+ return currentRevalidator ? currentRevalidator(info) : Promise.resolve();
25
+ }
@@ -1,26 +1,52 @@
1
1
  import { createContext, useContext, type ComponentType } from "react";
2
2
  import type { ServerManifest } from "../server/render.ts";
3
+ import type { RouterLocation, RouteMatch } from "../shared/route-types.ts";
3
4
 
4
5
  // ── Route module shape visible on the client ───────────────────────────────
5
6
 
6
7
  export interface RouteModuleClient {
7
8
  default?: ComponentType;
8
9
  ErrorBoundary?: ComponentType<{ error: Error }>;
10
+ /** SSR'd placeholder for selective-SSR routes (`ssr: false` / `"data-only"`). */
11
+ Fallback?: ComponentType;
12
+ /** Browser-side loader (RR7-style). Runs on navigation instead of just fetching /_data. */
13
+ clientLoader?: import("../shared/route-types.ts").ClientLoaderFunction;
14
+ /** Browser-side action (RR7-style). Runs on submit instead of POSTing directly. */
15
+ clientAction?: import("../shared/route-types.ts").ClientActionFunction;
9
16
  }
10
17
 
18
+ /**
19
+ * Truthy while the initial client render must keep showing what the server
20
+ * sent instead of the real route component: the Fallback for selective-SSR
21
+ * documents, nothing for the SPA shell. Cleared (→ `false`) once loader data
22
+ * is in place.
23
+ */
24
+ export type HydrationPending = false | "client-only" | "data-only" | "spa";
25
+
11
26
  // ── Router Context ─────────────────────────────────────────────────────────
12
27
 
13
28
  export interface RouteState {
14
29
  loaderData: Record<string, unknown>;
15
30
  actionData: unknown;
16
31
  params: Record<string, string>;
32
+ /** Query-free pathname of the current route (kept alongside `location` for back-compat). */
17
33
  pathname: string;
34
+ location: RouterLocation;
35
+ /** Validated search params (route `searchSchema` output; raw string record otherwise). */
36
+ search: Record<string, unknown>;
37
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
38
+ matches: RouteMatch[];
18
39
  }
19
40
 
20
41
  export interface RouterContextValue extends RouteState {
21
42
  manifest: ServerManifest;
22
43
  currentModule: RouteModuleClient | null;
23
44
  setRoute(state: Partial<RouteState>): void;
45
+ /** Re-run the active route's loaders (gated by `shouldRevalidate`). */
46
+ revalidate(): Promise<void>;
47
+ /** "loading" while a revalidation is in flight. Distinct from the navigation state. */
48
+ revalidationState: "idle" | "loading";
49
+ hydrationPending: HydrationPending;
24
50
  }
25
51
 
26
52
  export const RouterContext = createContext<RouterContextValue>(null!);
@@ -37,9 +63,16 @@ export function useRouterContext(): RouterContextValue {
37
63
 
38
64
  export type NavigationState = "idle" | "loading" | "submitting";
39
65
 
66
+ export interface NavigateOptions {
67
+ /** Replace the current history entry instead of pushing a new one. */
68
+ replace?: boolean;
69
+ /** Arbitrary history state, readable via `useLocation().state` after the navigation. */
70
+ state?: unknown;
71
+ }
72
+
40
73
  export interface NavigationContextValue {
41
74
  state: NavigationState;
42
- navigate(to: string): Promise<void>;
75
+ navigate(to: string, options?: NavigateOptions): Promise<void>;
43
76
  submit(to: string, options: { method: string; body: FormData | Record<string, string> }): Promise<void>;
44
77
  }
45
78
 
package/src/client/rpc.ts CHANGED
@@ -49,9 +49,19 @@ export function createClient<
49
49
  const httpMethod = method.toUpperCase();
50
50
  const url = baseUrl + path;
51
51
  const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
52
+ // Send the custom CSRF marker on every mutating call. The server's
53
+ // /api CSRF gate accepts Sec-Fetch-Site / Origin too, but this
54
+ // header keeps same-origin calls working even behind proxies that
55
+ // strip those — and it can't be set cross-origin without a CORS
56
+ // preflight the framework's CORS never grants. Mirrors the
57
+ // server-action proxy in src/build/directives.ts.
58
+ const isMutating = httpMethod !== "GET";
59
+ const headers: Record<string, string> = {};
60
+ if (hasBody) headers["Content-Type"] = "application/json";
61
+ if (isMutating) headers["X-BractJS-Action"] = "1";
52
62
  const res = await fetch(url, {
53
63
  method: httpMethod,
54
- headers: hasBody ? { "Content-Type": "application/json" } : undefined,
64
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
55
65
  body: hasBody ? JSON.stringify(input) : undefined,
56
66
  });
57
67
  if (!res.ok) {
@@ -0,0 +1,48 @@
1
+ // Pure helpers for <ScrollRestoration /> — kept DOM-free so they are
2
+ // unit-testable under bun:test without a browser.
3
+
4
+ export const SCROLL_STORAGE_KEY = "bractjs:scroll";
5
+
6
+ /** Cap stored entries so sessionStorage doesn't grow unbounded in long sessions. */
7
+ export const MAX_SCROLL_ENTRIES = 50;
8
+
9
+ /**
10
+ * Record a scroll position for a history-entry key. Re-inserts the key so the
11
+ * map keeps insertion order as LRU order, then evicts the oldest entries past
12
+ * the cap.
13
+ */
14
+ export function savePosition(
15
+ positions: Map<string, number>,
16
+ key: string,
17
+ y: number,
18
+ max: number = MAX_SCROLL_ENTRIES,
19
+ ): void {
20
+ positions.delete(key);
21
+ positions.set(key, y);
22
+ while (positions.size > max) {
23
+ const oldest = positions.keys().next().value;
24
+ if (oldest === undefined) break;
25
+ positions.delete(oldest);
26
+ }
27
+ }
28
+
29
+ export function serializePositions(positions: Map<string, number>): string {
30
+ return JSON.stringify(Object.fromEntries(positions));
31
+ }
32
+
33
+ /** Tolerant parse: malformed/foreign payloads yield an empty map, never throw. */
34
+ export function deserializePositions(raw: string | null): Map<string, number> {
35
+ if (!raw) return new Map();
36
+ try {
37
+ const obj = JSON.parse(raw) as Record<string, unknown>;
38
+ const positions = new Map<string, number>();
39
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
40
+ for (const [key, value] of Object.entries(obj)) {
41
+ if (typeof value === "number" && Number.isFinite(value)) positions.set(key, value);
42
+ }
43
+ }
44
+ return positions;
45
+ } catch {
46
+ return new Map();
47
+ }
48
+ }