@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.
- package/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- 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/features-demo.tsx +28 -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__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- 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 +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- 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-fixes.test.ts +201 -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 +51 -1
- package/src/__tests__/use-matches.test.ts +54 -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 +339 -47
- 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 +80 -9
- 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/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -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/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -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 +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- 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
|
|
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,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
|
|
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
|
|
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
|
-
*
|
|
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
|
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
|
/**
|
package/src/client/prefetch.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
5
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
6
|
-
|
|
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
|
|
12
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
package/src/client/registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/router.tsx
CHANGED
|
@@ -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:
|
|
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
|
+
}
|