@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/build/bundler.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface BuildConfig {
|
|
|
19
19
|
minify?: boolean;
|
|
20
20
|
clientEnv?: string[];
|
|
21
21
|
plugins?: BunPlugin[];
|
|
22
|
+
/** SPA mode: when `false`, the build also emits the static document shell. */
|
|
23
|
+
ssr?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export async function runBuild(config: BuildConfig): Promise<void> {
|
|
@@ -30,6 +32,16 @@ export async function runBuild(config: BuildConfig): Promise<void> {
|
|
|
30
32
|
const routeFilePaths = routes.map((r) => join(appDir, r.filePath));
|
|
31
33
|
const rootFilePath = join(appDir, "root.tsx");
|
|
32
34
|
|
|
35
|
+
// Static route-module lint: surface empty routes and miscased exports at
|
|
36
|
+
// build time (no execution — just source analysis).
|
|
37
|
+
const { lintRouteModuleSource } = await import("./route-lint.ts");
|
|
38
|
+
for (const r of routes) {
|
|
39
|
+
const src = await Bun.file(join(appDir, r.filePath)).text().catch(() => "");
|
|
40
|
+
for (const warning of lintRouteModuleSource(src, r.filePath)) {
|
|
41
|
+
console.warn(`[bract] ${warning}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
// ── 1. Clean stale artefacts ────────────────────────────────────────────
|
|
34
46
|
const buildDir = config.buildDir ?? "build";
|
|
35
47
|
await Promise.all([
|
|
@@ -140,5 +152,26 @@ export async function runBuild(config: BuildConfig): Promise<void> {
|
|
|
140
152
|
// ── 5. Write manifest ──────────────────────────────────────────────────
|
|
141
153
|
const manifest = generateManifest({ clientEntry, rootChunk, routeChunks, mode: "production" });
|
|
142
154
|
await writeManifest(manifest, "build");
|
|
155
|
+
|
|
156
|
+
// ── 6. SPA shell (ssr: false) ───────────────────────────────────────────
|
|
157
|
+
// Emit the static document shell every document GET will serve in SPA mode.
|
|
158
|
+
if (config.ssr === false) {
|
|
159
|
+
const { renderSpaShell } = await import("../server/spa.ts");
|
|
160
|
+
const { installUseClientServerStub } = await import("../server/use-client-runtime.ts");
|
|
161
|
+
// root.tsx is imported from source here — "use client" components inside
|
|
162
|
+
// it must null-render exactly as they do on the running server.
|
|
163
|
+
installUseClientServerStub(appDir);
|
|
164
|
+
const serverManifest = {
|
|
165
|
+
clientEntry,
|
|
166
|
+
rootChunk,
|
|
167
|
+
routes: Object.fromEntries(
|
|
168
|
+
Object.entries(manifest.routes).map(([pat, e]) => [pat, { file: e.chunk, chunk: e.chunk }]),
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
const html = await renderSpaShell(appDir, serverManifest);
|
|
172
|
+
await Bun.write(join(buildDir, "client", "__spa.html"), html);
|
|
173
|
+
console.log("[bract] SPA shell → build/client/__spa.html");
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
console.log("[bract] build complete →", Object.keys(manifest.routes).length, "routes");
|
|
144
177
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { buildFetchHandler } from "../server/serve.ts";
|
|
3
|
+
import type { ServerManifest } from "../server/render.ts";
|
|
4
|
+
|
|
5
|
+
export interface PrerenderOptions {
|
|
6
|
+
/** Concrete paths to prerender (or a function resolving them, e.g. from a DB). */
|
|
7
|
+
prerender: string[] | (() => string[] | Promise<string[]>);
|
|
8
|
+
appDir?: string;
|
|
9
|
+
publicDir?: string;
|
|
10
|
+
buildDir?: string;
|
|
11
|
+
/** Override the manifest instead of loading `<buildDir>/route-manifest.json`. */
|
|
12
|
+
manifest?: ServerManifest;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PrerenderResult {
|
|
16
|
+
written: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where a path's prerendered files live under `<buildDir>/client/_prerender`.
|
|
21
|
+
* Throws on anything that isn't a clean absolute path — these strings come
|
|
22
|
+
* from user config but become filesystem writes.
|
|
23
|
+
*/
|
|
24
|
+
export function prerenderPaths(path: string): { html: string; data: string } {
|
|
25
|
+
if (!path.startsWith("/")) {
|
|
26
|
+
throw new Error(`[bractjs] prerender: paths must start with "/", got ${JSON.stringify(path)}`);
|
|
27
|
+
}
|
|
28
|
+
if (path.includes(":") || path.includes("[") || path.includes("*")) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[bractjs] prerender: ${JSON.stringify(path)} looks like a route PATTERN — ` +
|
|
31
|
+
`expand dynamic routes to concrete paths (e.g. "/blog/intro", not "/blog/:slug").`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const segments = path.split("/").filter(Boolean);
|
|
35
|
+
if (segments.some((s) => s === ".." || s === ".")) {
|
|
36
|
+
throw new Error(`[bractjs] prerender: refusing path with dot segments: ${JSON.stringify(path)}`);
|
|
37
|
+
}
|
|
38
|
+
const dir = segments.join("/");
|
|
39
|
+
return {
|
|
40
|
+
html: dir === "" ? "index.html" : `${dir}/index.html`,
|
|
41
|
+
data: dir === "" ? "_data.json" : `${dir}/_data.json`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build-time prerendering (SSG): run the production fetch handler in-process
|
|
47
|
+
* against each configured path and write the HTML document plus its `/_data`
|
|
48
|
+
* payload (used by client navigations INTO a prerendered page) under
|
|
49
|
+
* `<buildDir>/client/_prerender/`. The production server serves these before
|
|
50
|
+
* falling back to dynamic SSR — query-carrying requests stay dynamic.
|
|
51
|
+
*
|
|
52
|
+
* Loaders run for real at build time: anything they need (DB, env) must be
|
|
53
|
+
* available to the build.
|
|
54
|
+
*/
|
|
55
|
+
export async function runPrerender(options: PrerenderOptions): Promise<PrerenderResult> {
|
|
56
|
+
const buildDir = options.buildDir ?? "./build";
|
|
57
|
+
const paths = typeof options.prerender === "function" ? await options.prerender() : options.prerender;
|
|
58
|
+
|
|
59
|
+
const handler = buildFetchHandler({
|
|
60
|
+
appDir: options.appDir ?? "./app",
|
|
61
|
+
publicDir: options.publicDir,
|
|
62
|
+
buildDir,
|
|
63
|
+
manifest: options.manifest,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const written: string[] = [];
|
|
67
|
+
for (const path of paths) {
|
|
68
|
+
const out = prerenderPaths(path);
|
|
69
|
+
|
|
70
|
+
const htmlRes = await handler(new Request("http://prerender.local" + path));
|
|
71
|
+
if (htmlRes.status !== 200) {
|
|
72
|
+
throw new Error(`[bractjs] prerender: GET ${path} returned ${htmlRes.status}`);
|
|
73
|
+
}
|
|
74
|
+
const htmlFile = join(buildDir, "client", "_prerender", out.html);
|
|
75
|
+
await Bun.write(htmlFile, await htmlRes.text());
|
|
76
|
+
written.push(htmlFile);
|
|
77
|
+
|
|
78
|
+
const dataRes = await handler(
|
|
79
|
+
new Request("http://prerender.local/_data?path=" + encodeURIComponent(path)),
|
|
80
|
+
);
|
|
81
|
+
if (dataRes.status === 200) {
|
|
82
|
+
const dataFile = join(buildDir, "client", "_prerender", out.data);
|
|
83
|
+
await Bun.write(dataFile, await dataRes.text());
|
|
84
|
+
written.push(dataFile);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { written };
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extractExports } from "./directives.ts";
|
|
2
|
+
|
|
3
|
+
// The canonical route-module export names. A route file exporting a near-miss
|
|
4
|
+
// (wrong case) of one of these is almost always a mistake — the framework's
|
|
5
|
+
// projection is case-sensitive, so `Loader` is silently ignored.
|
|
6
|
+
export const ROUTE_EXPORT_NAMES = [
|
|
7
|
+
"default", "loader", "action", "meta", "beforeLoad", "shouldRevalidate",
|
|
8
|
+
"searchSchema", "ssr", "Fallback", "handle", "ErrorBoundary", "config",
|
|
9
|
+
"loaderDeps", "context",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const CANONICAL_LOWER = new Map(ROUTE_EXPORT_NAMES.map((n) => [n.toLowerCase(), n]));
|
|
13
|
+
const CANONICAL_SET = new Set<string>(ROUTE_EXPORT_NAMES);
|
|
14
|
+
|
|
15
|
+
/** A route is "renderable or does work" if it has any of these. */
|
|
16
|
+
const MEANINGFUL = ["default", "loader", "action", "beforeLoad"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Static lint of a route module's SOURCE (no execution). Returns human-readable
|
|
20
|
+
* warning strings. Used by the dev rebuilder and the production build to catch
|
|
21
|
+
* two common, silent mistakes: a route that renders nothing, and an export
|
|
22
|
+
* whose casing doesn't match a framework export (so it's ignored).
|
|
23
|
+
*/
|
|
24
|
+
export function lintRouteModuleSource(src: string, filePath: string): string[] {
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
const names = extractExports(src);
|
|
27
|
+
// extractExports misses anonymous `export default () => …` / `export default function() {}`.
|
|
28
|
+
const hasAnonDefault = /^export\s+default\b/m.test(src) && !names.includes("default");
|
|
29
|
+
const exportSet = new Set(names);
|
|
30
|
+
if (hasAnonDefault) exportSet.add("default");
|
|
31
|
+
|
|
32
|
+
if (!MEANINGFUL.some((n) => exportSet.has(n))) {
|
|
33
|
+
warnings.push(
|
|
34
|
+
`${filePath}: route has no default/loader/action/beforeLoad export — it renders an empty page.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const name of exportSet) {
|
|
39
|
+
if (CANONICAL_SET.has(name)) continue;
|
|
40
|
+
const canonical = CANONICAL_LOWER.get(name.toLowerCase());
|
|
41
|
+
if (canonical && canonical !== name) {
|
|
42
|
+
warnings.push(
|
|
43
|
+
`${filePath}: export "${name}" looks like "${canonical}" — route exports are case-sensitive, so "${name}" is ignored.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return warnings;
|
|
49
|
+
}
|
|
@@ -7,19 +7,24 @@ import {
|
|
|
7
7
|
NavigationContext,
|
|
8
8
|
type RouteState,
|
|
9
9
|
type NavigationState,
|
|
10
|
+
type NavigateOptions,
|
|
10
11
|
type RouteModuleClient,
|
|
12
|
+
type HydrationPending,
|
|
11
13
|
} from "./router.tsx";
|
|
12
14
|
import type { ServerManifest } from "../server/render.ts";
|
|
13
|
-
import { matchPatternForPath, toSamePath } from "./nav-utils.ts";
|
|
15
|
+
import { matchPatternForPath, toSamePath, parseTo, createLocationKey } from "./nav-utils.ts";
|
|
14
16
|
import { loaderCache, cacheKey } from "./cache.ts";
|
|
17
|
+
import { registerRevalidator, type RevalidationInfo } from "./revalidation.ts";
|
|
15
18
|
import { MetaTags } from "../shared/meta-tags.tsx";
|
|
16
|
-
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
19
|
+
import type { MetaDescriptor, RouterLocation, ShouldRevalidateFunction } from "../shared/route-types.ts";
|
|
17
20
|
|
|
18
21
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
19
22
|
|
|
20
23
|
export interface BractJSInitialData extends RouteState {
|
|
21
24
|
manifest: ServerManifest;
|
|
22
25
|
meta?: MetaDescriptor[];
|
|
26
|
+
/** Present when the document did not SSR the route component (selective SSR / SPA shell). */
|
|
27
|
+
ssrMode?: "client-only" | "data-only" | "spa";
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
interface ClientRouterProps {
|
|
@@ -28,31 +33,52 @@ interface ClientRouterProps {
|
|
|
28
33
|
initialModule?: RouteModuleClient | null;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** History-entry init carried into loadRoute by navigate/popstate. */
|
|
37
|
+
interface LocationInit {
|
|
38
|
+
key?: string;
|
|
39
|
+
state?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
32
43
|
|
|
33
44
|
export function ClientRouter({ children, initialData, initialModule = null }: ClientRouterProps): ReactElement {
|
|
34
45
|
const [loaderData, setLoaderData] = useState(initialData.loaderData);
|
|
35
46
|
const [actionData, setActionData] = useState<unknown>(initialData.actionData);
|
|
36
47
|
const [params, setParams] = useState(initialData.params);
|
|
37
|
-
const [
|
|
48
|
+
const [location, setLocation] = useState<RouterLocation>(initialData.location);
|
|
49
|
+
const [search, setSearch] = useState<Record<string, unknown>>(initialData.search ?? {});
|
|
38
50
|
const [navState, setNavState] = useState<NavigationState>("idle");
|
|
51
|
+
const [revalidationState, setRevalidationState] = useState<"idle" | "loading">("idle");
|
|
39
52
|
const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
|
|
40
53
|
const [meta, setMeta] = useState<MetaDescriptor[]>(initialData.meta ?? []);
|
|
54
|
+
const [hydrationPending, setHydrationPending] = useState<HydrationPending>(initialData.ssrMode ?? false);
|
|
41
55
|
|
|
42
56
|
const manifest = initialData.manifest;
|
|
43
57
|
|
|
44
58
|
// Stable ref to navigate so loadRoute can call it without a circular dep.
|
|
45
59
|
const navigateRef = useRef<(to: string) => Promise<void>>(null!);
|
|
46
60
|
|
|
61
|
+
// Refs mirroring state that the stable revalidate/submit callbacks need.
|
|
62
|
+
const locationRef = useRef(location);
|
|
63
|
+
useEffect(() => { locationRef.current = location; }, [location]);
|
|
64
|
+
const currentModuleRef = useRef(currentModule);
|
|
65
|
+
useEffect(() => { currentModuleRef.current = currentModule; }, [currentModule]);
|
|
66
|
+
|
|
47
67
|
const setRoute = useCallback((state: Partial<RouteState>) => {
|
|
48
68
|
if (state.loaderData !== undefined) setLoaderData(state.loaderData);
|
|
49
69
|
if (state.actionData !== undefined) setActionData(state.actionData);
|
|
50
70
|
if (state.params !== undefined) setParams(state.params);
|
|
51
|
-
if (state.
|
|
71
|
+
if (state.search !== undefined) setSearch(state.search);
|
|
72
|
+
if (state.location !== undefined) setLocation(state.location);
|
|
73
|
+
else if (state.pathname !== undefined) {
|
|
74
|
+
// Legacy callers pass a (possibly query-carrying) pathname string.
|
|
75
|
+
const parsed = parseTo(state.pathname);
|
|
76
|
+
setLocation((prev) => ({ ...prev, ...parsed }));
|
|
77
|
+
}
|
|
52
78
|
}, []);
|
|
53
79
|
|
|
54
80
|
/** Load route data + module without touching history. */
|
|
55
|
-
const loadRoute = useCallback(async (to: string) => {
|
|
81
|
+
const loadRoute = useCallback(async (to: string, locInit?: LocationInit) => {
|
|
56
82
|
setNavState("loading");
|
|
57
83
|
// Follow a redirect Location from client-side beforeLoad. Same-origin
|
|
58
84
|
// targets stay in the SPA; an off-origin/protocol-relative Location is NOT
|
|
@@ -64,7 +90,18 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
64
90
|
window.location.href = loc;
|
|
65
91
|
};
|
|
66
92
|
try {
|
|
67
|
-
const toPathname = to
|
|
93
|
+
const { pathname: toPathname, search: toSearch, hash: toHash } = parseTo(to);
|
|
94
|
+
// The path handed to /_data: never includes the hash (the fragment is
|
|
95
|
+
// client-only) and never inherits the previous page's query string —
|
|
96
|
+
// what you navigate to is exactly what loads.
|
|
97
|
+
const dataPath = toPathname + toSearch;
|
|
98
|
+
const nextLocation: RouterLocation = {
|
|
99
|
+
pathname: toPathname,
|
|
100
|
+
search: toSearch,
|
|
101
|
+
hash: toHash,
|
|
102
|
+
state: locInit?.state ?? null,
|
|
103
|
+
key: locInit?.key ?? createLocationKey(),
|
|
104
|
+
};
|
|
68
105
|
const pattern = matchPatternForPath(toPathname, manifest);
|
|
69
106
|
const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
70
107
|
|
|
@@ -99,8 +136,20 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
99
136
|
}
|
|
100
137
|
}
|
|
101
138
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
139
|
+
// Commit a /_data payload + the new location in one transition.
|
|
140
|
+
const commit = (data: Record<string, unknown>, module: RouteModuleClient | null) => {
|
|
141
|
+
startTransition(() => {
|
|
142
|
+
setLoaderData(data);
|
|
143
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
144
|
+
setLocation(nextLocation);
|
|
145
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
146
|
+
setCurrentModule(module);
|
|
147
|
+
// Re-render the document head from the new route's merged meta.
|
|
148
|
+
// React 19 hoists the <title>/<meta> elements rendered by <MetaTags>
|
|
149
|
+
// into <head>, so description/OG tags update on soft navigation.
|
|
150
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
151
|
+
});
|
|
152
|
+
};
|
|
104
153
|
|
|
105
154
|
// ── Cache lookup (B1 / B2) ──────────────────────────────────────────
|
|
106
155
|
// Read config and loaderDeps from the route module if available.
|
|
@@ -113,33 +162,34 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
113
162
|
const loaderDepsFn = (routeModule as Record<string, unknown> | null)?.loaderDeps as
|
|
114
163
|
| ((args: { searchParams: URLSearchParams }) => unknown[])
|
|
115
164
|
| undefined;
|
|
116
|
-
const searchParams = new URLSearchParams(
|
|
117
|
-
const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [
|
|
165
|
+
const searchParams = new URLSearchParams(toSearch);
|
|
166
|
+
const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [dataPath];
|
|
118
167
|
const key = cacheKey(toPathname, deps);
|
|
119
168
|
|
|
120
169
|
const cached = loaderCache.get(key);
|
|
121
170
|
if (cached?.fresh) {
|
|
122
171
|
// Serve from cache immediately; skip fetch.
|
|
123
|
-
|
|
124
|
-
setLoaderData(cached.data);
|
|
125
|
-
setParams((cached.data.params as Record<string, string>) ?? {});
|
|
126
|
-
setPathname(to);
|
|
127
|
-
setCurrentModule(routeModule);
|
|
128
|
-
});
|
|
172
|
+
commit(cached.data, routeModule);
|
|
129
173
|
setNavState("idle");
|
|
130
174
|
return;
|
|
131
175
|
}
|
|
132
176
|
if (cached && !cached.fresh) {
|
|
133
177
|
// Stale-while-revalidate: render stale data immediately, then refresh.
|
|
134
|
-
|
|
135
|
-
setLoaderData(cached.data);
|
|
136
|
-
setParams((cached.data.params as Record<string, string>) ?? {});
|
|
137
|
-
setPathname(to);
|
|
138
|
-
setCurrentModule(routeModule);
|
|
139
|
-
});
|
|
178
|
+
commit(cached.data, routeModule);
|
|
140
179
|
setNavState("idle");
|
|
180
|
+
// The route can veto the background refetch via shouldRevalidate.
|
|
181
|
+
const gate = (routeModule as Record<string, unknown> | null)
|
|
182
|
+
?.shouldRevalidate as ShouldRevalidateFunction | undefined;
|
|
183
|
+
const allowRefetch = gate
|
|
184
|
+
? gate({
|
|
185
|
+
currentUrl: new URL(window.location.href),
|
|
186
|
+
nextUrl: new URL(dataPath, window.location.origin),
|
|
187
|
+
defaultShouldRevalidate: true,
|
|
188
|
+
})
|
|
189
|
+
: true;
|
|
190
|
+
if (!allowRefetch) return;
|
|
141
191
|
// Revalidate in background.
|
|
142
|
-
void fetch(`/_data?path=${encodeURIComponent(
|
|
192
|
+
void fetch(`/_data?path=${encodeURIComponent(dataPath)}`)
|
|
143
193
|
.then((r) => r.ok ? r.json() : null)
|
|
144
194
|
.then((fresh) => {
|
|
145
195
|
if (!fresh) return;
|
|
@@ -147,13 +197,15 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
147
197
|
startTransition(() => {
|
|
148
198
|
setLoaderData(fresh as Record<string, unknown>);
|
|
149
199
|
setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
|
|
200
|
+
setSearch(((fresh as Record<string, unknown>).search as Record<string, unknown>) ?? {});
|
|
201
|
+
setMeta(((fresh as Record<string, unknown>).meta as MetaDescriptor[] | undefined) ?? []);
|
|
150
202
|
});
|
|
151
203
|
});
|
|
152
204
|
return;
|
|
153
205
|
}
|
|
154
206
|
|
|
155
207
|
// Cache miss — fetch from server.
|
|
156
|
-
const res = await fetch(`/_data?path=${encodeURIComponent(
|
|
208
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(dataPath)}`);
|
|
157
209
|
// Guard: always parse JSON, but only when the server signals success.
|
|
158
210
|
// Without res.ok check, a Bun 500 plain-text response causes
|
|
159
211
|
// SyntaxError: JSON.parse: unexpected character — an unhandled rejection.
|
|
@@ -183,17 +235,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
183
235
|
});
|
|
184
236
|
}).catch(() => {/* devtools not available in prod */});
|
|
185
237
|
}
|
|
186
|
-
|
|
187
|
-
setLoaderData(data);
|
|
188
|
-
setParams((data.params as Record<string, string>) ?? {});
|
|
189
|
-
setPathname(to);
|
|
190
|
-
setCurrentModule(routeModule);
|
|
191
|
-
});
|
|
192
|
-
// Re-render the document head from the new route's merged meta. React 19
|
|
193
|
-
// hoists the <title>/<meta> elements rendered by <MetaTags> into <head>,
|
|
194
|
-
// so description/OG tags update on soft navigation, not just the title.
|
|
195
|
-
const nextMeta = (data.meta as MetaDescriptor[] | undefined) ?? [];
|
|
196
|
-
startTransition(() => setMeta(nextMeta));
|
|
238
|
+
commit(data, routeModule);
|
|
197
239
|
} catch (err) {
|
|
198
240
|
console.error("[bractjs] loadRoute error:", err);
|
|
199
241
|
} finally {
|
|
@@ -201,17 +243,130 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
201
243
|
}
|
|
202
244
|
}, [manifest]);
|
|
203
245
|
|
|
204
|
-
const navigate = useCallback(async (to: string) => {
|
|
205
|
-
|
|
206
|
-
|
|
246
|
+
const navigate = useCallback(async (to: string, options?: NavigateOptions) => {
|
|
247
|
+
const key = createLocationKey();
|
|
248
|
+
await loadRoute(to, { key, state: options?.state ?? null });
|
|
249
|
+
const entry = { __bractKey: key, __bractState: options?.state ?? null };
|
|
250
|
+
if (options?.replace) history.replaceState(entry, "", to);
|
|
251
|
+
else history.pushState(entry, "", to);
|
|
207
252
|
}, [loadRoute]);
|
|
208
253
|
|
|
209
254
|
// Keep navigateRef current so loadRoute can redirect via navigate.
|
|
210
255
|
useEffect(() => { navigateRef.current = navigate; }, [navigate]);
|
|
211
256
|
|
|
212
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Re-run the active route's loaders and commit fresh data without touching
|
|
259
|
+
* history or the location. Gated by the route's shouldRevalidate export;
|
|
260
|
+
* mutation-triggered runs (info.formMethod set) first drop the whole loader
|
|
261
|
+
* cache — any cached entry may reflect pre-mutation state.
|
|
262
|
+
*/
|
|
263
|
+
const revalidate = useCallback(async (info?: RevalidationInfo) => {
|
|
264
|
+
const loc = locationRef.current;
|
|
265
|
+
const path = loc.pathname + loc.search;
|
|
266
|
+
const gate = (currentModuleRef.current as Record<string, unknown> | null)
|
|
267
|
+
?.shouldRevalidate as ShouldRevalidateFunction | undefined;
|
|
268
|
+
const url = new URL(path, window.location.origin);
|
|
269
|
+
const allow = gate
|
|
270
|
+
? gate({
|
|
271
|
+
currentUrl: url,
|
|
272
|
+
nextUrl: url,
|
|
273
|
+
formMethod: info?.formMethod,
|
|
274
|
+
actionStatus: info?.actionStatus,
|
|
275
|
+
defaultShouldRevalidate: true,
|
|
276
|
+
})
|
|
277
|
+
: true;
|
|
278
|
+
if (!allow) return;
|
|
279
|
+
if (info?.formMethod) loaderCache.clear();
|
|
280
|
+
setRevalidationState("loading");
|
|
281
|
+
try {
|
|
282
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
|
|
283
|
+
if (!res.ok) {
|
|
284
|
+
console.error(`[bractjs] revalidate /_data ${res.status} for ${path}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
288
|
+
startTransition(() => {
|
|
289
|
+
setLoaderData(data);
|
|
290
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
291
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
292
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error("[bractjs] revalidate error:", err);
|
|
296
|
+
} finally {
|
|
297
|
+
setRevalidationState("idle");
|
|
298
|
+
}
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
// Let fetchers trigger revalidation without importing this component.
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
registerRevalidator(revalidate);
|
|
304
|
+
return () => registerRevalidator(null);
|
|
305
|
+
}, [revalidate]);
|
|
306
|
+
|
|
307
|
+
// Selective-SSR / SPA hydration completion. The first client render matched
|
|
308
|
+
// the server (Fallback or empty shell); after mount, put loader data in
|
|
309
|
+
// place and swap in the real component via a transition.
|
|
213
310
|
useEffect(() => {
|
|
214
|
-
|
|
311
|
+
if (!hydrationPending) return;
|
|
312
|
+
if (hydrationPending === "data-only") {
|
|
313
|
+
// Loaders already ran on the server — the data arrived in the bootstrap.
|
|
314
|
+
startTransition(() => setHydrationPending(false));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// "client-only" / "spa": the route loader never ran for this document.
|
|
318
|
+
void (async () => {
|
|
319
|
+
const path = window.location.pathname + window.location.search;
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
|
|
322
|
+
// A redirect here is a beforeLoad gate (SPA shells skip server-side
|
|
323
|
+
// gating on the document). Do a real navigation — never render a
|
|
324
|
+
// protected route around redirected data.
|
|
325
|
+
if (res.redirected) {
|
|
326
|
+
const safe = toSamePath(res.url);
|
|
327
|
+
window.location.assign(safe ?? res.url);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (res.ok) {
|
|
331
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
332
|
+
startTransition(() => {
|
|
333
|
+
setLoaderData(data);
|
|
334
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
335
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
336
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
337
|
+
setHydrationPending(false);
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
console.error(`[bractjs] hydration /_data ${res.status} for ${path}`);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.error("[bractjs] hydration fetch error:", err);
|
|
344
|
+
}
|
|
345
|
+
startTransition(() => setHydrationPending(false));
|
|
346
|
+
})();
|
|
347
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
// Stamp the initial history entry with our key so back/forward to it can
|
|
351
|
+
// restore scroll position. Merge into any pre-existing state, don't replace.
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const st = history.state as Record<string, unknown> | null;
|
|
354
|
+
if (!st || typeof st.__bractKey !== "string") {
|
|
355
|
+
history.replaceState({ ...(st ?? {}), __bractKey: initialData.location.key }, "", window.location.href);
|
|
356
|
+
}
|
|
357
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
358
|
+
}, []);
|
|
359
|
+
|
|
360
|
+
// Handle browser back / forward. `window.location` must stay explicit here —
|
|
361
|
+
// the component has a `location` state variable that would shadow the global.
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
const onPopState = (e: PopStateEvent) => {
|
|
364
|
+
const st = e.state as { __bractKey?: string; __bractState?: unknown } | null;
|
|
365
|
+
void loadRoute(
|
|
366
|
+
window.location.pathname + window.location.search + window.location.hash,
|
|
367
|
+
{ key: st?.__bractKey ?? "default", state: st?.__bractState ?? null },
|
|
368
|
+
);
|
|
369
|
+
};
|
|
215
370
|
window.addEventListener("popstate", onPopState);
|
|
216
371
|
return () => window.removeEventListener("popstate", onPopState);
|
|
217
372
|
}, [loadRoute]);
|
|
@@ -225,22 +380,59 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
225
380
|
const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
|
|
226
381
|
if (w.__BRACT_DEV__ !== true) return;
|
|
227
382
|
w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
|
|
228
|
-
const current = matchPatternForPath(pathname, manifest);
|
|
383
|
+
const current = matchPatternForPath(location.pathname, manifest);
|
|
229
384
|
if (current === pattern) startTransition(() => setCurrentModule(mod));
|
|
230
385
|
};
|
|
231
386
|
return () => { delete w.__BRACTJS_HMR_ACCEPT__; };
|
|
232
|
-
}, [pathname, manifest]);
|
|
387
|
+
}, [location.pathname, manifest]);
|
|
233
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Submit a mutation: navState walks "submitting" → "loading" (revalidation)
|
|
391
|
+
* → "idle", which is what `useNavigation()` renders pending UI from. The
|
|
392
|
+
* fetch mirrors `<Form>`'s contract: the CSRF header marks it a same-origin
|
|
393
|
+
* mutation, and a redirected response becomes a real navigation — via
|
|
394
|
+
* toSamePath so an attacker-controlled Location can never soft-nav the SPA.
|
|
395
|
+
*/
|
|
234
396
|
const submit = useCallback(async (
|
|
235
|
-
|
|
236
|
-
|
|
397
|
+
to: string,
|
|
398
|
+
opts: { method: string; body: FormData | Record<string, string> },
|
|
237
399
|
) => {
|
|
238
400
|
setNavState("submitting");
|
|
239
|
-
|
|
240
|
-
|
|
401
|
+
try {
|
|
402
|
+
const body = opts.body instanceof FormData
|
|
403
|
+
? opts.body
|
|
404
|
+
: new URLSearchParams(opts.body);
|
|
405
|
+
const res = await fetch(to, {
|
|
406
|
+
method: opts.method.toUpperCase(),
|
|
407
|
+
body,
|
|
408
|
+
headers: { "X-BractJS-Action": "1" },
|
|
409
|
+
});
|
|
410
|
+
if (res.redirected) {
|
|
411
|
+
const safe = toSamePath(res.url);
|
|
412
|
+
if (safe) {
|
|
413
|
+
// navigate() loads fresh data for the target — no extra revalidation.
|
|
414
|
+
await navigateRef.current(safe);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
window.location.assign(res.url);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const data = (await res.json()) as unknown;
|
|
421
|
+
setActionData(data);
|
|
422
|
+
setNavState("loading");
|
|
423
|
+
await revalidate({ formMethod: opts.method, actionStatus: res.status });
|
|
424
|
+
} finally {
|
|
425
|
+
setNavState("idle");
|
|
426
|
+
}
|
|
427
|
+
}, [revalidate]);
|
|
241
428
|
|
|
242
429
|
return (
|
|
243
|
-
<RouterContext.Provider
|
|
430
|
+
<RouterContext.Provider
|
|
431
|
+
value={{
|
|
432
|
+
loaderData, actionData, params, pathname: location.pathname, location, search,
|
|
433
|
+
manifest, currentModule, setRoute, revalidate, revalidationState, hydrationPending,
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
244
436
|
<NavigationContext.Provider value={{ state: navState, navigate, submit }}>
|
|
245
437
|
<MetaTags meta={meta} />
|
|
246
438
|
{children}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Substitute `:name` segments in a colon-style route pattern with param values.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the substitution `bractjs codegen` bakes into the generated `routes`
|
|
4
|
+
// builder (`src/codegen/route-codegen.ts`). The framework's own `<Link>` /
|
|
5
|
+
// `useNavigate` can't import that app-local generated object, so they share this
|
|
6
|
+
// helper instead. Values are URL-encoded; an absent param leaves its `:name`
|
|
7
|
+
// segment intact (surfaced as an obviously-wrong URL rather than silently dropped).
|
|
8
|
+
//
|
|
9
|
+
// Patterns without a `:` (static routes, or already-built hrefs) pass straight
|
|
10
|
+
// through, so this is safe to call unconditionally.
|
|
11
|
+
export function buildPath(
|
|
12
|
+
pattern: string,
|
|
13
|
+
params: Record<string, string | number>,
|
|
14
|
+
): string {
|
|
15
|
+
if (!pattern.includes(":")) return pattern;
|
|
16
|
+
return pattern
|
|
17
|
+
.split("/")
|
|
18
|
+
.map((seg) => {
|
|
19
|
+
if (!seg.startsWith(":")) return seg;
|
|
20
|
+
const value = params[seg.slice(1)];
|
|
21
|
+
return value === undefined ? seg : encodeURIComponent(String(value));
|
|
22
|
+
})
|
|
23
|
+
.join("/");
|
|
24
|
+
}
|
package/src/client/cache.ts
CHANGED
|
@@ -31,6 +31,14 @@ class LoaderCache {
|
|
|
31
31
|
this.store.delete(key);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Drop every entry. Called after a successful mutation — any cached loader
|
|
36
|
+
* data may now be stale, and serving it would show pre-mutation state.
|
|
37
|
+
*/
|
|
38
|
+
clear(): void {
|
|
39
|
+
this.store.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
entries(): Array<{ key: string; age: number; staleTime: number; gcTime: number }> {
|
|
35
43
|
const now = Date.now();
|
|
36
44
|
return Array.from(this.store.entries()).map(([key, entry]) => ({
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Suspense, use, type ReactNode } from "react";
|
|
2
|
+
import { Deferred } from "../../shared/deferred.ts";
|
|
2
3
|
|
|
3
4
|
interface AwaitProps<T> {
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A promise, or a `Deferred<T>` field from a loader that returned `defer()`.
|
|
7
|
+
* `useLoaderData<typeof loader>()` preserves deferred fields as `Deferred<T>`,
|
|
8
|
+
* so they can be passed straight through.
|
|
9
|
+
*/
|
|
10
|
+
resolve: Promise<T> | Deferred<T>;
|
|
5
11
|
fallback: ReactNode;
|
|
6
12
|
children: (data: T) => ReactNode;
|
|
7
13
|
}
|
|
@@ -13,7 +19,8 @@ interface AwaitProps<T> {
|
|
|
13
19
|
* with the resolved value.
|
|
14
20
|
*/
|
|
15
21
|
function Resolved<T>({ resolve, children }: Pick<AwaitProps<T>, "resolve" | "children">) {
|
|
16
|
-
const
|
|
22
|
+
const promise = resolve instanceof Deferred ? resolve.promise : resolve;
|
|
23
|
+
const data = use(promise);
|
|
17
24
|
return <>{children(data)}</>;
|
|
18
25
|
}
|
|
19
26
|
|