@bractjs/bractjs 0.1.27 → 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.
Files changed (95) hide show
  1. package/README.md +242 -36
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/codegen-write.test.ts +67 -0
  5. package/src/__tests__/codegen.test.ts +29 -2
  6. package/src/__tests__/compile-safety.test.ts +4 -0
  7. package/src/__tests__/csp.test.ts +10 -0
  8. package/src/__tests__/define-actions.test.ts +69 -0
  9. package/src/__tests__/env.test.ts +18 -0
  10. package/src/__tests__/fetcher-store.test.ts +67 -0
  11. package/src/__tests__/fixtures/app/root.tsx +7 -2
  12. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  13. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  17. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  18. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  19. package/src/__tests__/form-data-helpers.test.ts +43 -0
  20. package/src/__tests__/integration.test.ts +56 -0
  21. package/src/__tests__/loader.test.ts +32 -1
  22. package/src/__tests__/nav-utils.test.ts +46 -0
  23. package/src/__tests__/prerender.test.ts +102 -0
  24. package/src/__tests__/programmatic-api.test.ts +20 -1
  25. package/src/__tests__/revalidation.test.ts +65 -0
  26. package/src/__tests__/route-lint.test.ts +74 -0
  27. package/src/__tests__/route-table.test.ts +33 -0
  28. package/src/__tests__/safe-validate.test.ts +96 -0
  29. package/src/__tests__/scroll-restoration.test.ts +66 -0
  30. package/src/__tests__/search-serializer.test.ts +42 -0
  31. package/src/__tests__/search-validation.test.ts +125 -0
  32. package/src/__tests__/security.test.ts +110 -1
  33. package/src/__tests__/selective-ssr.test.ts +85 -0
  34. package/src/__tests__/spa-mode.test.ts +77 -0
  35. package/src/__tests__/typed-routing.test.ts +51 -1
  36. package/src/build/bundler.ts +33 -0
  37. package/src/build/prerender.ts +88 -0
  38. package/src/build/route-lint.ts +49 -0
  39. package/src/client/ClientRouter.tsx +239 -47
  40. package/src/client/cache.ts +8 -0
  41. package/src/client/components/Await.tsx +9 -2
  42. package/src/client/components/Form.tsx +23 -34
  43. package/src/client/components/Link.tsx +80 -9
  44. package/src/client/components/Outlet.tsx +8 -2
  45. package/src/client/components/ScrollRestoration.tsx +125 -0
  46. package/src/client/entry.tsx +39 -2
  47. package/src/client/fetcher-store.ts +61 -0
  48. package/src/client/form-utils.ts +3 -0
  49. package/src/client/hooks/useActionData.ts +7 -3
  50. package/src/client/hooks/useFetcher.ts +116 -33
  51. package/src/client/hooks/useFetchers.ts +23 -0
  52. package/src/client/hooks/useLoaderData.ts +8 -4
  53. package/src/client/hooks/useLocation.ts +27 -0
  54. package/src/client/hooks/useNavigate.ts +11 -6
  55. package/src/client/hooks/useRevalidator.ts +26 -0
  56. package/src/client/hooks/useSearch.ts +73 -0
  57. package/src/client/hooks/useSearchParams.ts +7 -2
  58. package/src/client/nav-utils.ts +26 -0
  59. package/src/client/prefetch.ts +110 -15
  60. package/src/client/registry.ts +24 -0
  61. package/src/client/revalidation.ts +25 -0
  62. package/src/client/router.tsx +28 -1
  63. package/src/client/scroll-restoration.ts +48 -0
  64. package/src/client/search-serializer.ts +40 -0
  65. package/src/client/types.ts +6 -0
  66. package/src/codegen/route-codegen.ts +141 -8
  67. package/src/config/load.ts +21 -0
  68. package/src/dev/hmr-client.ts +3 -1
  69. package/src/dev/route-table.ts +27 -0
  70. package/src/dev/server.ts +106 -8
  71. package/src/dev/watcher.ts +25 -3
  72. package/src/index.ts +27 -3
  73. package/src/server/action-handler.ts +12 -3
  74. package/src/server/action-registry.ts +35 -0
  75. package/src/server/csp.ts +10 -1
  76. package/src/server/csrf.ts +26 -0
  77. package/src/server/env.ts +26 -5
  78. package/src/server/layout.ts +31 -1
  79. package/src/server/loader.ts +14 -8
  80. package/src/server/render.ts +18 -3
  81. package/src/server/request-handler.ts +50 -8
  82. package/src/server/search.ts +43 -0
  83. package/src/server/serve.ts +88 -1
  84. package/src/server/spa.ts +62 -0
  85. package/src/server/stream-handler.ts +10 -1
  86. package/src/server/validate.ts +85 -13
  87. package/src/shared/context.ts +5 -0
  88. package/src/shared/define-actions.ts +39 -0
  89. package/src/shared/form-data.ts +34 -0
  90. package/src/shared/route-types.ts +83 -2
  91. package/templates/new-app/app/root.tsx +2 -1
  92. package/templates/new-app/bractjs.config.ts +7 -12
  93. package/types/config.d.ts +21 -0
  94. package/types/index.d.ts +165 -9
  95. package/types/route.d.ts +62 -2
@@ -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 [pathname, setPathname] = useState(initialData.pathname);
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.pathname !== undefined) setPathname(state.pathname);
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.split("?")[0];
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
- // Include search params in the /_data path param so loaders receive them.
103
- const toWithSearch = to.includes("?") ? to : to + window.location.search;
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(toWithSearch.split("?")[1] ?? "");
117
- const deps = loaderDepsFn ? loaderDepsFn({ searchParams }) : [toWithSearch];
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
- startTransition(() => {
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
- startTransition(() => {
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(toWithSearch)}`)
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(toWithSearch)}`);
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
- startTransition(() => {
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
- await loadRoute(to);
206
- history.pushState({}, "", to);
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
- // Handle browser back / forward
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
- const onPopState = () => { void loadRoute(location.pathname + location.search); };
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
- _to: string,
236
- _opts: { method: string; body: FormData | Record<string, string> },
397
+ to: string,
398
+ opts: { method: string; body: FormData | Record<string, string> },
237
399
  ) => {
238
400
  setNavState("submitting");
239
- setNavState("idle");
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 value={{ loaderData, actionData, params, pathname, manifest, currentModule, setRoute }}>
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}
@@ -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
- resolve: Promise<T>;
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 data = use(resolve);
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 { pathname, setRoute } = routerCtx;
32
- const { navigate } = navCtx;
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
- const url = action ?? pathname;
45
- const formData = new FormData(target);
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
- const actionData = (await response.json()) as unknown;
67
- setRoute({ actionData });
68
- await reloadLoaders(pathname, setLoaderData);
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
  );