@bractjs/bractjs 0.1.29 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bractjs/bractjs",
3
- "version": "0.1.29",
3
+ "version": "0.2.1",
4
4
  "description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/bractjs/bractjs#readme",
@@ -0,0 +1,13 @@
1
+ // Fixture for the "/_data must surface a redirect THROWN from a loader" test.
2
+ // Auth gates like requireAdmin() do `throw redirect("/login")` inside a loader.
3
+ // On the /_data soft-nav path that thrown redirect must come back as a real 3xx
4
+ // (so the client follows it), not escape to the top-level handler as a 500.
5
+ import { redirect } from "../../../../server/response.ts";
6
+
7
+ export function loader(): never {
8
+ throw redirect("/login");
9
+ }
10
+
11
+ export default function RedirectLoaderPage() {
12
+ return <p>redirect loader page</p>;
13
+ }
@@ -183,6 +183,24 @@ test("/_data of a beforeLoad-gated route is blocked and never leaks loader data"
183
183
  expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
184
184
  });
185
185
 
186
+ // Regression: a redirect THROWN from a loader (the requireAdmin/auth-gate
187
+ // pattern) must come back from /_data as a real 3xx, not a 500. The /_data
188
+ // handler wraps loaders in `return await runRouteMiddleware(...)`; a bare
189
+ // `return` would let the rejection escape its try/catch (which handles
190
+ // isRedirect) to the top-level handler, which logs "unhandled request error"
191
+ // and returns 500 — breaking soft-nav redirects for gated routes.
192
+ test("full-page GET of a loader that throws redirect returns the 3xx", async () => {
193
+ const res = await fetch(`${BASE}/redirect-loader`, { redirect: "manual" });
194
+ expect(res.status).toBe(302);
195
+ expect(res.headers.get("location")).toBe("/login");
196
+ });
197
+
198
+ test("/_data of a loader that throws redirect returns the 3xx (not 500)", async () => {
199
+ const res = await fetch(`${BASE}/_data?path=/redirect-loader`, { redirect: "manual" });
200
+ expect(res.status).toBe(302);
201
+ expect(res.headers.get("location")).toBe("/login");
202
+ });
203
+
186
204
  // ── Route headers / useMatches / nested middleware (Phases 1, 2, 4) ──────────
187
205
 
188
206
  test("route `headers` export sets Cache-Control on the document response", async () => {
@@ -56,8 +56,12 @@ export function Form({ method = "post", action, intent, children, ...rest }: For
56
56
  await submit(url, { method, body: new FormData(target) });
57
57
  }
58
58
 
59
+ // Render `action` here too (SSR sets it on line 34): handleSubmit preventDefaults
60
+ // so it's never used for a native submit, but keeping it on the element makes
61
+ // the client markup match the server's and avoids a hydration mismatch for any
62
+ // <Form action="…"> (e.g. a logout form posting to a different route).
59
63
  return (
60
- <form method={method} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
64
+ <form method={method} action={action} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
61
65
  {intentInput}
62
66
  {children}
63
67
  </form>
@@ -69,8 +69,10 @@ export function Image({
69
69
  height={height}
70
70
  loading={priority ? "eager" : "lazy"}
71
71
  decoding={priority ? "sync" : "async"}
72
- // @ts-expect-error fetchpriority is a valid HTML attribute in React 19
73
- fetchpriority={priority ? "high" : "auto"}
72
+ // React 19 prop is camelCase `fetchPriority`; React emits the lowercase
73
+ // `fetchpriority` HTML attribute. Using the lowercase prop here triggers
74
+ // "Invalid DOM property `fetchpriority`" at hydration.
75
+ fetchPriority={priority ? "high" : "auto"}
74
76
  sizes={sizes}
75
77
  className={className}
76
78
  style={style}
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { useToasts } from "../hooks/useToast.ts";
3
+ import { toast } from "../toast-store.ts";
4
+ import type { ToastEntry, ToastType } from "../toast-store.ts";
5
+
6
+ export type ToastPosition =
7
+ | "top-left" | "top-center" | "top-right"
8
+ | "bottom-left" | "bottom-center" | "bottom-right";
9
+
10
+ export interface ToasterProps {
11
+ position?: ToastPosition;
12
+ /** Gap between stacked toasts, px. */
13
+ gap?: number;
14
+ /** Custom renderer — receives the entry and a dismiss callback. Falls back to the default card. */
15
+ renderToast?: (toast: ToastEntry, dismiss: () => void) => ReactNode;
16
+ }
17
+
18
+ const ACCENT: Record<ToastType, string> = {
19
+ success: "#16a34a", error: "#dc2626", warning: "#d97706", info: "#2563eb", loading: "#6b7280",
20
+ };
21
+ const ICON: Record<ToastType, string> = {
22
+ success: "✓", error: "✕", warning: "!", info: "i", loading: "↻",
23
+ };
24
+
25
+ function isTop(p: ToastPosition) { return p.startsWith("top"); }
26
+
27
+ function containerStyle(position: ToastPosition, gap: number): CSSProperties {
28
+ const [, x] = position.split("-");
29
+ return {
30
+ position: "fixed", zIndex: 9999, display: "flex", flexDirection: "column", gap,
31
+ pointerEvents: "none", maxWidth: "calc(100vw - 32px)", width: 380,
32
+ top: isTop(position) ? 16 : undefined, bottom: isTop(position) ? undefined : 16,
33
+ left: x === "left" ? 16 : x === "center" ? "50%" : undefined,
34
+ right: x === "right" ? 16 : undefined,
35
+ transform: x === "center" ? "translateX(-50%)" : undefined,
36
+ };
37
+ }
38
+
39
+ function ToastCard({ entry, top }: { entry: ToastEntry; top: boolean }) {
40
+ const [shown, setShown] = useState(false);
41
+ useEffect(() => { const r = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(r); }, []);
42
+ const dismiss = () => toast.dismiss(entry.id);
43
+ return (
44
+ <div
45
+ role={entry.type === "error" ? "alert" : "status"}
46
+ aria-live={entry.type === "error" ? "assertive" : "polite"}
47
+ data-bract-toast={entry.type}
48
+ style={{
49
+ pointerEvents: "auto", display: "flex", alignItems: "flex-start", gap: 12,
50
+ padding: "12px 14px", borderRadius: 10, background: "#fff", color: "#111",
51
+ border: "1px solid rgba(0,0,0,0.08)", borderLeft: `4px solid ${ACCENT[entry.type]}`,
52
+ boxShadow: "0 6px 24px rgba(0,0,0,0.12)", fontSize: 14, lineHeight: 1.4,
53
+ transition: "opacity .22s ease, transform .22s ease",
54
+ opacity: shown ? 1 : 0,
55
+ transform: shown ? "translateY(0)" : `translateY(${top ? -8 : 8}px)`,
56
+ }}
57
+ >
58
+ <span
59
+ aria-hidden
60
+ style={{
61
+ flex: "0 0 auto", width: 20, height: 20, borderRadius: "50%", color: "#fff",
62
+ background: ACCENT[entry.type], display: "grid", placeItems: "center",
63
+ fontSize: 12, fontWeight: 700, marginTop: 1,
64
+ }}
65
+ >
66
+ {ICON[entry.type]}
67
+ </span>
68
+ <div style={{ flex: 1, minWidth: 0 }}>
69
+ <div style={{ fontWeight: 600, wordBreak: "break-word" }}>{entry.message}</div>
70
+ {entry.description ? (
71
+ <div style={{ marginTop: 2, color: "#555", fontWeight: 400 }}>{entry.description}</div>
72
+ ) : null}
73
+ {entry.action ? (
74
+ <button
75
+ type="button"
76
+ onClick={() => { entry.action!.onClick(); dismiss(); }}
77
+ style={{
78
+ marginTop: 8, padding: "4px 10px", fontSize: 13, fontWeight: 600, cursor: "pointer",
79
+ color: ACCENT[entry.type], background: "transparent",
80
+ border: `1px solid ${ACCENT[entry.type]}`, borderRadius: 6,
81
+ }}
82
+ >
83
+ {entry.action.label}
84
+ </button>
85
+ ) : null}
86
+ </div>
87
+ <button
88
+ type="button" aria-label="Dismiss" onClick={dismiss}
89
+ style={{
90
+ flex: "0 0 auto", border: "none", background: "transparent", cursor: "pointer",
91
+ color: "#888", fontSize: 16, lineHeight: 1, padding: 0,
92
+ }}
93
+ >
94
+ ×
95
+ </button>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Renders the active toast queue. Mount once in root.tsx, then call `toast.*`
102
+ * (or `useToast()`) anywhere — e.g. after a save/delete action resolves.
103
+ */
104
+ export function Toaster({ position = "top-right", gap = 10, renderToast }: ToasterProps): ReactNode {
105
+ const toasts = useToasts();
106
+ if (toasts.length === 0) return null;
107
+ const ordered = isTop(position) ? toasts : [...toasts].reverse();
108
+ return (
109
+ <div data-bract-toaster={position} style={containerStyle(position, gap)}>
110
+ {ordered.map((entry) =>
111
+ renderToast
112
+ ? <div key={entry.id} style={{ pointerEvents: "auto" }}>{renderToast(entry, () => toast.dismiss(entry.id))}</div>
113
+ : <ToastCard key={entry.id} entry={entry} top={isTop(position)} />,
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,12 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { toast, toastStore, EMPTY_TOASTS, type Toast, type ToastEntry } from "../toast-store.ts";
3
+
4
+ /** The stable `toast` API — `toast.success(...)`, `toast.error(...)`, `toast.promise(...)`. */
5
+ export function useToast(): Toast {
6
+ return toast;
7
+ }
8
+
9
+ /** Reactive list of active toasts. <Toaster> uses this; expose it to build a custom renderer. */
10
+ export function useToasts(): ToastEntry[] {
11
+ return useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, () => EMPTY_TOASTS);
12
+ }
@@ -0,0 +1,132 @@
1
+ // Module-level toast state, shaped for React's useSyncExternalStore (mirrors
2
+ // fetcher-store.ts). Living outside component state means `toast()` is callable
3
+ // from anywhere — event handlers, fetcher callbacks, non-React code — and every
4
+ // <Toaster> in the tree observes the same queue.
5
+
6
+ export type ToastType = "success" | "error" | "info" | "warning" | "loading";
7
+
8
+ export interface ToastAction {
9
+ label: string;
10
+ onClick: () => void;
11
+ }
12
+
13
+ export interface ToastEntry {
14
+ id: string;
15
+ type: ToastType;
16
+ message: string;
17
+ description?: string;
18
+ /** ms before auto-dismiss; `Infinity`/`0` keeps it until dismissed. */
19
+ duration: number;
20
+ action?: ToastAction;
21
+ createdAt: number;
22
+ }
23
+
24
+ export interface ToastOptions {
25
+ /** Reuse an id to update an existing toast in place (e.g. loading → success). */
26
+ id?: string;
27
+ type?: ToastType;
28
+ description?: string;
29
+ duration?: number;
30
+ action?: ToastAction;
31
+ }
32
+
33
+ type Listener = () => void;
34
+ const DEFAULT_DURATION = 4000;
35
+
36
+ class ToastStore {
37
+ private entries = new Map<string, ToastEntry>();
38
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
39
+ private listeners = new Set<Listener>();
40
+ // Stable reference between emits or useSyncExternalStore loops.
41
+ private snapshot: ToastEntry[] = [];
42
+ private seq = 0;
43
+
44
+ add(message: string, opts: ToastOptions = {}): string {
45
+ const id = opts.id ?? `bract-toast-${++this.seq}`;
46
+ const type = opts.type ?? "info";
47
+ const duration = opts.duration ?? (type === "loading" ? Infinity : DEFAULT_DURATION);
48
+ this.clearTimer(id);
49
+ const prev = this.entries.get(id);
50
+ this.entries.set(id, {
51
+ id, type, message, description: opts.description, duration,
52
+ action: opts.action, createdAt: prev?.createdAt ?? Date.now(),
53
+ });
54
+ this.schedule(id, duration);
55
+ this.emit();
56
+ return id;
57
+ }
58
+
59
+ dismiss(id: string): void {
60
+ this.clearTimer(id);
61
+ if (this.entries.delete(id)) this.emit();
62
+ }
63
+
64
+ clear(): void {
65
+ for (const id of [...this.timers.keys()]) this.clearTimer(id);
66
+ if (this.entries.size) { this.entries.clear(); this.emit(); }
67
+ }
68
+
69
+ private schedule(id: string, duration: number): void {
70
+ if (!Number.isFinite(duration) || duration <= 0) return;
71
+ this.timers.set(id, setTimeout(() => this.dismiss(id), duration));
72
+ }
73
+
74
+ private clearTimer(id: string): void {
75
+ const t = this.timers.get(id);
76
+ if (t !== undefined) { clearTimeout(t); this.timers.delete(id); }
77
+ }
78
+
79
+ subscribe = (listener: Listener): (() => void) => {
80
+ this.listeners.add(listener);
81
+ return () => { this.listeners.delete(listener); };
82
+ };
83
+
84
+ getSnapshot = (): ToastEntry[] => this.snapshot;
85
+
86
+ private emit(): void {
87
+ this.snapshot = Array.from(this.entries.values());
88
+ for (const listener of this.listeners) listener();
89
+ }
90
+ }
91
+
92
+ export const toastStore = new ToastStore();
93
+
94
+ /** Stable server snapshot — SSR renders with no toasts. */
95
+ export const EMPTY_TOASTS: ToastEntry[] = [];
96
+
97
+ type Msg<T> = string | ((value: T) => string);
98
+ const resolve = <T,>(m: Msg<T>, v: T): string => (typeof m === "function" ? m(v) : m);
99
+ const typed = (type: ToastType) =>
100
+ (message: string, opts?: Omit<ToastOptions, "type">): string => toastStore.add(message, { ...opts, type });
101
+
102
+ /**
103
+ * Fire a toast from anywhere. Use the typed helpers for status feedback:
104
+ * toast.success("Saved"); toast.error("Delete failed");
105
+ * `toast.promise` shows loading → success/error around an async action.
106
+ */
107
+ export const toast = Object.assign(
108
+ (message: string, opts?: ToastOptions): string => toastStore.add(message, opts),
109
+ {
110
+ success: typed("success"),
111
+ error: typed("error"),
112
+ info: typed("info"),
113
+ warning: typed("warning"),
114
+ loading: typed("loading"),
115
+ /** Dismiss one toast by id, or all toasts when called with no id. */
116
+ dismiss: (id?: string): void => (id ? toastStore.dismiss(id) : toastStore.clear()),
117
+ promise<T>(
118
+ promise: Promise<T>,
119
+ msgs: { loading: string; success: Msg<T>; error: Msg<unknown> },
120
+ opts?: Omit<ToastOptions, "type" | "id">,
121
+ ): Promise<T> {
122
+ const id = toastStore.add(msgs.loading, { ...opts, type: "loading" });
123
+ promise.then(
124
+ (value) => toastStore.add(resolve(msgs.success, value), { ...opts, id, type: "success" }),
125
+ (err) => toastStore.add(resolve(msgs.error, err), { ...opts, id, type: "error" }),
126
+ );
127
+ return promise;
128
+ },
129
+ },
130
+ );
131
+
132
+ export type Toast = typeof toast;
package/src/index.ts CHANGED
@@ -119,6 +119,8 @@ export { Image } from "./client/components/Image.tsx";
119
119
  export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
120
120
  export { ScrollRestoration } from "./client/components/ScrollRestoration.tsx";
121
121
  export type { ScrollRestorationProps } from "./client/components/ScrollRestoration.tsx";
122
+ export { Toaster } from "./client/components/Toaster.tsx";
123
+ export type { ToasterProps, ToastPosition } from "./client/components/Toaster.tsx";
122
124
 
123
125
  // Client hooks
124
126
  export { useLoaderData } from "./client/hooks/useLoaderData.ts";
@@ -140,6 +142,9 @@ export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
140
142
  export { useSearch, useSetSearch } from "./client/hooks/useSearch.ts";
141
143
  export type { SetSearchFn, SetSearchOptions } from "./client/hooks/useSearch.ts";
142
144
  export { serializeSearch } from "./client/search-serializer.ts";
145
+ export { useToast, useToasts } from "./client/hooks/useToast.ts";
146
+ export { toast, toastStore } from "./client/toast-store.ts";
147
+ export type { Toast, ToastEntry, ToastOptions, ToastType, ToastAction } from "./client/toast-store.ts";
143
148
  export { useBlocker } from "./client/hooks/useBlocker.ts";
144
149
  export { useLocale } from "./client/hooks/useLocale.ts";
145
150
  export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
@@ -1,3 +1,5 @@
1
+ import { isExplicitDev } from "./env.ts";
2
+
1
3
  // ── BractAdapter ──────────────────────────────────────────────────────────
2
4
 
3
5
  /**
@@ -59,7 +61,11 @@ export class BunAdapter implements BractAdapter {
59
61
  fetch: handler,
60
62
  error(err: Error) {
61
63
  console.error("[bractjs] unhandled server error:", err);
62
- return new Response(JSON.stringify({ error: err.message }), {
64
+ // SECURITY(high): never leak internal error details in production. This
65
+ // is a last-resort backstop (buildFetchHandler already catches request
66
+ // errors) — mirror the isExplicitDev() gating used on every other path.
67
+ const message = isExplicitDev() ? err.message : "Internal Server Error";
68
+ return new Response(JSON.stringify({ error: message }), {
63
69
  status: 500,
64
70
  headers: { "Content-Type": "application/json; charset=utf-8" },
65
71
  });
@@ -101,7 +101,12 @@ async function route(
101
101
  // and leak loader data. Run the route middleware chain around the work,
102
102
  // sharing the same mutable `context` so a gate can set/clear fields.
103
103
  const mwCtx: MiddlewareContext = { request: loaderRequest, params: match.params, context };
104
- return runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
104
+ // `return await` (not bare `return`): a loader/gate inside the middleware
105
+ // work can throw a redirect (e.g. requireAdmin). Without awaiting here the
106
+ // returned promise rejects *after* this try block, so the catch below never
107
+ // runs isRedirect() and the redirect escapes to the top-level handler as a
108
+ // 500 instead of being returned as a 302 for the soft-nav client.
109
+ return await runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
105
110
  const routeContext = await runRouteContext(
106
111
  chain.route as Parameters<typeof runRouteContext>[0],
107
112
  loaderRequest,
@@ -13,6 +13,7 @@ import { handleActionRequest } from "./action-handler.ts";
13
13
  import { BunAdapter, type BractAdapter } from "./adapter.ts";
14
14
  import type { ModuleRegistry } from "./layout.ts";
15
15
  import { resolve, join } from "node:path";
16
+ import { error } from "./response.ts";
16
17
  import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
17
18
  import { installUseClientServerStub } from "./use-client-runtime.ts";
18
19
 
@@ -310,7 +311,22 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
310
311
  // only SSR documents. The per-route (nested) middleware chain still runs
311
312
  // inside handleRequest for SSR/_data, sharing this same `context` object.
312
313
  const ctx: MiddlewareContext = { request, params: {}, context: {} };
313
- return pipeline.run(ctx, () => dispatch(request, ctx.context));
314
+ // SECURITY(high): adapter-agnostic catch-all. An uncaught throw from a
315
+ // global middleware or from dispatch itself (e.g. resolveRouteChain at
316
+ // import time) would otherwise reach the adapter's error handler — which on
317
+ // Bun leaks err.message and on Cloudflare/custom adapters isn't handled at
318
+ // all. Log, fire onError (so observability still sees it), and return a
319
+ // generic 500 with the message gated to dev — matching every other path.
320
+ try {
321
+ return await pipeline.run(ctx, () => dispatch(request, ctx.context));
322
+ } catch (err) {
323
+ console.error("[bract] unhandled request error:", err);
324
+ await fireOnError(onError, err, request);
325
+ return error(
326
+ isExplicitDev() ? (err instanceof Error ? err.message : String(err)) : "Internal Server Error",
327
+ 500,
328
+ );
329
+ }
314
330
  };
315
331
  }
316
332
 
package/types/index.d.ts CHANGED
@@ -4,11 +4,12 @@ import type { ReactNode, Context, CSSProperties } from "react";
4
4
  export type {
5
5
  LoaderArgs, ActionArgs, MetaDescriptor, MetaArgs,
6
6
  LoaderFunction, ActionFunction, MetaFunction, RouteModule,
7
- RouteFile, Segment, RouterLocation,
7
+ RouteFile, Segment, RouterLocation, RouteMatch,
8
8
  ShouldRevalidateArgs, ShouldRevalidateFunction,
9
+ HeadersFunction, HeadersArgs,
9
10
  LoaderData, ActionData,
10
11
  } from "./route.d.ts";
11
- import type { RouterLocation, LoaderData, ActionData, ActionArgs } from "./route.d.ts";
12
+ import type { RouterLocation, LoaderData, ActionData, ActionArgs, RouteMatch } from "./route.d.ts";
12
13
 
13
14
  // ── Config + Server ───────────────────────────────────────────────────────
14
15
  export type { BractJSConfig, ServerManifest, BuildConfig } from "./config.d.ts";
@@ -259,6 +260,50 @@ export interface ScrollRestorationProps {
259
260
  /** Restores scroll on back/forward, scrolls to top (or `#hash`) on new navigations. Render once in root.tsx. */
260
261
  export declare function ScrollRestoration(props?: ScrollRestorationProps): null;
261
262
 
263
+ export type ToastType = "success" | "error" | "info" | "warning" | "loading";
264
+ export interface ToastAction { label: string; onClick: () => void }
265
+ export interface ToastEntry {
266
+ id: string;
267
+ type: ToastType;
268
+ message: string;
269
+ description?: string;
270
+ duration: number;
271
+ action?: ToastAction;
272
+ createdAt: number;
273
+ }
274
+ export interface ToastOptions {
275
+ id?: string;
276
+ type?: ToastType;
277
+ description?: string;
278
+ duration?: number;
279
+ action?: ToastAction;
280
+ }
281
+ type ToastMsg<T> = string | ((value: T) => string);
282
+ export interface Toast {
283
+ (message: string, opts?: ToastOptions): string;
284
+ success(message: string, opts?: Omit<ToastOptions, "type">): string;
285
+ error(message: string, opts?: Omit<ToastOptions, "type">): string;
286
+ info(message: string, opts?: Omit<ToastOptions, "type">): string;
287
+ warning(message: string, opts?: Omit<ToastOptions, "type">): string;
288
+ loading(message: string, opts?: Omit<ToastOptions, "type">): string;
289
+ dismiss(id?: string): void;
290
+ promise<T>(
291
+ promise: Promise<T>,
292
+ msgs: { loading: string; success: ToastMsg<T>; error: ToastMsg<unknown> },
293
+ opts?: Omit<ToastOptions, "type" | "id">,
294
+ ): Promise<T>;
295
+ }
296
+ export type ToastPosition =
297
+ | "top-left" | "top-center" | "top-right"
298
+ | "bottom-left" | "bottom-center" | "bottom-right";
299
+ export interface ToasterProps {
300
+ position?: ToastPosition;
301
+ gap?: number;
302
+ renderToast?: (toast: ToastEntry, dismiss: () => void) => ReactNode;
303
+ }
304
+ /** Renders the active toast queue. Mount once in root.tsx, then call `toast.*` anywhere. */
305
+ export declare function Toaster(props?: ToasterProps): ReactNode;
306
+
262
307
  export interface FormProps { method?: "post" | "put" | "delete"; action?: string; /** Renders a hidden `intent` input (pairs with defineActions()). */ intent?: string; children?: ReactNode; [key: string]: unknown; }
263
308
  export declare function Form(props: FormProps): ReactNode;
264
309
 
@@ -294,6 +339,8 @@ export declare function useLoaderData<T = unknown>(): LoaderData<T>;
294
339
  export declare function useActionData<T = unknown>(): ActionData<T> | null;
295
340
  /** The current location — reactive on the client, request-derived during SSR. */
296
341
  export declare function useLocation(): RouterLocation;
342
+ /** The matched route chain (root → layouts → route); each entry has `{ id, pathname, params, data, handle }`. */
343
+ export declare function useMatches(): RouteMatch[];
297
344
  export declare function useParams<TTo extends string>(): ParamsFor<TTo>;
298
345
  export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
299
346
  export type NavigationState = "idle" | "loading" | "submitting";
@@ -393,6 +440,19 @@ export interface ContextFactory<T> {
393
440
  // ── beforeLoad / useBlocker ───────────────────────────────────────────────
394
441
  export declare function useBlocker(shouldBlock: () => boolean): void;
395
442
 
443
+ // ── Toasts ────────────────────────────────────────────────────────────────
444
+ /** The stable `toast` API — call from anywhere to flash status feedback. */
445
+ export declare const toast: Toast;
446
+ export declare const toastStore: {
447
+ add(message: string, opts?: ToastOptions): string;
448
+ dismiss(id: string): void;
449
+ clear(): void;
450
+ subscribe(listener: () => void): () => void;
451
+ getSnapshot(): ToastEntry[];
452
+ };
453
+ export declare function useToast(): Toast;
454
+ export declare function useToasts(): ToastEntry[];
455
+
396
456
  // ── i18n routing (E2) ────────────────────────────────────────────────────
397
457
  export declare function useLocale(defaultLocale?: string): string;
398
458
  export declare function useLocalizedLink(defaultLocale?: string): (path: string) => string;