@bractjs/bractjs 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/src/client/entry.tsx
CHANGED
|
@@ -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(
|
|
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
|
|
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[] = [];
|
package/src/client/form-utils.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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?:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
128
|
+
fetcherStore.update(key, { data: json.route });
|
|
92
129
|
} finally {
|
|
93
|
-
|
|
130
|
+
fetcherStore.update(key, { state: "idle" });
|
|
94
131
|
}
|
|
95
|
-
}
|
|
132
|
+
}, [key]);
|
|
96
133
|
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|