@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.
Files changed (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
@@ -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
  );
@@ -1,16 +1,49 @@
1
- import { useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
1
+ import {
2
+ useContext, useEffect, useRef, useCallback,
3
+ type AnchorHTMLAttributes, type ReactNode,
4
+ } from "react";
2
5
  import { NavigationContext, RouterContext } from "../router.tsx";
3
- import { prefetchRoute } from "../prefetch.ts";
6
+ import { prefetchRoute, observeOnce } from "../prefetch.ts";
7
+ import { buildPath } from "../build-path.ts";
8
+ import { withSearch } from "../search-serializer.ts";
9
+ import type { RegisteredRoutes, ParamsFor, SearchOutputFor } from "../registry.ts";
4
10
 
5
11
  // ── Types ──────────────────────────────────────────────────────────────────
6
12
 
7
- interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
8
- to: string;
9
- prefetch?: "hover" | "none";
13
+ /**
14
+ * When to prefetch the target route's chunk + loader data:
15
+ * - `"none"` (default) — never.
16
+ * - `"intent"` — on hover/focus, after a short delay (canceled if the pointer
17
+ * leaves). The best default for most links.
18
+ * - `"hover"` — immediately on mouseenter (legacy alias of intent without the
19
+ * delay; kept for back-compat).
20
+ * - `"viewport"` — when the link scrolls into view (shared
21
+ * IntersectionObserver). Good for lists.
22
+ * - `"render"` — as soon as the link mounts.
23
+ */
24
+ type PrefetchMode = "none" | "intent" | "hover" | "viewport" | "render";
25
+
26
+ // `to` accepts any registered route literal (autocomplete + typed `params`) but
27
+ // also any string via `(string & {})`, so existing call sites that build the URL
28
+ // themselves — `to={`/posts/${slug}`}`, `to={item.href}` — keep compiling. Run
29
+ // `bractjs codegen` to register the app's routes and unlock autocomplete; until
30
+ // then `RegisteredRoutes` is `string` and this is just today's loose prop.
31
+ type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = Omit<
32
+ AnchorHTMLAttributes<HTMLAnchorElement>,
33
+ "href"
34
+ > & {
35
+ to: TTo | (string & {});
36
+ /** Path params for a dynamic `to` (e.g. `params={{ id }}` for `/blog/:id`). */
37
+ params?: ParamsFor<TTo>;
38
+ /** Search params for the target, typed by its `searchSchema` (replaces any query in `to`). */
39
+ search?: Partial<SearchOutputFor<TTo>>;
40
+ prefetch?: PrefetchMode;
10
41
  /** Opt in to View Transitions API for this navigation (E1). */
11
42
  viewTransition?: boolean;
43
+ /** Replace the current history entry instead of pushing. */
44
+ replace?: boolean;
12
45
  children: ReactNode;
13
- }
46
+ };
14
47
 
15
48
  // ── Component ──────────────────────────────────────────────────────────────
16
49
 
@@ -19,11 +52,51 @@ const supportsViewTransitions =
19
52
  typeof document !== "undefined" &&
20
53
  typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
21
54
 
22
- export function Link({ to, prefetch = "none", viewTransition = false, children, ...rest }: LinkProps) {
55
+ /** Hover-intent delay before prefetching cancels on a fly-by pointer. */
56
+ const INTENT_DELAY_MS = 100;
57
+
58
+ export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
59
+ to,
60
+ params,
61
+ search,
62
+ prefetch = "none",
63
+ viewTransition = false,
64
+ replace,
65
+ children,
66
+ ...rest
67
+ }: LinkProps<TTo>) {
23
68
  const navCtx = useContext(NavigationContext);
24
69
  const routerCtx = useContext(RouterContext);
25
70
  const isLoading = navCtx?.state === "loading";
26
71
 
72
+ // Resolve the final href once: substitute params into a dynamic pattern, or
73
+ // pass an already-built string straight through; then apply `search`.
74
+ const base = params ? buildPath(to as string, params as Record<string, string>) : (to as string);
75
+ const href = withSearch(base, search as Record<string, unknown> | undefined);
76
+
77
+ const anchorRef = useRef<HTMLAnchorElement>(null);
78
+ const intentTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
79
+
80
+ const triggerPrefetch = useCallback(() => {
81
+ if (routerCtx) void prefetchRoute(href, routerCtx.manifest);
82
+ }, [href, routerCtx]);
83
+
84
+ // viewport / render modes register in an effect — SSR renders a plain <a>.
85
+ useEffect(() => {
86
+ if (prefetch === "render") {
87
+ triggerPrefetch();
88
+ return;
89
+ }
90
+ if (prefetch === "viewport" && anchorRef.current) {
91
+ return observeOnce(anchorRef.current, triggerPrefetch);
92
+ }
93
+ }, [prefetch, triggerPrefetch]);
94
+
95
+ // Cancel a pending intent timer on unmount.
96
+ useEffect(() => () => {
97
+ if (intentTimer.current) clearTimeout(intentTimer.current);
98
+ }, []);
99
+
27
100
  function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
28
101
  if (!navCtx) return; // SSR: let browser handle naturally
29
102
  if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
@@ -31,22 +104,43 @@ export function Link({ to, prefetch = "none", viewTransition = false, children,
31
104
 
32
105
  if (viewTransition && supportsViewTransitions) {
33
106
  (document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
34
- () => { void navCtx.navigate(to); },
107
+ () => { void navCtx.navigate(href, { replace }); },
35
108
  );
36
109
  } else {
37
- void navCtx.navigate(to);
110
+ void navCtx.navigate(href, { replace });
111
+ }
112
+ }
113
+
114
+ function startIntent() {
115
+ if (prefetch !== "intent" || intentTimer.current) return;
116
+ intentTimer.current = setTimeout(() => {
117
+ intentTimer.current = null;
118
+ triggerPrefetch();
119
+ }, INTENT_DELAY_MS);
120
+ }
121
+
122
+ function cancelIntent() {
123
+ if (intentTimer.current) {
124
+ clearTimeout(intentTimer.current);
125
+ intentTimer.current = null;
38
126
  }
39
127
  }
40
128
 
41
129
  function handleMouseEnter() {
42
- if (prefetch === "hover" && routerCtx) prefetchRoute(to, routerCtx.manifest);
130
+ if (prefetch === "hover") triggerPrefetch();
131
+ else startIntent();
43
132
  }
44
133
 
45
134
  return (
46
135
  <a
47
- href={to}
136
+ href={href}
137
+ ref={anchorRef}
48
138
  onClick={handleClick}
49
139
  onMouseEnter={handleMouseEnter}
140
+ onMouseLeave={cancelIntent}
141
+ onFocus={startIntent}
142
+ onBlur={cancelIntent}
143
+ onTouchStart={prefetch === "intent" || prefetch === "hover" ? triggerPrefetch : undefined}
50
144
  aria-disabled={isLoading || undefined}
51
145
  {...rest}
52
146
  >
@@ -41,8 +41,14 @@ export function Outlet(): ReactElement | null {
41
41
  // Server-side (SSR): fall back to BractJSContext which carries RouteComponent
42
42
  const bractCtx = useContext(BractJSContext);
43
43
 
44
- const RouteComponent: ComponentType | undefined =
45
- routerCtx?.currentModule?.default ?? bractCtx?.RouteComponent;
44
+ // While selective-SSR hydration is pending, render exactly what the server
45
+ // sent: the route's Fallback ("client-only"/"data-only" documents) or
46
+ // nothing (the "spa" shell knows no route at build time). Rendering the real
47
+ // component here would mismatch the server HTML.
48
+ const pending = routerCtx?.hydrationPending;
49
+ const RouteComponent: ComponentType | undefined = pending
50
+ ? (pending === "spa" ? undefined : routerCtx?.currentModule?.Fallback)
51
+ : routerCtx?.currentModule?.default ?? bractCtx?.RouteComponent;
46
52
  const ErrorFallback: ComponentType<{ error: Error }> =
47
53
  routerCtx?.currentModule?.ErrorBoundary ?? DefaultErrorFallback;
48
54
 
@@ -0,0 +1,125 @@
1
+ import { useEffect, useLayoutEffect, useRef } from "react";
2
+ import { useLocation } from "../hooks/useLocation.ts";
3
+ import type { RouterLocation } from "../../shared/route-types.ts";
4
+ import {
5
+ SCROLL_STORAGE_KEY,
6
+ savePosition,
7
+ serializePositions,
8
+ deserializePositions,
9
+ } from "../scroll-restoration.ts";
10
+
11
+ // ── Types ──────────────────────────────────────────────────────────────────
12
+
13
+ export interface ScrollRestorationProps {
14
+ /**
15
+ * Derive the storage key for a location. Defaults to `location.key` (one
16
+ * position per history entry). Return e.g. `location.pathname` to share one
17
+ * position across every visit to the same path.
18
+ */
19
+ getKey?: (location: RouterLocation) => string;
20
+ /** sessionStorage key holding the persisted positions. */
21
+ storageKey?: string;
22
+ }
23
+
24
+ // ── Component ──────────────────────────────────────────────────────────────
25
+
26
+ // useLayoutEffect warns during SSR; the component renders nothing and the
27
+ // effect only matters in the browser, so alias it away on the server.
28
+ const useBrowserLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
29
+
30
+ /**
31
+ * Emulates the browser's scroll restoration on soft navigations. Render it
32
+ * once in `app/root.tsx` (next to `<Scripts />`).
33
+ *
34
+ * Behavior: returning to a history entry (back/forward, reload) restores its
35
+ * saved scroll position; navigating to a new entry scrolls to the top, or to
36
+ * the `#fragment` element when the target has one. Positions are tracked from
37
+ * scroll events (so the leaving page's offset is never lost to layout
38
+ * clamping) and persisted to sessionStorage across reloads.
39
+ */
40
+ export function ScrollRestoration({ getKey, storageKey = SCROLL_STORAGE_KEY }: ScrollRestorationProps): null {
41
+ const location = useLocation();
42
+
43
+ const getKeyRef = useRef(getKey);
44
+ useEffect(() => { getKeyRef.current = getKey; });
45
+
46
+ // Lazily hydrated from sessionStorage on first access — the restore layout
47
+ // effect runs before mount effects, so eager hydration would come too late.
48
+ const positionsRef = useRef<Map<string, number> | null>(null);
49
+ const getPositions = (): Map<string, number> => {
50
+ if (positionsRef.current === null) {
51
+ positionsRef.current = deserializePositions(sessionStorage.getItem(storageKey));
52
+ }
53
+ return positionsRef.current;
54
+ };
55
+
56
+ /** Storage key of the entry currently on screen (what scroll events record). */
57
+ const activeKeyRef = useRef<string | null>(null);
58
+
59
+ // Take manual control + persist across reloads/full navigations.
60
+ useEffect(() => {
61
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
62
+ const persist = () => {
63
+ if (positionsRef.current !== null) {
64
+ try {
65
+ sessionStorage.setItem(storageKey, serializePositions(positionsRef.current));
66
+ } catch {
67
+ // Storage full/blocked — restoration degrades to in-memory only.
68
+ }
69
+ }
70
+ };
71
+ window.addEventListener("pagehide", persist);
72
+ return () => {
73
+ window.removeEventListener("pagehide", persist);
74
+ persist();
75
+ };
76
+ }, [storageKey]);
77
+
78
+ // Continuously record the active entry's position (rAF-throttled). Recording
79
+ // from scroll events — rather than snapshotting at navigation time — means
80
+ // the position is already saved before the new page's (possibly shorter)
81
+ // content clamps window.scrollY.
82
+ useEffect(() => {
83
+ let frame = 0;
84
+ const onScroll = () => {
85
+ if (frame) return;
86
+ frame = requestAnimationFrame(() => {
87
+ frame = 0;
88
+ if (activeKeyRef.current !== null) {
89
+ savePosition(getPositions(), activeKeyRef.current, window.scrollY);
90
+ }
91
+ });
92
+ };
93
+ window.addEventListener("scroll", onScroll, { passive: true });
94
+ return () => {
95
+ window.removeEventListener("scroll", onScroll);
96
+ if (frame) cancelAnimationFrame(frame);
97
+ };
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, []);
100
+
101
+ // Apply scroll on every committed location change (and on first mount, which
102
+ // restores the position after a reload).
103
+ useBrowserLayoutEffect(() => {
104
+ const key = getKeyRef.current ? getKeyRef.current(location) : location.key;
105
+ if (activeKeyRef.current === key) return;
106
+ activeKeyRef.current = key;
107
+
108
+ const saved = getPositions().get(key);
109
+ if (typeof saved === "number") {
110
+ window.scrollTo(0, saved);
111
+ return;
112
+ }
113
+ if (location.hash) {
114
+ const el = document.getElementById(location.hash.slice(1));
115
+ if (el) {
116
+ el.scrollIntoView();
117
+ return;
118
+ }
119
+ }
120
+ window.scrollTo(0, 0);
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, [location]);
123
+
124
+ return null;
125
+ }
@@ -20,6 +20,25 @@ function FallbackApp(): ReactElement {
20
20
  (async () => {
21
21
  const data: BractJSClientData = window.__BRACTJS_DATA__;
22
22
 
23
+ // Dev-only: surface a loader error captured during SSR in the error overlay.
24
+ // safeRun serializes failures as `{ __error: { message, stack, routeFile } }`
25
+ // into each loader slot; this is the overlay's producer (the overlay script
26
+ // installs a __BRACTJS_ERROR__ setter but nothing assigned it before).
27
+ if ((window as unknown as { __BRACT_DEV__?: boolean }).__BRACT_DEV__) {
28
+ const slots = [data.loaderData?.root, data.loaderData?.route, ...((data.loaderData?.layouts as unknown[]) ?? [])];
29
+ for (const slot of slots) {
30
+ const e = (slot as { __error?: { message?: string; stack?: string; routeFile?: string } } | null)?.__error;
31
+ if (e) {
32
+ const where = e.routeFile ? ` in ${e.routeFile}` : "";
33
+ (window as unknown as { __BRACTJS_ERROR__?: unknown }).__BRACTJS_ERROR__ = {
34
+ message: `Loader error${where}: ${e.message ?? "unknown error"}`,
35
+ stack: e.stack,
36
+ };
37
+ break;
38
+ }
39
+ }
40
+ }
41
+
23
42
  // 1. Import the root component (app/root.tsx) so the client tree matches
24
43
  // the server-rendered shell (html, head, body, header, nav, etc.).
25
44
  let RootComponent: ComponentType = FallbackApp;
@@ -28,9 +47,13 @@ function FallbackApp(): ReactElement {
28
47
  if (rootMod.default) RootComponent = rootMod.default;
29
48
  }
30
49
 
50
+ // The SPA shell is built once for "/" and served for every document path —
51
+ // the browser URL, not the payload, says where we actually are.
52
+ const initialPathname = data.ssrMode === "spa" ? window.location.pathname : data.pathname;
53
+
31
54
  // 2. Pre-load the current route module so <Outlet> sees it during hydration.
32
55
  let initialModule: RouteModuleClient | null = null;
33
- const pattern = matchPatternForPath(data.pathname, data.manifest);
56
+ const pattern = matchPatternForPath(initialPathname, data.manifest);
34
57
  const chunkUrl = pattern !== null ? data.manifest.routes[pattern]?.chunk : undefined;
35
58
 
36
59
  if (chunkUrl) {
@@ -40,9 +63,23 @@ function FallbackApp(): ReactElement {
40
63
  initialModule = (await import(url)) as RouteModuleClient;
41
64
  }
42
65
 
66
+ // Initial location: pathname comes from the server payload; search is
67
+ // identical to the request's by construction. The hash never reaches the
68
+ // server, so it is only known here.
69
+ const initialLocation = {
70
+ pathname: initialPathname,
71
+ search: window.location.search,
72
+ hash: window.location.hash,
73
+ state: null,
74
+ key: "default",
75
+ };
76
+
43
77
  hydrateRoot(
44
78
  document,
45
- <ClientRouter initialData={data} initialModule={initialModule}>
79
+ <ClientRouter
80
+ initialData={{ ...data, location: initialLocation, search: data.search ?? {} }}
81
+ initialModule={initialModule}
82
+ >
46
83
  <RootComponent />
47
84
  </ClientRouter>,
48
85
  );
@@ -0,0 +1,61 @@
1
+ // Module-level fetcher state, shaped for React's useSyncExternalStore. Giving
2
+ // fetchers identity outside component state is what makes optimistic UI work:
3
+ // a keyed fetcher survives remounts, and `useFetchers()` lets any component
4
+ // observe every in-flight mutation (e.g. dim a list row while its delete
5
+ // fetcher is submitting elsewhere in the tree).
6
+
7
+ export type FetcherState = "idle" | "loading" | "submitting";
8
+
9
+ export interface FetcherEntry {
10
+ key: string;
11
+ state: FetcherState;
12
+ data: unknown;
13
+ /** The submitted form data, available from the moment submit() is called — read this for optimistic UI. */
14
+ formData?: FormData;
15
+ /** Uppercase HTTP method of the in-flight/last submission. */
16
+ formMethod?: string;
17
+ }
18
+
19
+ type Listener = () => void;
20
+
21
+ const IDLE_ENTRY: Omit<FetcherEntry, "key"> = { state: "idle", data: undefined };
22
+
23
+ class FetcherStore {
24
+ private entries = new Map<string, FetcherEntry>();
25
+ private listeners = new Set<Listener>();
26
+ // Snapshots must be referentially stable between emits or
27
+ // useSyncExternalStore loops. Rebuilt only inside emit().
28
+ private snapshot: FetcherEntry[] = [];
29
+
30
+ get(key: string): FetcherEntry | undefined {
31
+ return this.entries.get(key);
32
+ }
33
+
34
+ update(key: string, partial: Partial<Omit<FetcherEntry, "key">>): void {
35
+ const prev = this.entries.get(key) ?? { key, ...IDLE_ENTRY };
36
+ this.entries.set(key, { ...prev, ...partial });
37
+ this.emit();
38
+ }
39
+
40
+ remove(key: string): void {
41
+ if (this.entries.delete(key)) this.emit();
42
+ }
43
+
44
+ subscribe = (listener: Listener): (() => void) => {
45
+ this.listeners.add(listener);
46
+ return () => this.listeners.delete(listener);
47
+ };
48
+
49
+ /** All current entries (stable reference between updates). */
50
+ getSnapshot = (): FetcherEntry[] => this.snapshot;
51
+
52
+ private emit(): void {
53
+ this.snapshot = Array.from(this.entries.values());
54
+ for (const listener of this.listeners) listener();
55
+ }
56
+ }
57
+
58
+ export const fetcherStore = new FetcherStore();
59
+
60
+ /** Stable server snapshot — SSR renders with no active fetchers. */
61
+ export const EMPTY_FETCHERS: FetcherEntry[] = [];
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Fetches fresh loader data for a pathname and updates the router context.
3
+ *
4
+ * @deprecated `<Form>` now revalidates through the router (see
5
+ * `useRevalidator`); kept only for callers that imported this directly.
3
6
  */
4
7
  export async function reloadLoaders(
5
8
  pathname: string,
@@ -1,14 +1,18 @@
1
1
  import { useContext } from "react";
2
2
  import { RouterContext } from "../router.tsx";
3
3
  import { BractJSContext } from "../../shared/context.ts";
4
+ import type { ActionData } from "../../shared/route-types.ts";
4
5
 
5
6
  /**
6
- * Returns the current route's action data, typed as T | null.
7
+ * Returns the current route's action data, or null until an action has run.
7
8
  * Works in both SSR and client contexts.
9
+ *
10
+ * Prefer passing the action function type — `useActionData<typeof action>()` —
11
+ * so the type is inferred from the action's return. An explicit type still works.
8
12
  */
9
- export function useActionData<T = unknown>(): T | null {
13
+ export function useActionData<T = unknown>(): ActionData<T> | null {
10
14
  const router = useContext(RouterContext);
11
15
  const bract = useContext(BractJSContext);
12
16
  const actionData = router?.actionData ?? bract?.actionData ?? null;
13
- return actionData as T | null;
17
+ return actionData as ActionData<T> | null;
14
18
  }