@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.
- package/README.md +283 -58
- package/bin/cli.ts +18 -1
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +64 -1
- 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/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__/integration.test.ts +56 -0
- package/src/__tests__/loader.test.ts +32 -1
- 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 +74 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- 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.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 +239 -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 +239 -47
- package/src/client/build-path.ts +24 -0
- 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 +105 -11
- 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/useNavigate.ts +51 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +21 -6
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +131 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +28 -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/route-codegen.ts +201 -29
- package/src/config/load.ts +21 -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 +44 -3
- package/src/server/action-handler.ts +12 -3
- package/src/server/action-registry.ts +35 -0
- package/src/server/csp.ts +10 -1
- package/src/server/csrf.ts +26 -0
- package/src/server/env.ts +26 -5
- package/src/server/layout.ts +31 -1
- package/src/server/loader.ts +14 -8
- package/src/server/render.ts +18 -3
- package/src/server/request-handler.ts +50 -8
- package/src/server/search.ts +43 -0
- package/src/server/serve.ts +88 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +85 -13
- package/src/shared/context.ts +5 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +83 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +21 -0
- package/types/index.d.ts +210 -10
- 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 {
|
|
32
|
-
const {
|
|
33
|
-
|
|
34
|
-
// setLoaderData shim — updates just the loaderData slice via setRoute
|
|
35
|
-
function setLoaderData(data: Record<string, unknown>) {
|
|
36
|
-
setRoute({ loaderData: data });
|
|
37
|
-
}
|
|
41
|
+
const { location, setRoute } = routerCtx;
|
|
42
|
+
const { submit } = navCtx;
|
|
38
43
|
|
|
39
44
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
40
45
|
e.preventDefault();
|
|
41
46
|
setRoute({ actionData: null }); // clear stale action data
|
|
42
47
|
|
|
43
48
|
const target = e.currentTarget;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const response = await fetch(url, {
|
|
48
|
-
method: method.toUpperCase(),
|
|
49
|
-
body: formData,
|
|
50
|
-
headers: { "X-BractJS-Action": "1" },
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// The action returned (or threw) a redirect. The browser auto-follows the
|
|
54
|
-
// 3xx, so `response.url` is the *absolute* final URL — normalize it to a
|
|
55
|
-
// same-origin path before handing it to the client router, which matches a
|
|
56
|
-
// route pattern against the pathname (an absolute URL wouldn't match). An
|
|
57
|
-
// off-origin final URL is NOT handed to the SPA router: fall back to a
|
|
58
|
-
// full-page navigation so we don't open-redirect through it.
|
|
59
|
-
if (response.redirected) {
|
|
60
|
-
const to = toSamePath(response.url);
|
|
61
|
-
if (to) { await navigate(to); return; }
|
|
62
|
-
window.location.href = response.url;
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
49
|
+
// Default to the full current URL (pathname + search) so actions can read
|
|
50
|
+
// the same search params their page was rendered with.
|
|
51
|
+
const url = action ?? location.pathname + location.search;
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
// The router's submit drives useNavigation() through "submitting" →
|
|
54
|
+
// "loading" → "idle", commits the action data, follows redirects safely
|
|
55
|
+
// (CSRF header + same-origin guard), and revalidates loaders.
|
|
56
|
+
await submit(url, { method, body: new FormData(target) });
|
|
69
57
|
}
|
|
70
58
|
|
|
71
59
|
return (
|
|
72
60
|
<form method={method} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
|
|
61
|
+
{intentInput}
|
|
73
62
|
{children}
|
|
74
63
|
</form>
|
|
75
64
|
);
|
|
@@ -1,16 +1,49 @@
|
|
|
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";
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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(
|
|
107
|
+
() => { void navCtx.navigate(href, { replace }); },
|
|
35
108
|
);
|
|
36
109
|
} else {
|
|
37
|
-
void navCtx.navigate(
|
|
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"
|
|
130
|
+
if (prefetch === "hover") triggerPrefetch();
|
|
131
|
+
else startIntent();
|
|
43
132
|
}
|
|
44
133
|
|
|
45
134
|
return (
|
|
46
135
|
<a
|
|
47
|
-
href={
|
|
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
|
-
|
|
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
|
}
|