@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.
Files changed (117) hide show
  1. package/bin/cli.ts +18 -1
  2. package/package.json +3 -2
  3. package/src/__tests__/codegen-write.test.ts +67 -0
  4. package/src/__tests__/codegen.test.ts +29 -2
  5. package/src/__tests__/compile-safety.test.ts +4 -0
  6. package/src/__tests__/csp.test.ts +10 -0
  7. package/src/__tests__/define-actions.test.ts +69 -0
  8. package/src/__tests__/env.test.ts +18 -0
  9. package/src/__tests__/fetcher-store.test.ts +67 -0
  10. package/src/__tests__/fixtures/app/root.tsx +7 -2
  11. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  12. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  13. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  14. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -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__/headers.test.ts +111 -0
  21. package/src/__tests__/integration.test.ts +90 -0
  22. package/src/__tests__/layout-registry.test.ts +7 -3
  23. package/src/__tests__/loader.test.ts +32 -1
  24. package/src/__tests__/matcher.test.ts +29 -0
  25. package/src/__tests__/module-registry.test.ts +2 -3
  26. package/src/__tests__/nav-utils.test.ts +46 -0
  27. package/src/__tests__/prerender.test.ts +102 -0
  28. package/src/__tests__/programmatic-api.test.ts +20 -1
  29. package/src/__tests__/revalidation.test.ts +65 -0
  30. package/src/__tests__/route-lint.test.ts +79 -0
  31. package/src/__tests__/route-middleware.test.ts +84 -0
  32. package/src/__tests__/route-table.test.ts +33 -0
  33. package/src/__tests__/safe-validate.test.ts +96 -0
  34. package/src/__tests__/scanner.test.ts +46 -1
  35. package/src/__tests__/scroll-restoration.test.ts +66 -0
  36. package/src/__tests__/search-serializer.test.ts +42 -0
  37. package/src/__tests__/search-validation.test.ts +125 -0
  38. package/src/__tests__/security-fixes.test.ts +201 -0
  39. package/src/__tests__/security.test.ts +110 -1
  40. package/src/__tests__/selective-ssr.test.ts +85 -0
  41. package/src/__tests__/spa-mode.test.ts +77 -0
  42. package/src/__tests__/typed-routing.test.ts +51 -1
  43. package/src/__tests__/use-matches.test.ts +54 -0
  44. package/src/build/bundler.ts +33 -0
  45. package/src/build/prerender.ts +88 -0
  46. package/src/build/route-lint.ts +49 -0
  47. package/src/client/ClientRouter.tsx +339 -47
  48. package/src/client/cache.ts +8 -0
  49. package/src/client/components/Await.tsx +9 -2
  50. package/src/client/components/Form.tsx +23 -34
  51. package/src/client/components/Link.tsx +80 -9
  52. package/src/client/components/Outlet.tsx +8 -2
  53. package/src/client/components/ScrollRestoration.tsx +125 -0
  54. package/src/client/entry.tsx +39 -2
  55. package/src/client/fetcher-store.ts +61 -0
  56. package/src/client/form-utils.ts +3 -0
  57. package/src/client/hooks/useActionData.ts +7 -3
  58. package/src/client/hooks/useFetcher.ts +116 -33
  59. package/src/client/hooks/useFetchers.ts +23 -0
  60. package/src/client/hooks/useLoaderData.ts +8 -4
  61. package/src/client/hooks/useLocation.ts +27 -0
  62. package/src/client/hooks/useMatches.ts +32 -0
  63. package/src/client/hooks/useNavigate.ts +11 -6
  64. package/src/client/hooks/useRevalidator.ts +26 -0
  65. package/src/client/hooks/useSearch.ts +73 -0
  66. package/src/client/hooks/useSearchParams.ts +7 -2
  67. package/src/client/nav-utils.ts +26 -0
  68. package/src/client/prefetch.ts +110 -15
  69. package/src/client/registry.ts +24 -0
  70. package/src/client/revalidation.ts +25 -0
  71. package/src/client/router.tsx +34 -1
  72. package/src/client/rpc.ts +11 -1
  73. package/src/client/scroll-restoration.ts +48 -0
  74. package/src/client/search-serializer.ts +40 -0
  75. package/src/client/types.ts +6 -0
  76. package/src/codegen/module-registry.ts +13 -21
  77. package/src/codegen/route-codegen.ts +148 -10
  78. package/src/config/load.ts +22 -0
  79. package/src/dev/hmr-client.ts +3 -1
  80. package/src/dev/route-table.ts +27 -0
  81. package/src/dev/server.ts +106 -8
  82. package/src/dev/watcher.ts +25 -3
  83. package/src/index.ts +38 -6
  84. package/src/server/action-handler.ts +3 -13
  85. package/src/server/action-registry.ts +35 -0
  86. package/src/server/adapter.ts +16 -0
  87. package/src/server/api-route.ts +47 -0
  88. package/src/server/csp.ts +19 -4
  89. package/src/server/csrf.ts +36 -3
  90. package/src/server/env.ts +26 -5
  91. package/src/server/headers.ts +49 -0
  92. package/src/server/layout.ts +43 -20
  93. package/src/server/loader.ts +14 -8
  94. package/src/server/matcher.ts +29 -2
  95. package/src/server/matches.ts +50 -0
  96. package/src/server/middleware.ts +66 -0
  97. package/src/server/proto-guard.ts +56 -0
  98. package/src/server/render.ts +51 -18
  99. package/src/server/request-handler.ts +111 -29
  100. package/src/server/scanner.ts +45 -3
  101. package/src/server/search.ts +47 -0
  102. package/src/server/serve.ts +116 -4
  103. package/src/server/session.ts +12 -1
  104. package/src/server/spa.ts +62 -0
  105. package/src/server/stream-handler.ts +10 -1
  106. package/src/server/validate.ts +89 -14
  107. package/src/shared/context.ts +7 -0
  108. package/src/shared/define-actions.ts +39 -0
  109. package/src/shared/form-data.ts +34 -0
  110. package/src/shared/route-types.ts +191 -2
  111. package/templates/new-app/app/root.tsx +2 -1
  112. package/templates/new-app/bractjs.config.ts +7 -12
  113. package/types/config.d.ts +24 -0
  114. package/types/index.d.ts +182 -9
  115. package/types/route.d.ts +138 -3
  116. package/LICENSE +0 -21
  117. package/README.md +0 -1125
@@ -1,11 +1,28 @@
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";
4
7
  import { buildPath } from "../build-path.ts";
5
- import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
8
+ import { withSearch } from "../search-serializer.ts";
9
+ import type { RegisteredRoutes, ParamsFor, SearchOutputFor } from "../registry.ts";
6
10
 
7
11
  // ── Types ──────────────────────────────────────────────────────────────────
8
12
 
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
+
9
26
  // `to` accepts any registered route literal (autocomplete + typed `params`) but
10
27
  // also any string via `(string & {})`, so existing call sites that build the URL
11
28
  // themselves — `to={`/posts/${slug}`}`, `to={item.href}` — keep compiling. Run
@@ -18,9 +35,13 @@ type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = Omit<
18
35
  to: TTo | (string & {});
19
36
  /** Path params for a dynamic `to` (e.g. `params={{ id }}` for `/blog/:id`). */
20
37
  params?: ParamsFor<TTo>;
21
- prefetch?: "hover" | "none";
38
+ /** Search params for the target, typed by its `searchSchema` (replaces any query in `to`). */
39
+ search?: Partial<SearchOutputFor<TTo>>;
40
+ prefetch?: PrefetchMode;
22
41
  /** Opt in to View Transitions API for this navigation (E1). */
23
42
  viewTransition?: boolean;
43
+ /** Replace the current history entry instead of pushing. */
44
+ replace?: boolean;
24
45
  children: ReactNode;
25
46
  };
26
47
 
@@ -31,11 +52,16 @@ const supportsViewTransitions =
31
52
  typeof document !== "undefined" &&
32
53
  typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
33
54
 
55
+ /** Hover-intent delay before prefetching — cancels on a fly-by pointer. */
56
+ const INTENT_DELAY_MS = 100;
57
+
34
58
  export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
35
59
  to,
36
60
  params,
61
+ search,
37
62
  prefetch = "none",
38
63
  viewTransition = false,
64
+ replace,
39
65
  children,
40
66
  ...rest
41
67
  }: LinkProps<TTo>) {
@@ -44,8 +70,32 @@ export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
44
70
  const isLoading = navCtx?.state === "loading";
45
71
 
46
72
  // Resolve the final href once: substitute params into a dynamic pattern, or
47
- // pass an already-built string straight through.
48
- const href = params ? buildPath(to as string, params as Record<string, string>) : (to as string);
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
+ }, []);
49
99
 
50
100
  function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
51
101
  if (!navCtx) return; // SSR: let browser handle naturally
@@ -54,22 +104,43 @@ export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
54
104
 
55
105
  if (viewTransition && supportsViewTransitions) {
56
106
  (document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
57
- () => { void navCtx.navigate(href); },
107
+ () => { void navCtx.navigate(href, { replace }); },
58
108
  );
59
109
  } else {
60
- void navCtx.navigate(href);
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;
61
126
  }
62
127
  }
63
128
 
64
129
  function handleMouseEnter() {
65
- if (prefetch === "hover" && routerCtx) prefetchRoute(href, routerCtx.manifest);
130
+ if (prefetch === "hover") triggerPrefetch();
131
+ else startIntent();
66
132
  }
67
133
 
68
134
  return (
69
135
  <a
70
136
  href={href}
137
+ ref={anchorRef}
71
138
  onClick={handleClick}
72
139
  onMouseEnter={handleMouseEnter}
140
+ onMouseLeave={cancelIntent}
141
+ onFocus={startIntent}
142
+ onBlur={cancelIntent}
143
+ onTouchStart={prefetch === "intent" || prefetch === "hover" ? triggerPrefetch : undefined}
73
144
  aria-disabled={isLoading || undefined}
74
145
  {...rest}
75
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
  }
@@ -1,27 +1,57 @@
1
- import { useState } from "react";
1
+ import {
2
+ createElement, useCallback, useEffect, useId, useMemo, useSyncExternalStore,
3
+ type FormEvent, type FormHTMLAttributes, type FunctionComponent, type ReactNode,
4
+ } from "react";
2
5
  import { toSamePath } from "../nav-utils.ts";
6
+ import { fetcherStore, type FetcherState } from "../fetcher-store.ts";
7
+ import { triggerRevalidation } from "../revalidation.ts";
3
8
 
4
9
  // ── Types ──────────────────────────────────────────────────────────────────
5
10
 
6
- type FetcherState = "idle" | "loading" | "submitting";
7
-
8
11
  interface SubmitOptions {
9
12
  method: string;
10
13
  body: FormData | Record<string, string>;
11
14
  }
12
15
 
13
- interface FetcherResult {
16
+ export interface FetcherFormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, "method" | "onSubmit"> {
17
+ method?: "post" | "put" | "delete";
18
+ action?: string;
19
+ /** Renders a hidden `intent` input (pairs with `defineActions()`). */
20
+ intent?: string;
21
+ children: ReactNode;
22
+ }
23
+
24
+ export interface FetcherResult {
14
25
  data: unknown;
15
26
  state: FetcherState;
27
+ /** The submitted FormData while a submission is in flight — the optimistic-UI source. */
28
+ formData?: FormData;
29
+ /** Uppercase method of the in-flight/last submission. */
30
+ formMethod?: string;
31
+ /** This fetcher's identity (explicit `key` option, or component-bound). */
32
+ key: string;
16
33
  load(path: string): Promise<void>;
17
34
  submit(path: string, opts: SubmitOptions): Promise<void>;
35
+ /** A `<fetcher.Form>` that submits through this fetcher (no navigation, no history). */
36
+ Form: FunctionComponent<FetcherFormProps>;
18
37
  }
19
38
 
20
39
  interface StreamFetcherResult<T = unknown> {
40
+ /** @deprecated Never emitted — call `connect(actionId)` instead. Removed in 0.2. */
21
41
  events: AsyncGenerator<T>;
22
42
  connect(actionId: string): AsyncGenerator<T>;
23
43
  }
24
44
 
45
+ export interface UseFetcherOptions {
46
+ /**
47
+ * Give the fetcher a stable identity. Keyed fetchers persist across
48
+ * unmounts and are shared by every component using the same key; unkeyed
49
+ * fetchers are removed from `useFetchers()` when their component unmounts.
50
+ */
51
+ key?: string;
52
+ stream?: boolean;
53
+ }
54
+
25
55
  // ── SSE async generator ────────────────────────────────────────────────────
26
56
 
27
57
  async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
@@ -66,46 +96,60 @@ async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
66
96
 
67
97
  // ── Hook ───────────────────────────────────────────────────────────────────
68
98
 
69
- export function useFetcher(): FetcherResult;
99
+ const EMPTY_ENTRY = undefined;
100
+
101
+ export function useFetcher(opts?: { key?: string }): FetcherResult;
70
102
  export function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
71
- export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | StreamFetcherResult<T> {
72
- if (opts?.stream) {
73
- return {
74
- events: (null as unknown) as AsyncGenerator<T>,
75
- connect(actionId: string): AsyncGenerator<T> {
76
- return sseStream<T>(actionId);
77
- },
78
- } satisfies StreamFetcherResult<T>;
79
- }
103
+ export function useFetcher<T = unknown>(opts?: UseFetcherOptions): FetcherResult | StreamFetcherResult<T> {
104
+ // All hooks run unconditionally — branching on opts.stream happens only in
105
+ // the returned value, so the rules of hooks hold for every variant.
106
+ const autoKey = useId();
107
+ const key = opts?.key ?? `__fetcher${autoKey}`;
108
+
109
+ const entry = useSyncExternalStore(
110
+ fetcherStore.subscribe,
111
+ () => fetcherStore.get(key),
112
+ () => EMPTY_ENTRY,
113
+ );
80
114
 
81
- // eslint-disable-next-line react-hooks/rules-of-hooks
82
- const [data, setData] = useState<unknown>(undefined);
83
- // eslint-disable-next-line react-hooks/rules-of-hooks
84
- const [state, setState] = useState<FetcherState>("idle");
115
+ // Unkeyed fetchers disappear from useFetchers() with their component; keyed
116
+ // ones persist so optimistic state survives remounts.
117
+ const isKeyed = opts?.key !== undefined;
118
+ useEffect(() => {
119
+ if (isKeyed) return;
120
+ return () => fetcherStore.remove(key);
121
+ }, [key, isKeyed]);
85
122
 
86
- async function load(path: string): Promise<void> {
87
- setState("loading");
123
+ const load = useCallback(async (path: string): Promise<void> => {
124
+ fetcherStore.update(key, { state: "loading" });
88
125
  try {
89
126
  const res = await fetch(`/_data?path=${encodeURIComponent(path)}`);
90
127
  const json = (await res.json()) as { route?: unknown };
91
- setData(json.route);
128
+ fetcherStore.update(key, { data: json.route });
92
129
  } finally {
93
- setState("idle");
130
+ fetcherStore.update(key, { state: "idle" });
94
131
  }
95
- }
132
+ }, [key]);
96
133
 
97
- async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
98
- setState("submitting");
134
+ const submit = useCallback(async (path: string, submitOpts: SubmitOptions): Promise<void> => {
135
+ const body =
136
+ submitOpts.body instanceof FormData
137
+ ? submitOpts.body
138
+ : new URLSearchParams(submitOpts.body as Record<string, string>);
139
+ const formMethod = submitOpts.method.toUpperCase();
140
+ // Expose the submission BEFORE the fetch — this is what optimistic UI
141
+ // renders while the mutation is in flight.
142
+ fetcherStore.update(key, {
143
+ state: "submitting",
144
+ formData: submitOpts.body instanceof FormData ? submitOpts.body : undefined,
145
+ formMethod,
146
+ });
99
147
  try {
100
- const body =
101
- submitOpts.body instanceof FormData
102
- ? submitOpts.body
103
- : new URLSearchParams(submitOpts.body as Record<string, string>);
104
148
  // Send the custom header so the server's CSRF gate accepts this
105
149
  // same-origin mutation (browsers block it cross-origin without a CORS
106
150
  // preflight). Without it every fetcher submit 403s.
107
151
  const res = await fetch(path, {
108
- method: submitOpts.method,
152
+ method: formMethod,
109
153
  body,
110
154
  headers: { "X-BractJS-Action": "1" },
111
155
  });
@@ -117,11 +161,50 @@ export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | Stre
117
161
  window.location.assign(to ?? res.url);
118
162
  return;
119
163
  }
120
- setData(await res.json());
164
+ fetcherStore.update(key, { data: await res.json() });
165
+ // Mutations invalidate loader data — re-run the active route's loaders
166
+ // (gated by its shouldRevalidate) so the page reflects the change.
167
+ fetcherStore.update(key, { state: "loading" });
168
+ await triggerRevalidation({ formMethod, actionStatus: res.status });
121
169
  } finally {
122
- setState("idle");
170
+ fetcherStore.update(key, { state: "idle", formData: undefined });
123
171
  }
172
+ }, [key]);
173
+
174
+ // Stable component identity across renders (remounting a form on every
175
+ // render would drop focus/IME state).
176
+ const FetcherForm = useMemo<FunctionComponent<FetcherFormProps>>(() => {
177
+ return function FetcherFormImpl({ method = "post", action, intent, children, ...rest }: FetcherFormProps) {
178
+ function handleSubmit(e: FormEvent<HTMLFormElement>) {
179
+ e.preventDefault();
180
+ const target = e.currentTarget;
181
+ const url = action ?? window.location.pathname + window.location.search;
182
+ void submit(url, { method, body: new FormData(target) });
183
+ }
184
+ const intentInput = intent !== undefined
185
+ ? createElement("input", { key: "__bract_intent", type: "hidden", name: "intent", value: intent })
186
+ : null;
187
+ return createElement("form", { method, onSubmit: handleSubmit, ...rest }, intentInput, children);
188
+ };
189
+ }, [submit]);
190
+
191
+ if (opts?.stream) {
192
+ return {
193
+ events: (null as unknown) as AsyncGenerator<T>,
194
+ connect(actionId: string): AsyncGenerator<T> {
195
+ return sseStream<T>(actionId);
196
+ },
197
+ } satisfies StreamFetcherResult<T>;
124
198
  }
125
199
 
126
- return { data, state, load, submit };
200
+ return {
201
+ data: entry?.data,
202
+ state: entry?.state ?? "idle",
203
+ formData: entry?.formData,
204
+ formMethod: entry?.formMethod,
205
+ key,
206
+ load,
207
+ submit,
208
+ Form: FetcherForm,
209
+ };
127
210
  }
@@ -0,0 +1,23 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { fetcherStore, EMPTY_FETCHERS, type FetcherEntry } from "../fetcher-store.ts";
3
+
4
+ /**
5
+ * Every active fetcher (keyed and mounted-unkeyed alike) — the cross-component
6
+ * view for optimistic UI. Example: a table dims each row whose keyed delete
7
+ * fetcher (`useFetcher({ key: "delete-" + id })`) is currently submitting:
8
+ *
9
+ * const deleting = new Set(
10
+ * useFetchers()
11
+ * .filter((f) => f.state === "submitting" && f.key.startsWith("delete-"))
12
+ * .map((f) => f.key.slice("delete-".length)),
13
+ * );
14
+ *
15
+ * SSR-safe: renders an empty list on the server.
16
+ */
17
+ export function useFetchers(): FetcherEntry[] {
18
+ return useSyncExternalStore(
19
+ fetcherStore.subscribe,
20
+ fetcherStore.getSnapshot,
21
+ () => EMPTY_FETCHERS,
22
+ );
23
+ }