@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
|
@@ -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, RouteMatch, 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,56 @@ 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 ?? {});
|
|
50
|
+
const [matches, setMatches] = useState<RouteMatch[]>(initialData.matches ?? []);
|
|
38
51
|
const [navState, setNavState] = useState<NavigationState>("idle");
|
|
52
|
+
const [revalidationState, setRevalidationState] = useState<"idle" | "loading">("idle");
|
|
39
53
|
const [currentModule, setCurrentModule] = useState<RouteModuleClient | null>(initialModule);
|
|
40
54
|
const [meta, setMeta] = useState<MetaDescriptor[]>(initialData.meta ?? []);
|
|
55
|
+
const [hydrationPending, setHydrationPending] = useState<HydrationPending>(initialData.ssrMode ?? false);
|
|
41
56
|
|
|
42
57
|
const manifest = initialData.manifest;
|
|
43
58
|
|
|
44
59
|
// Stable ref to navigate so loadRoute can call it without a circular dep.
|
|
45
60
|
const navigateRef = useRef<(to: string) => Promise<void>>(null!);
|
|
46
61
|
|
|
62
|
+
// Refs mirroring state that the stable revalidate/submit callbacks need.
|
|
63
|
+
const locationRef = useRef(location);
|
|
64
|
+
useEffect(() => { locationRef.current = location; }, [location]);
|
|
65
|
+
const paramsRef = useRef(params);
|
|
66
|
+
useEffect(() => { paramsRef.current = params; }, [params]);
|
|
67
|
+
const currentModuleRef = useRef(currentModule);
|
|
68
|
+
useEffect(() => { currentModuleRef.current = currentModule; }, [currentModule]);
|
|
69
|
+
|
|
47
70
|
const setRoute = useCallback((state: Partial<RouteState>) => {
|
|
48
71
|
if (state.loaderData !== undefined) setLoaderData(state.loaderData);
|
|
49
72
|
if (state.actionData !== undefined) setActionData(state.actionData);
|
|
50
73
|
if (state.params !== undefined) setParams(state.params);
|
|
51
|
-
if (state.
|
|
74
|
+
if (state.search !== undefined) setSearch(state.search);
|
|
75
|
+
if (state.matches !== undefined) setMatches(state.matches);
|
|
76
|
+
if (state.location !== undefined) setLocation(state.location);
|
|
77
|
+
else if (state.pathname !== undefined) {
|
|
78
|
+
// Legacy callers pass a (possibly query-carrying) pathname string.
|
|
79
|
+
const parsed = parseTo(state.pathname);
|
|
80
|
+
setLocation((prev) => ({ ...prev, ...parsed }));
|
|
81
|
+
}
|
|
52
82
|
}, []);
|
|
53
83
|
|
|
54
84
|
/** Load route data + module without touching history. */
|
|
55
|
-
const loadRoute = useCallback(async (to: string) => {
|
|
85
|
+
const loadRoute = useCallback(async (to: string, locInit?: LocationInit) => {
|
|
56
86
|
setNavState("loading");
|
|
57
87
|
// Follow a redirect Location from client-side beforeLoad. Same-origin
|
|
58
88
|
// targets stay in the SPA; an off-origin/protocol-relative Location is NOT
|
|
@@ -64,7 +94,18 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
64
94
|
window.location.href = loc;
|
|
65
95
|
};
|
|
66
96
|
try {
|
|
67
|
-
const toPathname = to
|
|
97
|
+
const { pathname: toPathname, search: toSearch, hash: toHash } = parseTo(to);
|
|
98
|
+
// The path handed to /_data: never includes the hash (the fragment is
|
|
99
|
+
// client-only) and never inherits the previous page's query string —
|
|
100
|
+
// what you navigate to is exactly what loads.
|
|
101
|
+
const dataPath = toPathname + toSearch;
|
|
102
|
+
const nextLocation: RouterLocation = {
|
|
103
|
+
pathname: toPathname,
|
|
104
|
+
search: toSearch,
|
|
105
|
+
hash: toHash,
|
|
106
|
+
state: locInit?.state ?? null,
|
|
107
|
+
key: locInit?.key ?? createLocationKey(),
|
|
108
|
+
};
|
|
68
109
|
const pattern = matchPatternForPath(toPathname, manifest);
|
|
69
110
|
const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
70
111
|
|
|
@@ -99,8 +140,21 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
99
140
|
}
|
|
100
141
|
}
|
|
101
142
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
143
|
+
// Commit a /_data payload + the new location in one transition.
|
|
144
|
+
const commit = (data: Record<string, unknown>, module: RouteModuleClient | null) => {
|
|
145
|
+
startTransition(() => {
|
|
146
|
+
setLoaderData(data);
|
|
147
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
148
|
+
setLocation(nextLocation);
|
|
149
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
150
|
+
setCurrentModule(module);
|
|
151
|
+
// Re-render the document head from the new route's merged meta.
|
|
152
|
+
// React 19 hoists the <title>/<meta> elements rendered by <MetaTags>
|
|
153
|
+
// into <head>, so description/OG tags update on soft navigation.
|
|
154
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
155
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
156
|
+
});
|
|
157
|
+
};
|
|
104
158
|
|
|
105
159
|
// ── Cache lookup (B1 / B2) ──────────────────────────────────────────
|
|
106
160
|
// Read config and loaderDeps from the route module if available.
|
|
@@ -113,33 +167,34 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
113
167
|
const loaderDepsFn = (routeModule as Record<string, unknown> | null)?.loaderDeps as
|
|
114
168
|
| ((args: { searchParams: URLSearchParams }) => unknown[])
|
|
115
169
|
| undefined;
|
|
116
|
-
const searchParams = new URLSearchParams(
|
|
117
|
-
const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [
|
|
170
|
+
const searchParams = new URLSearchParams(toSearch);
|
|
171
|
+
const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [dataPath];
|
|
118
172
|
const key = cacheKey(toPathname, deps);
|
|
119
173
|
|
|
120
174
|
const cached = loaderCache.get(key);
|
|
121
175
|
if (cached?.fresh) {
|
|
122
176
|
// 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
|
-
});
|
|
177
|
+
commit(cached.data, routeModule);
|
|
129
178
|
setNavState("idle");
|
|
130
179
|
return;
|
|
131
180
|
}
|
|
132
181
|
if (cached && !cached.fresh) {
|
|
133
182
|
// 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
|
-
});
|
|
183
|
+
commit(cached.data, routeModule);
|
|
140
184
|
setNavState("idle");
|
|
185
|
+
// The route can veto the background refetch via shouldRevalidate.
|
|
186
|
+
const gate = (routeModule as Record<string, unknown> | null)
|
|
187
|
+
?.shouldRevalidate as ShouldRevalidateFunction | undefined;
|
|
188
|
+
const allowRefetch = gate
|
|
189
|
+
? gate({
|
|
190
|
+
currentUrl: new URL(window.location.href),
|
|
191
|
+
nextUrl: new URL(dataPath, window.location.origin),
|
|
192
|
+
defaultShouldRevalidate: true,
|
|
193
|
+
})
|
|
194
|
+
: true;
|
|
195
|
+
if (!allowRefetch) return;
|
|
141
196
|
// Revalidate in background.
|
|
142
|
-
void fetch(`/_data?path=${encodeURIComponent(
|
|
197
|
+
void fetch(`/_data?path=${encodeURIComponent(dataPath)}`)
|
|
143
198
|
.then((r) => r.ok ? r.json() : null)
|
|
144
199
|
.then((fresh) => {
|
|
145
200
|
if (!fresh) return;
|
|
@@ -147,13 +202,16 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
147
202
|
startTransition(() => {
|
|
148
203
|
setLoaderData(fresh as Record<string, unknown>);
|
|
149
204
|
setParams(((fresh as Record<string, unknown>).params as Record<string, string>) ?? {});
|
|
205
|
+
setSearch(((fresh as Record<string, unknown>).search as Record<string, unknown>) ?? {});
|
|
206
|
+
setMeta(((fresh as Record<string, unknown>).meta as MetaDescriptor[] | undefined) ?? []);
|
|
207
|
+
setMatches(((fresh as Record<string, unknown>).matches as RouteMatch[] | undefined) ?? []);
|
|
150
208
|
});
|
|
151
209
|
});
|
|
152
210
|
return;
|
|
153
211
|
}
|
|
154
212
|
|
|
155
213
|
// Cache miss — fetch from server.
|
|
156
|
-
const res = await fetch(`/_data?path=${encodeURIComponent(
|
|
214
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(dataPath)}`);
|
|
157
215
|
// Guard: always parse JSON, but only when the server signals success.
|
|
158
216
|
// Without res.ok check, a Bun 500 plain-text response causes
|
|
159
217
|
// SyntaxError: JSON.parse: unexpected character — an unhandled rejection.
|
|
@@ -163,6 +221,27 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
163
221
|
return;
|
|
164
222
|
}
|
|
165
223
|
const data = await res.json() as Record<string, unknown>;
|
|
224
|
+
|
|
225
|
+
// clientLoader (RR7-style): when the route exports one, it runs in the
|
|
226
|
+
// browser and its result replaces the route's loader slice. It receives a
|
|
227
|
+
// `serverLoader()` that resolves to the freshly-fetched server data, so a
|
|
228
|
+
// clientLoader can wrap/augment/cache it. Other slices (root/layouts) and
|
|
229
|
+
// the meta/matches payload are untouched.
|
|
230
|
+
const clientLoader = (routeModule as Record<string, unknown> | null)
|
|
231
|
+
?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
|
|
232
|
+
if (typeof clientLoader === "function") {
|
|
233
|
+
try {
|
|
234
|
+
data.route = await clientLoader({
|
|
235
|
+
request: new Request(new URL(dataPath, window.location.origin)),
|
|
236
|
+
params: (data.params as Record<string, string>) ?? {},
|
|
237
|
+
search: (data.search as Record<string, unknown>) ?? {},
|
|
238
|
+
serverLoader: () => Promise.resolve(data.route),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error("[bractjs] clientLoader error:", err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
166
245
|
if (staleTime > 0) loaderCache.set(key, data, staleTime, gcTime);
|
|
167
246
|
|
|
168
247
|
// Update DevTools state (dev-only — no-op in prod since the import fails).
|
|
@@ -183,17 +262,7 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
183
262
|
});
|
|
184
263
|
}).catch(() => {/* devtools not available in prod */});
|
|
185
264
|
}
|
|
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));
|
|
265
|
+
commit(data, routeModule);
|
|
197
266
|
} catch (err) {
|
|
198
267
|
console.error("[bractjs] loadRoute error:", err);
|
|
199
268
|
} finally {
|
|
@@ -201,17 +270,165 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
201
270
|
}
|
|
202
271
|
}, [manifest]);
|
|
203
272
|
|
|
204
|
-
const navigate = useCallback(async (to: string) => {
|
|
205
|
-
|
|
206
|
-
|
|
273
|
+
const navigate = useCallback(async (to: string, options?: NavigateOptions) => {
|
|
274
|
+
const key = createLocationKey();
|
|
275
|
+
await loadRoute(to, { key, state: options?.state ?? null });
|
|
276
|
+
const entry = { __bractKey: key, __bractState: options?.state ?? null };
|
|
277
|
+
if (options?.replace) history.replaceState(entry, "", to);
|
|
278
|
+
else history.pushState(entry, "", to);
|
|
207
279
|
}, [loadRoute]);
|
|
208
280
|
|
|
209
281
|
// Keep navigateRef current so loadRoute can redirect via navigate.
|
|
210
282
|
useEffect(() => { navigateRef.current = navigate; }, [navigate]);
|
|
211
283
|
|
|
212
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Re-run the active route's loaders and commit fresh data without touching
|
|
286
|
+
* history or the location. Gated by the route's shouldRevalidate export;
|
|
287
|
+
* mutation-triggered runs (info.formMethod set) first drop the whole loader
|
|
288
|
+
* cache — any cached entry may reflect pre-mutation state.
|
|
289
|
+
*/
|
|
290
|
+
const revalidate = useCallback(async (info?: RevalidationInfo) => {
|
|
291
|
+
const loc = locationRef.current;
|
|
292
|
+
const path = loc.pathname + loc.search;
|
|
293
|
+
const gate = (currentModuleRef.current as Record<string, unknown> | null)
|
|
294
|
+
?.shouldRevalidate as ShouldRevalidateFunction | undefined;
|
|
295
|
+
const url = new URL(path, window.location.origin);
|
|
296
|
+
const allow = gate
|
|
297
|
+
? gate({
|
|
298
|
+
currentUrl: url,
|
|
299
|
+
nextUrl: url,
|
|
300
|
+
formMethod: info?.formMethod,
|
|
301
|
+
actionStatus: info?.actionStatus,
|
|
302
|
+
defaultShouldRevalidate: true,
|
|
303
|
+
})
|
|
304
|
+
: true;
|
|
305
|
+
if (!allow) return;
|
|
306
|
+
if (info?.formMethod) loaderCache.clear();
|
|
307
|
+
setRevalidationState("loading");
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
console.error(`[bractjs] revalidate /_data ${res.status} for ${path}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
315
|
+
startTransition(() => {
|
|
316
|
+
setLoaderData(data);
|
|
317
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
318
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
319
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
320
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error("[bractjs] revalidate error:", err);
|
|
324
|
+
} finally {
|
|
325
|
+
setRevalidationState("idle");
|
|
326
|
+
}
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
// Let fetchers trigger revalidation without importing this component.
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
registerRevalidator(revalidate);
|
|
332
|
+
return () => registerRevalidator(null);
|
|
333
|
+
}, [revalidate]);
|
|
334
|
+
|
|
335
|
+
// clientLoader.hydrate: for a fully-SSR'd route whose clientLoader opted into
|
|
336
|
+
// hydration, run it once after mount and replace the route's loader slice.
|
|
337
|
+
// Routes that didn't SSR (hydrationPending truthy) take the fetch path below,
|
|
338
|
+
// where clientLoader already applies via loadRoute on navigation; this effect
|
|
339
|
+
// is only for the first paint of an SSR document.
|
|
213
340
|
useEffect(() => {
|
|
214
|
-
|
|
341
|
+
if (hydrationPending) return;
|
|
342
|
+
const cl = (initialModule as Record<string, unknown> | null)
|
|
343
|
+
?.clientLoader as import("../shared/route-types.ts").ClientLoaderFunction | undefined;
|
|
344
|
+
if (typeof cl !== "function" || cl.hydrate !== true) return;
|
|
345
|
+
let cancelled = false;
|
|
346
|
+
void (async () => {
|
|
347
|
+
const path = window.location.pathname + window.location.search;
|
|
348
|
+
const serverSlice = (initialData.loaderData as Record<string, unknown>)?.route;
|
|
349
|
+
try {
|
|
350
|
+
const next = await cl({
|
|
351
|
+
request: new Request(new URL(path, window.location.origin)),
|
|
352
|
+
params: initialData.params,
|
|
353
|
+
search: initialData.search ?? {},
|
|
354
|
+
serverLoader: async () => serverSlice,
|
|
355
|
+
});
|
|
356
|
+
if (cancelled) return;
|
|
357
|
+
startTransition(() => {
|
|
358
|
+
setLoaderData((prev) => ({ ...prev, route: next }));
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("[bractjs] clientLoader (hydrate) error:", err);
|
|
362
|
+
}
|
|
363
|
+
})();
|
|
364
|
+
return () => { cancelled = true; };
|
|
365
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
// Selective-SSR / SPA hydration completion. The first client render matched
|
|
369
|
+
// the server (Fallback or empty shell); after mount, put loader data in
|
|
370
|
+
// place and swap in the real component via a transition.
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!hydrationPending) return;
|
|
373
|
+
if (hydrationPending === "data-only") {
|
|
374
|
+
// Loaders already ran on the server — the data arrived in the bootstrap.
|
|
375
|
+
startTransition(() => setHydrationPending(false));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// "client-only" / "spa": the route loader never ran for this document.
|
|
379
|
+
void (async () => {
|
|
380
|
+
const path = window.location.pathname + window.location.search;
|
|
381
|
+
try {
|
|
382
|
+
const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
|
|
383
|
+
// A redirect here is a beforeLoad gate (SPA shells skip server-side
|
|
384
|
+
// gating on the document). Do a real navigation — never render a
|
|
385
|
+
// protected route around redirected data.
|
|
386
|
+
if (res.redirected) {
|
|
387
|
+
const safe = toSamePath(res.url);
|
|
388
|
+
window.location.assign(safe ?? res.url);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (res.ok) {
|
|
392
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
393
|
+
startTransition(() => {
|
|
394
|
+
setLoaderData(data);
|
|
395
|
+
setParams((data.params as Record<string, string>) ?? {});
|
|
396
|
+
setSearch((data.search as Record<string, unknown>) ?? {});
|
|
397
|
+
setMeta((data.meta as MetaDescriptor[] | undefined) ?? []);
|
|
398
|
+
setMatches((data.matches as RouteMatch[] | undefined) ?? []);
|
|
399
|
+
setHydrationPending(false);
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
console.error(`[bractjs] hydration /_data ${res.status} for ${path}`);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error("[bractjs] hydration fetch error:", err);
|
|
406
|
+
}
|
|
407
|
+
startTransition(() => setHydrationPending(false));
|
|
408
|
+
})();
|
|
409
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
410
|
+
}, []);
|
|
411
|
+
|
|
412
|
+
// Stamp the initial history entry with our key so back/forward to it can
|
|
413
|
+
// restore scroll position. Merge into any pre-existing state, don't replace.
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
const st = history.state as Record<string, unknown> | null;
|
|
416
|
+
if (!st || typeof st.__bractKey !== "string") {
|
|
417
|
+
history.replaceState({ ...(st ?? {}), __bractKey: initialData.location.key }, "", window.location.href);
|
|
418
|
+
}
|
|
419
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
420
|
+
}, []);
|
|
421
|
+
|
|
422
|
+
// Handle browser back / forward. `window.location` must stay explicit here —
|
|
423
|
+
// the component has a `location` state variable that would shadow the global.
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
const onPopState = (e: PopStateEvent) => {
|
|
426
|
+
const st = e.state as { __bractKey?: string; __bractState?: unknown } | null;
|
|
427
|
+
void loadRoute(
|
|
428
|
+
window.location.pathname + window.location.search + window.location.hash,
|
|
429
|
+
{ key: st?.__bractKey ?? "default", state: st?.__bractState ?? null },
|
|
430
|
+
);
|
|
431
|
+
};
|
|
215
432
|
window.addEventListener("popstate", onPopState);
|
|
216
433
|
return () => window.removeEventListener("popstate", onPopState);
|
|
217
434
|
}, [loadRoute]);
|
|
@@ -225,22 +442,97 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
225
442
|
const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
|
|
226
443
|
if (w.__BRACT_DEV__ !== true) return;
|
|
227
444
|
w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
|
|
228
|
-
const current = matchPatternForPath(pathname, manifest);
|
|
445
|
+
const current = matchPatternForPath(location.pathname, manifest);
|
|
229
446
|
if (current === pattern) startTransition(() => setCurrentModule(mod));
|
|
230
447
|
};
|
|
231
448
|
return () => { delete w.__BRACTJS_HMR_ACCEPT__; };
|
|
232
|
-
}, [pathname, manifest]);
|
|
449
|
+
}, [location.pathname, manifest]);
|
|
233
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Submit a mutation: navState walks "submitting" → "loading" (revalidation)
|
|
453
|
+
* → "idle", which is what `useNavigation()` renders pending UI from. The
|
|
454
|
+
* fetch mirrors `<Form>`'s contract: the CSRF header marks it a same-origin
|
|
455
|
+
* mutation, and a redirected response becomes a real navigation — via
|
|
456
|
+
* toSamePath so an attacker-controlled Location can never soft-nav the SPA.
|
|
457
|
+
*/
|
|
234
458
|
const submit = useCallback(async (
|
|
235
|
-
|
|
236
|
-
|
|
459
|
+
to: string,
|
|
460
|
+
opts: { method: string; body: FormData | Record<string, string> },
|
|
237
461
|
) => {
|
|
238
462
|
setNavState("submitting");
|
|
239
|
-
|
|
240
|
-
|
|
463
|
+
try {
|
|
464
|
+
const body = opts.body instanceof FormData
|
|
465
|
+
? opts.body
|
|
466
|
+
: new URLSearchParams(opts.body);
|
|
467
|
+
|
|
468
|
+
// The server submit — also the `serverAction()` a clientAction can call.
|
|
469
|
+
// A redirected response short-circuits to a real navigation (via
|
|
470
|
+
// toSamePath so an attacker Location can never soft-nav the SPA); it
|
|
471
|
+
// returns a sentinel so the caller stops.
|
|
472
|
+
const REDIRECTED = Symbol("redirected");
|
|
473
|
+
let lastStatus = 0;
|
|
474
|
+
const doServerPost = async (): Promise<unknown> => {
|
|
475
|
+
const res = await fetch(to, {
|
|
476
|
+
method: opts.method.toUpperCase(),
|
|
477
|
+
body,
|
|
478
|
+
headers: { "X-BractJS-Action": "1" },
|
|
479
|
+
});
|
|
480
|
+
lastStatus = res.status;
|
|
481
|
+
if (res.redirected) {
|
|
482
|
+
const safe = toSamePath(res.url);
|
|
483
|
+
if (safe) { await navigateRef.current(safe); return REDIRECTED; }
|
|
484
|
+
window.location.assign(res.url);
|
|
485
|
+
return REDIRECTED;
|
|
486
|
+
}
|
|
487
|
+
return res.json();
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// clientAction (RR7-style): if the target route exports one, it runs in
|
|
491
|
+
// the browser and decides whether/how to hit the server via serverAction().
|
|
492
|
+
const [toPath] = to.split("?");
|
|
493
|
+
const pattern = matchPatternForPath(toPath, manifest);
|
|
494
|
+
const chunkUrl = pattern !== null ? manifest.routes[pattern]?.chunk : undefined;
|
|
495
|
+
let clientAction: import("../shared/route-types.ts").ClientActionFunction | undefined;
|
|
496
|
+
if (chunkUrl) {
|
|
497
|
+
try {
|
|
498
|
+
const mod = await import(/* @vite-ignore */ chunkUrl) as Record<string, unknown>;
|
|
499
|
+
if (typeof mod.clientAction === "function") {
|
|
500
|
+
clientAction = mod.clientAction as import("../shared/route-types.ts").ClientActionFunction;
|
|
501
|
+
}
|
|
502
|
+
} catch { /* fall back to a plain server submit */ }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let data: unknown;
|
|
506
|
+
if (clientAction) {
|
|
507
|
+
let calledServer = false;
|
|
508
|
+
data = await clientAction({
|
|
509
|
+
request: new Request(new URL(to, window.location.origin), { method: opts.method.toUpperCase() }),
|
|
510
|
+
params: paramsRef.current,
|
|
511
|
+
formData: body instanceof FormData ? body : new FormData(),
|
|
512
|
+
serverAction: () => { calledServer = true; return doServerPost(); },
|
|
513
|
+
});
|
|
514
|
+
// If the clientAction triggered a redirect via serverAction(), stop.
|
|
515
|
+
if (calledServer && data === REDIRECTED) return;
|
|
516
|
+
} else {
|
|
517
|
+
data = await doServerPost();
|
|
518
|
+
if (data === REDIRECTED) return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
setActionData(data);
|
|
522
|
+
setNavState("loading");
|
|
523
|
+
await revalidate({ formMethod: opts.method, actionStatus: lastStatus });
|
|
524
|
+
} finally {
|
|
525
|
+
setNavState("idle");
|
|
526
|
+
}
|
|
527
|
+
}, [revalidate, manifest]);
|
|
241
528
|
|
|
242
529
|
return (
|
|
243
|
-
<RouterContext.Provider
|
|
530
|
+
<RouterContext.Provider
|
|
531
|
+
value={{
|
|
532
|
+
loaderData, actionData, params, pathname: location.pathname, location, search, matches,
|
|
533
|
+
manifest, currentModule, setRoute, revalidate, revalidationState, hydrationPending,
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
244
536
|
<NavigationContext.Provider value={{ state: navState, navigate, submit }}>
|
|
245
537
|
<MetaTags meta={meta} />
|
|
246
538
|
{children}
|
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
|
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { useContext, type FormEvent, type ReactNode, type FormHTMLAttributes } from "react";
|
|
2
2
|
import { RouterContext, NavigationContext } from "../router.tsx";
|
|
3
|
-
import { reloadLoaders } from "../form-utils.ts";
|
|
4
|
-
import { toSamePath } from "../nav-utils.ts";
|
|
5
3
|
|
|
6
4
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
7
5
|
|
|
@@ -10,66 +8,57 @@ type FormMethod = "post" | "put" | "delete";
|
|
|
10
8
|
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, "method" | "onSubmit"> {
|
|
11
9
|
method?: FormMethod;
|
|
12
10
|
action?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Convenience: renders `<input type="hidden" name="intent" value={intent}>`
|
|
13
|
+
* as the first child, so a single route action can dispatch on it (pairs with
|
|
14
|
+
* `defineActions()`). Carried on no-JS POSTs too.
|
|
15
|
+
*/
|
|
16
|
+
intent?: string;
|
|
13
17
|
children: ReactNode;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
17
21
|
|
|
18
|
-
export function Form({ method = "post", action, children, ...rest }: FormProps) {
|
|
22
|
+
export function Form({ method = "post", action, intent, children, ...rest }: FormProps) {
|
|
19
23
|
const routerCtx = useContext(RouterContext);
|
|
20
24
|
const navCtx = useContext(NavigationContext);
|
|
25
|
+
// The hidden intent input, rendered first so it's part of every submission
|
|
26
|
+
// (JS and native). `key` keeps React happy alongside arbitrary children.
|
|
27
|
+
const intentInput = intent !== undefined
|
|
28
|
+
? <input key="__bract_intent" type="hidden" name="intent" value={intent} />
|
|
29
|
+
: null;
|
|
21
30
|
|
|
22
31
|
// SSR: render a plain form — no JS submit handler needed
|
|
23
32
|
if (!routerCtx || !navCtx) {
|
|
24
33
|
return (
|
|
25
34
|
<form method={method} action={action} {...rest}>
|
|
35
|
+
{intentInput}
|
|
26
36
|
{children}
|
|
27
37
|
</form>
|
|
28
38
|
);
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
const {
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
-
// setLoaderData shim — updates just the loaderData slice via setRoute
|
|
35
|
-
function setLoaderData(data: Record<string, unknown>) {
|
|
36
|
-
setRoute({ loaderData: data });
|
|
37
|
-
}
|
|
41
|
+
const { location, setRoute } = routerCtx;
|
|
42
|
+
const { submit } = navCtx;
|
|
38
43
|
|
|
39
44
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
40
45
|
e.preventDefault();
|
|
41
46
|
setRoute({ actionData: null }); // clear stale action data
|
|
42
47
|
|
|
43
48
|
const target = e.currentTarget;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const response = await fetch(url, {
|
|
48
|
-
method: method.toUpperCase(),
|
|
49
|
-
body: formData,
|
|
50
|
-
headers: { "X-BractJS-Action": "1" },
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// The action returned (or threw) a redirect. The browser auto-follows the
|
|
54
|
-
// 3xx, so `response.url` is the *absolute* final URL — normalize it to a
|
|
55
|
-
// same-origin path before handing it to the client router, which matches a
|
|
56
|
-
// route pattern against the pathname (an absolute URL wouldn't match). An
|
|
57
|
-
// off-origin final URL is NOT handed to the SPA router: fall back to a
|
|
58
|
-
// full-page navigation so we don't open-redirect through it.
|
|
59
|
-
if (response.redirected) {
|
|
60
|
-
const to = toSamePath(response.url);
|
|
61
|
-
if (to) { await navigate(to); return; }
|
|
62
|
-
window.location.href = response.url;
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
49
|
+
// Default to the full current URL (pathname + search) so actions can read
|
|
50
|
+
// the same search params their page was rendered with.
|
|
51
|
+
const url = action ?? location.pathname + location.search;
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
// The router's submit drives useNavigation() through "submitting" →
|
|
54
|
+
// "loading" → "idle", commits the action data, follows redirects safely
|
|
55
|
+
// (CSRF header + same-origin guard), and revalidates loaders.
|
|
56
|
+
await submit(url, { method, body: new FormData(target) });
|
|
69
57
|
}
|
|
70
58
|
|
|
71
59
|
return (
|
|
72
60
|
<form method={method} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
|
|
61
|
+
{intentInput}
|
|
73
62
|
{children}
|
|
74
63
|
</form>
|
|
75
64
|
);
|