@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
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
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Type-registration seam for end-to-end typed routing (TanStack-Router style).
|
|
2
|
+
//
|
|
3
|
+
// This file is RUNTIME-FREE — it contributes only types. The runtime helpers
|
|
4
|
+
// (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`) resolve their route
|
|
5
|
+
// types from `Register` declared here.
|
|
6
|
+
//
|
|
7
|
+
// HOW IT WORKS
|
|
8
|
+
// ------------
|
|
9
|
+
// `Register` is an empty, augmentable interface. Running `bractjs codegen`
|
|
10
|
+
// writes `app/route-types.gen.ts`, which augments it:
|
|
11
|
+
//
|
|
12
|
+
// declare module "@bractjs/bractjs" {
|
|
13
|
+
// interface Register {
|
|
14
|
+
// routes: {
|
|
15
|
+
// routes: "/" | "/blog/:id";
|
|
16
|
+
// params: { "/blog/:id": { id: string } };
|
|
17
|
+
// search: RouteSearchParamsMap;
|
|
18
|
+
// };
|
|
19
|
+
// }
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Once augmented, `<Link to="...">` autocompletes the app's routes and type-
|
|
23
|
+
// checks `params`; `useNavigate`, `useParams`, `useSearchParams` follow suit.
|
|
24
|
+
//
|
|
25
|
+
// GRACEFUL FALLBACK
|
|
26
|
+
// -----------------
|
|
27
|
+
// Un-augmented (no codegen, or codegen not yet run), `Register` is `{}`, so the
|
|
28
|
+
// `Register extends { routes: ... } ? ... : <loose>` conditionals below resolve
|
|
29
|
+
// to the loose `string` / `Record<string, string>` types BractJS used before.
|
|
30
|
+
// Existing apps therefore keep compiling unchanged — the typed surface only
|
|
31
|
+
// activates after the generated augmentation lands.
|
|
32
|
+
//
|
|
33
|
+
// NOTE: this seam is mirrored verbatim in `types/index.d.ts` (the published
|
|
34
|
+
// declaration surface). Keep the two in sync — a divergence silently disables
|
|
35
|
+
// typed routing for either monorepo or published consumers.
|
|
36
|
+
|
|
37
|
+
// ── The augmentable seam ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Augmentable registration interface. Empty by default; `route-types.gen.ts`
|
|
41
|
+
* augments it with this app's `RouteRegistry`. See file header.
|
|
42
|
+
*/
|
|
43
|
+
export interface Register {}
|
|
44
|
+
|
|
45
|
+
/** The shape the generated file plugs into `Register["routes"]`. */
|
|
46
|
+
export interface RouteRegistry {
|
|
47
|
+
/** Union of all route patterns, colon-style — e.g. `"/" | "/blog/:id"`. */
|
|
48
|
+
routes: string;
|
|
49
|
+
/** Map of pattern → params object — e.g. `{ "/blog/:id": { id: string } }`. */
|
|
50
|
+
params: Record<string, Record<string, string>>;
|
|
51
|
+
/** Map of pattern → search-params object. */
|
|
52
|
+
search: Record<string, Record<string, string>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Package-level customization maps (stable augmentation targets) ───────────
|
|
56
|
+
//
|
|
57
|
+
// Users type search params / context per route by augmenting THESE interfaces
|
|
58
|
+
// on the package, e.g.:
|
|
59
|
+
//
|
|
60
|
+
// declare module "@bractjs/bractjs" {
|
|
61
|
+
// interface RouteSearchParamsMap { "/posts": { page: string } }
|
|
62
|
+
// }
|
|
63
|
+
//
|
|
64
|
+
// The generated file seeds every route with a permissive default; user
|
|
65
|
+
// augmentations merge on top.
|
|
66
|
+
|
|
67
|
+
/** Per-route search-params shapes. Augment to type a route's search params. */
|
|
68
|
+
export interface RouteSearchParamsMap {}
|
|
69
|
+
|
|
70
|
+
/** Per-route context shapes. Augment to type a route's `context`. */
|
|
71
|
+
export interface RouteContextMap {}
|
|
72
|
+
|
|
73
|
+
// ── Resolution helpers (drive the runtime hooks/components) ──────────────────
|
|
74
|
+
|
|
75
|
+
// NOTE: these conditionals deliberately do NOT use `infer R extends RouteRegistry`.
|
|
76
|
+
// A constrained `infer` here silently fails to match the generated registry (its
|
|
77
|
+
// `search` member is an empty interface, which trips the constraint check) and
|
|
78
|
+
// falls back to the loose branch — defeating the whole feature. We instead infer
|
|
79
|
+
// each member's shape directly. `RouteRegistry` remains the documented contract
|
|
80
|
+
// the generated file targets.
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The app's route union when registered, else `string`. The fallback is what
|
|
84
|
+
* keeps un-codegen'd apps compiling: every `to` still accepts any string.
|
|
85
|
+
*/
|
|
86
|
+
export type RegisteredRoutes =
|
|
87
|
+
Register extends { routes: { routes: infer R } } ? R : string;
|
|
88
|
+
|
|
89
|
+
/** Pattern → params map when registered, else a permissive map. */
|
|
90
|
+
export type RegisteredParamsMap =
|
|
91
|
+
Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
|
|
92
|
+
|
|
93
|
+
/** Pattern → search map when registered, else a permissive map. */
|
|
94
|
+
export type RegisteredSearchMap =
|
|
95
|
+
Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
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
|
+
|
|
106
|
+
/** Params object for a specific route literal (`{}` for static routes). */
|
|
107
|
+
export type ParamsFor<TTo> =
|
|
108
|
+
TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
|
|
109
|
+
|
|
110
|
+
/** Search-params object for a specific route literal. */
|
|
111
|
+
export type SearchFor<TTo> =
|
|
112
|
+
TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
|
|
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
|
+
|
|
129
|
+
/** Whether a route literal carries any path params. Reserved for a future strict `<Link>` mode. */
|
|
130
|
+
export type HasParams<TTo> =
|
|
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,46 @@
|
|
|
1
1
|
import { createContext, useContext, type ComponentType } from "react";
|
|
2
2
|
import type { ServerManifest } from "../server/render.ts";
|
|
3
|
+
import type { RouterLocation } 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;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Truthy while the initial client render must keep showing what the server
|
|
16
|
+
* sent instead of the real route component: the Fallback for selective-SSR
|
|
17
|
+
* documents, nothing for the SPA shell. Cleared (→ `false`) once loader data
|
|
18
|
+
* is in place.
|
|
19
|
+
*/
|
|
20
|
+
export type HydrationPending = false | "client-only" | "data-only" | "spa";
|
|
21
|
+
|
|
11
22
|
// ── Router Context ─────────────────────────────────────────────────────────
|
|
12
23
|
|
|
13
24
|
export interface RouteState {
|
|
14
25
|
loaderData: Record<string, unknown>;
|
|
15
26
|
actionData: unknown;
|
|
16
27
|
params: Record<string, string>;
|
|
28
|
+
/** Query-free pathname of the current route (kept alongside `location` for back-compat). */
|
|
17
29
|
pathname: string;
|
|
30
|
+
location: RouterLocation;
|
|
31
|
+
/** Validated search params (route `searchSchema` output; raw string record otherwise). */
|
|
32
|
+
search: Record<string, unknown>;
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
export interface RouterContextValue extends RouteState {
|
|
21
36
|
manifest: ServerManifest;
|
|
22
37
|
currentModule: RouteModuleClient | null;
|
|
23
38
|
setRoute(state: Partial<RouteState>): void;
|
|
39
|
+
/** Re-run the active route's loaders (gated by `shouldRevalidate`). */
|
|
40
|
+
revalidate(): Promise<void>;
|
|
41
|
+
/** "loading" while a revalidation is in flight. Distinct from the navigation state. */
|
|
42
|
+
revalidationState: "idle" | "loading";
|
|
43
|
+
hydrationPending: HydrationPending;
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
export const RouterContext = createContext<RouterContextValue>(null!);
|
|
@@ -37,9 +57,16 @@ export function useRouterContext(): RouterContextValue {
|
|
|
37
57
|
|
|
38
58
|
export type NavigationState = "idle" | "loading" | "submitting";
|
|
39
59
|
|
|
60
|
+
export interface NavigateOptions {
|
|
61
|
+
/** Replace the current history entry instead of pushing a new one. */
|
|
62
|
+
replace?: boolean;
|
|
63
|
+
/** Arbitrary history state, readable via `useLocation().state` after the navigation. */
|
|
64
|
+
state?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
40
67
|
export interface NavigationContextValue {
|
|
41
68
|
state: NavigationState;
|
|
42
|
-
navigate(to: string): Promise<void>;
|
|
69
|
+
navigate(to: string, options?: NavigateOptions): Promise<void>;
|
|
43
70
|
submit(to: string, options: { method: string; body: FormData | Record<string, string> }): Promise<void>;
|
|
44
71
|
}
|
|
45
72
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseTo } from "./nav-utils.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialize a validated-search-shaped object back into a query string
|
|
5
|
+
* (including the leading `?`, or `""` when empty).
|
|
6
|
+
*
|
|
7
|
+
* - `undefined`/`null` values are dropped (the way to delete a param).
|
|
8
|
+
* - Arrays serialize as repeated keys (`{ tag: ["a","b"] }` → `?tag=a&tag=b`),
|
|
9
|
+
* the inverse of the server's `searchParamsToObject`.
|
|
10
|
+
* - Other objects are JSON-stringified; pair them with a schema field that
|
|
11
|
+
* `JSON.parse`s on the way in.
|
|
12
|
+
* - Everything else goes through `String()` — the server schema re-coerces on
|
|
13
|
+
* the next request, so numbers/booleans round-trip.
|
|
14
|
+
*/
|
|
15
|
+
export function serializeSearch(search: Record<string, unknown>): string {
|
|
16
|
+
const sp = new URLSearchParams();
|
|
17
|
+
for (const [key, value] of Object.entries(search)) {
|
|
18
|
+
if (value === undefined || value === null) continue;
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
for (const item of value) {
|
|
21
|
+
if (item === undefined || item === null) continue;
|
|
22
|
+
sp.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
sp.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const qs = sp.toString();
|
|
29
|
+
return qs ? "?" + qs : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace a path's query string with the serialized `search` object,
|
|
34
|
+
* preserving any hash. No-op when `search` is undefined.
|
|
35
|
+
*/
|
|
36
|
+
export function withSearch(path: string, search?: Record<string, unknown>): string {
|
|
37
|
+
if (!search) return path;
|
|
38
|
+
const { pathname, hash } = parseTo(path);
|
|
39
|
+
return pathname + serializeSearch(search) + hash;
|
|
40
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -8,11 +8,15 @@ export interface BractJSClientData {
|
|
|
8
8
|
actionData: unknown;
|
|
9
9
|
params: Record<string, string>;
|
|
10
10
|
pathname: string;
|
|
11
|
+
/** Validated search params for the initial request (route `searchSchema` output). */
|
|
12
|
+
search?: Record<string, unknown>;
|
|
11
13
|
manifest: ServerManifest;
|
|
12
14
|
/** Path of the matched route file, used to pre-import the module before hydration. */
|
|
13
15
|
routeFile?: string;
|
|
14
16
|
/** Merged meta descriptors for the current route — keeps <head> in sync. */
|
|
15
17
|
meta?: MetaDescriptor[];
|
|
18
|
+
/** Present when the document did not SSR the route component (selective SSR / SPA shell). */
|
|
19
|
+
ssrMode?: "client-only" | "data-only" | "spa";
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
// ── Window augmentation ────────────────────────────────────────────────────
|
|
@@ -22,5 +26,7 @@ declare global {
|
|
|
22
26
|
__BRACTJS_DATA__: BractJSClientData;
|
|
23
27
|
/** Dev-only: registered by ClientRouter for module-level HMR swaps. */
|
|
24
28
|
__BRACTJS_HMR_ACCEPT__?: (pattern: string, mod: Record<string, unknown>) => void;
|
|
29
|
+
/** Dev-only: HMR WebSocket port published by the server's dev bootstrap. */
|
|
30
|
+
__BRACTJS_HMR_PORT__?: number;
|
|
25
31
|
}
|
|
26
32
|
}
|