@nifrajs/web-react 1.7.0 → 1.9.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/dist/router.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `@nifrajs/web-react/router` — React routing bindings over the agnostic `@nifrajs/web` router:
3
+ * `<Link>`/`<NavLink>`, `useNavigate`, `useParams`, `useLocation`, `useSearchParams`, and `<Navigate>`.
4
+ *
5
+ * These read the current route from a {@link RouterContext} that `compose` provides on BOTH the SSR
6
+ * render and the client mount (seeded from the request match / router state respectively), so the read
7
+ * hooks (`useParams`/`useLocation`/`useSearchParams`) are SSR-correct and hydrate with no mismatch.
8
+ * Navigation goes through `@nifrajs/web`'s DOM-free navigate bridge (`getBrowserNavigate`, populated by
9
+ * `installHistory`) — so this module imports only `react` (never `react-dom/*`), and a route component
10
+ * can use these on the server and the client without dragging a DOM build into the wrong bundle. No JSX
11
+ * (the package builds with plain `tsc`), so everything is `createElement`.
12
+ */
13
+ import { getBrowserNavigate } from "@nifrajs/web";
14
+ import { createContext, createElement, forwardRef, useCallback, useContext, useEffect, useMemo, } from "react";
15
+ // Frozen empty params so the default context value has a stable reference (no needless re-renders).
16
+ const EMPTY_PARAMS = Object.freeze({});
17
+ /** Router context. The default ({} params, "" path) is what a component sees when rendered outside a
18
+ * nifra route tree — the hooks stay defined (no throw) so a stray `useParams` degrades gracefully. */
19
+ export const RouterContext = createContext({ params: EMPTY_PARAMS, path: "" });
20
+ /** Split a `pathname + search` into its parts. `search` keeps its leading `?` (like `location.search`);
21
+ * an empty query yields `""`. */
22
+ function splitPath(path) {
23
+ const q = path.indexOf("?");
24
+ return q === -1
25
+ ? { pathname: path, search: "" }
26
+ : { pathname: path.slice(0, q), search: path.slice(q) };
27
+ }
28
+ /**
29
+ * The matched route's decoded path params — `/users/:id` on `/users/7` → `{ id: "7" }`. SSR-correct:
30
+ * `compose` provides the same value server-side (from the request match) and client-side (from router
31
+ * state), so a param rendered into markup doesn't flash on hydration.
32
+ */
33
+ export function useParams() {
34
+ return useContext(RouterContext).params;
35
+ }
36
+ /** The current {@link Location} (`pathname`/`search`/`hash`), derived from the router context. */
37
+ export function useLocation() {
38
+ const { path } = useContext(RouterContext);
39
+ return useMemo(() => {
40
+ const { pathname, search } = splitPath(path);
41
+ return { pathname, search, hash: "" };
42
+ }, [path]);
43
+ }
44
+ /** Get the {@link NavigateFunction}. Stable across renders; resolves the browser navigate at call time
45
+ * (so it works as soon as `installHistory` has run, and no-ops before then / on the server). */
46
+ export function useNavigate() {
47
+ return useCallback((to, options) => {
48
+ const navigate = getBrowserNavigate();
49
+ if (navigate !== undefined)
50
+ navigate(to, options);
51
+ }, []);
52
+ }
53
+ function toSearchParams(init) {
54
+ if (init instanceof URLSearchParams)
55
+ return init;
56
+ if (typeof init === "string")
57
+ return new URLSearchParams(init);
58
+ const usp = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(init)) {
60
+ if (Array.isArray(value))
61
+ for (const item of value)
62
+ usp.append(key, item);
63
+ else
64
+ usp.set(key, value);
65
+ }
66
+ return usp;
67
+ }
68
+ /**
69
+ * The current query as a `URLSearchParams` (SSR-correct via the router context) plus a setter that
70
+ * navigates to the new query. Mirrors react-router's `useSearchParams` tuple.
71
+ */
72
+ export function useSearchParams() {
73
+ const { path } = useContext(RouterContext);
74
+ const { pathname, search } = splitPath(path);
75
+ const searchParams = useMemo(() => new URLSearchParams(search), [search]);
76
+ const navigate = useNavigate();
77
+ const setSearchParams = useCallback((next, options) => {
78
+ const resolved = typeof next === "function" ? next(new URLSearchParams(search)) : next;
79
+ const qs = toSearchParams(resolved).toString();
80
+ navigate(pathname + (qs !== "" ? `?${qs}` : ""), options);
81
+ }, [navigate, pathname, search]);
82
+ return [searchParams, setSearchParams];
83
+ }
84
+ /** A left-click with no modifier and no new-tab target — the only click a client router should
85
+ * intercept (Cmd/Ctrl/Shift/Alt or a `_blank` target means the user wants native behavior). */
86
+ function isPlainLeftClick(event, target) {
87
+ if (event.button !== 0)
88
+ return false;
89
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
90
+ return false;
91
+ return target === undefined || target === "" || target === "_self";
92
+ }
93
+ /**
94
+ * A client-navigating anchor. Renders a real `<a href={to}>` (so it's a working link before hydration
95
+ * and for right-click / open-in-new-tab), and on a plain left-click navigates through the router
96
+ * instead of a full reload. Calling `navigate` + `preventDefault` here means `installHistory`'s
97
+ * document-level click handler sees `defaultPrevented` and stands down — exactly one navigation.
98
+ */
99
+ export const Link = forwardRef(function Link({ to, replace, onClick, target, ...rest }, ref) {
100
+ const handleClick = (event) => {
101
+ onClick?.(event);
102
+ if (event.defaultPrevented)
103
+ return; // the app's own handler already took over
104
+ if (!isPlainLeftClick(event, target))
105
+ return; // modified / new-tab click → native
106
+ const navigate = getBrowserNavigate();
107
+ if (navigate === undefined)
108
+ return; // pre-hydration → let the native <a href> full-load
109
+ event.preventDefault();
110
+ navigate(to, { replace: replace === true });
111
+ };
112
+ return createElement("a", { ...rest, href: to, target, onClick: handleClick, ref });
113
+ });
114
+ /** Normalize a pathname for comparison: optional case-fold + drop a single trailing slash (but keep a
115
+ * bare `"/"`). */
116
+ function normalizePath(pathname, caseSensitive) {
117
+ const cased = caseSensitive ? pathname : pathname.toLowerCase();
118
+ return cased !== "/" && cased.endsWith("/") ? cased.slice(0, -1) : cased;
119
+ }
120
+ /**
121
+ * A {@link Link} that knows whether it points at the current location. Adds `aria-current="page"` when
122
+ * active and resolves function-form `className`/`style`/`children` with `{ isActive, isPending }`.
123
+ * Default matching is prefix-on-segment-boundary (so `/users` is active on `/users/7`); pass `end` for
124
+ * an exact match.
125
+ */
126
+ export const NavLink = forwardRef(function NavLink({ to, end, caseSensitive, className, style, children, ...rest }, ref) {
127
+ const { pathname } = useLocation();
128
+ const cs = caseSensitive === true;
129
+ const current = normalizePath(pathname, cs);
130
+ const target = normalizePath(splitPath(to).pathname, cs);
131
+ const isActive = end === true ? current === target : matchesPrefix(current, target);
132
+ const renderProps = { isActive, isPending: false };
133
+ const resolvedClassName = typeof className === "function" ? className(renderProps) : className;
134
+ const resolvedStyle = typeof style === "function" ? style(renderProps) : style;
135
+ const resolvedChildren = typeof children === "function" ? children(renderProps) : children;
136
+ return createElement(Link, {
137
+ ...rest,
138
+ to,
139
+ ref,
140
+ ...(resolvedClassName !== undefined ? { className: resolvedClassName } : {}),
141
+ ...(resolvedStyle !== undefined ? { style: resolvedStyle } : {}),
142
+ // `aria-current="page"` is the accessible active marker; style with `[aria-current="page"]`.
143
+ "aria-current": isActive ? "page" : undefined,
144
+ }, resolvedChildren);
145
+ });
146
+ /** Prefix match on a segment boundary: `current` is `target`, or lives under it (`target + "/"`…). A
147
+ * bare `"/"` target is a prefix of everything (so `<NavLink to="/">` is active everywhere unless `end`). */
148
+ function matchesPrefix(current, target) {
149
+ if (current === target)
150
+ return true;
151
+ return current.startsWith(target === "/" ? "/" : `${target}/`);
152
+ }
153
+ /**
154
+ * Declaratively navigate on mount — the component analogue of `useNavigate` (e.g. a guard that renders
155
+ * `<Navigate to="/login" replace />`). Navigates in an effect, so it's a safe no-op during SSR (renders
156
+ * `null`); the redirect happens once on the client after hydration.
157
+ */
158
+ export function Navigate({ to, replace }) {
159
+ const navigate = useNavigate();
160
+ useEffect(() => {
161
+ navigate(to, { replace: replace === true });
162
+ }, [navigate, to, replace]);
163
+ return null;
164
+ }
165
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web-react",
3
- "version": "1.7.0",
3
+ "version": "1.9.1",
4
4
  "description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,6 +41,11 @@
41
41
  "types": "./dist/fetcher.d.ts",
42
42
  "default": "./dist/fetcher.js"
43
43
  },
44
+ "./router": {
45
+ "bun": "./src/router.ts",
46
+ "types": "./dist/router.d.ts",
47
+ "default": "./dist/router.js"
48
+ },
44
49
  "./query": {
45
50
  "bun": "./src/query.ts",
46
51
  "types": "./dist/query.d.ts",
@@ -75,10 +80,10 @@
75
80
  "access": "public"
76
81
  },
77
82
  "dependencies": {
78
- "@nifrajs/core": "1.7.0",
79
- "@nifrajs/i18n": "1.7.0",
80
- "@nifrajs/image": "1.7.0",
81
- "@nifrajs/web": "1.7.0"
83
+ "@nifrajs/core": "1.9.1",
84
+ "@nifrajs/i18n": "1.9.1",
85
+ "@nifrajs/image": "1.9.1",
86
+ "@nifrajs/web": "1.9.1"
82
87
  },
83
88
  "peerDependencies": {
84
89
  "react": "^19.0.0",
package/src/client.ts CHANGED
@@ -34,6 +34,10 @@ export function mountRouter(options: MountRouterOptions): void {
34
34
  data: state.data,
35
35
  actionData: state.actionData,
36
36
  pending: state.pending,
37
+ // `params`/`path` feed the routing hooks (useParams/useLocation) via compose's RouterContext —
38
+ // sourced from router state here, matching the SSR render's request-derived values on hydration.
39
+ params: state.params,
40
+ path: state.path,
37
41
  // The in-flight submission (for optimistic UI) — spread only when present.
38
42
  ...(state.submission ? { submission: state.submission } : {}),
39
43
  })
package/src/compose.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import type { RenderProps } from "@nifrajs/web"
2
2
  import { createElement, type FunctionComponent, type ReactNode } from "react"
3
+ import { RouterContext } from "./router.ts"
4
+
5
+ // Stable empty params so the provider value's `params` has a fixed reference when a route has none.
6
+ const EMPTY_PARAMS: Readonly<Record<string, string>> = Object.freeze({})
3
7
 
4
8
  /**
5
9
  * Fold a layout chain (outermost layout → page) into a single React tree: the page
6
10
  * (innermost) receives `props` (the loader data); each layout wraps the child via its
7
11
  * `children`. Shared by the server adapter (renderToString) and the client (hydrateRoot).
12
+ *
13
+ * The whole tree is wrapped in a {@link RouterContext} provider carrying the matched `params` + the
14
+ * current `path` (both threaded through `RenderProps` identically on SSR and client), so the routing
15
+ * hooks (`useParams`/`useLocation`/`useSearchParams`) read the same value on both sides — no hydration
16
+ * mismatch. A render with no routing fields (a non-router adapter usage) provides the empty default.
8
17
  */
9
18
  export function compose(chain: readonly unknown[], props: RenderProps): ReactNode {
10
19
  const last = chain.length - 1
@@ -13,5 +22,9 @@ export function compose(chain: readonly unknown[], props: RenderProps): ReactNod
13
22
  // children passed as the 3rd arg (not a `children` prop) — React's canonical form.
14
23
  node = createElement(chain[i] as FunctionComponent, null, node)
15
24
  }
16
- return node
25
+ return createElement(
26
+ RouterContext.Provider,
27
+ { value: { params: props.params ?? EMPTY_PARAMS, path: props.path ?? "" } },
28
+ node,
29
+ )
17
30
  }
package/src/query.ts CHANGED
@@ -1,35 +1,83 @@
1
+ /**
2
+ * `@nifrajs/web-react/query` — React bindings for the keyed query-cache + mutations: `useQuery`,
3
+ * `useInfiniteQuery`, `useMutation`, `useQueryClient`, `QueryClientProvider`, and the SSR
4
+ * `HydrationBoundary` (+ `dehydrate` re-exported). A drop-in for the TanStack Query surface,
5
+ * backed by `@nifrajs/web`'s agnostic engine. Imports only `react` + `@nifrajs/web` (never `react-dom/*`),
6
+ * so route components use it on the server *and* client. No JSX (the package builds with plain `tsc`).
7
+ *
8
+ * Resolution order for the client a hook uses: a `QueryClientProvider` in the tree (required for SSR
9
+ * dehydrate/hydrate and for tests), else a lazily-created **client-side** module singleton (the simple
10
+ * client-only app — the `typeof window` guard means the server has none, so hooks render idle/pending
11
+ * and the first client render matches for a clean hydration).
12
+ */
1
13
  import {
14
+ createMutation,
2
15
  createQueryClient,
16
+ type DehydratedState,
17
+ type InfiniteData,
18
+ type InfiniteQueryOptions,
19
+ type MutationCallbacks,
20
+ type MutationHandle,
21
+ type MutationState,
3
22
  type QueryClient,
4
23
  type QueryHandle,
24
+ type QueryOptions,
5
25
  type QueryState,
6
26
  } from "@nifrajs/web"
7
- /**
8
- * `@nifrajs/web-react/query` — React bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
9
- * component to a query (via `useSyncExternalStore`) and fetches on mount / key change; `useQueryClient`
10
- * exposes `invalidateQueries` for after a mutation. Imports only `react` + `@nifrajs/web` (never
11
- * `react-dom/*`), so route components use it on the server *and* client. No JSX (the package builds
12
- * with plain `tsc`).
13
- *
14
- * The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
15
- * there is none, so `useQuery` renders the idle/pending state (queries are client-first; loaders are
16
- * the SSR data source). The first client render uses the same idle server-snapshot, so no mismatch.
17
- */
18
- import { useEffect, useSyncExternalStore } from "react"
27
+ import {
28
+ createContext,
29
+ createElement,
30
+ type ReactNode,
31
+ useContext,
32
+ useEffect,
33
+ useMemo,
34
+ useRef,
35
+ useSyncExternalStore,
36
+ } from "react"
37
+
38
+ export type { DehydratedState } from "@nifrajs/web"
39
+
40
+ // The lazily-created client-side singleton (used when no QueryClientProvider is present). SSR-guarded:
41
+ // the server has no singleton, so provider-less hooks render idle there.
42
+ let singleton: QueryClient | undefined
43
+ function getSingleton(): QueryClient | undefined {
44
+ if (typeof window === "undefined") return undefined
45
+ if (singleton === undefined) singleton = createQueryClient({ now: () => Date.now() })
46
+ return singleton
47
+ }
19
48
 
20
- let client: QueryClient | undefined
21
- function getClient(): QueryClient | undefined {
22
- if (typeof window === "undefined") return undefined // SSR → no client (queries render idle)
23
- if (client === undefined) client = createQueryClient({ now: () => Date.now() })
24
- return client
49
+ // A no-op client for the server / pre-hydration (all reads empty, all writes ignored). Stable ref so a
50
+ // hook's `useSyncExternalStore` server snapshot is consistent.
51
+ const NOOP_CLIENT: QueryClient = {
52
+ query: () => IDLE_HANDLE as QueryHandle<never>,
53
+ infiniteQuery: () => IDLE_INFINITE_HANDLE as never,
54
+ invalidateQueries: () => {},
55
+ getQueryData: () => undefined,
56
+ setQueryData: () => {},
57
+ prefetchQuery: async () => {},
58
+ dehydrate: () => ({ queries: [] }),
59
+ hydrate: () => {},
25
60
  }
26
61
 
27
- /** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
28
- export function useQueryClient(): Pick<QueryClient, "invalidateQueries"> {
29
- return getClient() ?? { invalidateQueries: () => {} }
62
+ const QueryClientContext = createContext<QueryClient | undefined>(undefined)
63
+
64
+ /** Provide a {@link QueryClient} to the tree — required for SSR dehydrate/hydrate and for tests; a
65
+ * client-only app can omit it and rely on the built-in client-side singleton. */
66
+ export function QueryClientProvider(props: {
67
+ readonly client: QueryClient
68
+ readonly children?: ReactNode
69
+ }): ReactNode {
70
+ return createElement(QueryClientContext.Provider, { value: props.client }, props.children)
30
71
  }
31
72
 
32
- // Stable idle snapshot for the server / pre-fetch render (stable ref → no loop, no hydration mismatch).
73
+ /** The active {@link QueryClient}: a `QueryClientProvider`'s client, else the client-side singleton,
74
+ * else a no-op (server / pre-hydration). Use it to `invalidateQueries`/`setQueryData`/`prefetchQuery`. */
75
+ export function useQueryClient(): QueryClient {
76
+ const provided = useContext(QueryClientContext)
77
+ return provided ?? getSingleton() ?? NOOP_CLIENT
78
+ }
79
+
80
+ // Stable idle snapshots + handles for the server / pre-fetch render (stable refs → no loop, no mismatch).
33
81
  const IDLE: QueryState<never> = Object.freeze({
34
82
  status: "pending",
35
83
  data: undefined,
@@ -38,38 +86,191 @@ const IDLE: QueryState<never> = Object.freeze({
38
86
  updatedAt: Number.NEGATIVE_INFINITY,
39
87
  })
40
88
  const idleSnapshot = (): QueryState<never> => IDLE
89
+ const IDLE_INFINITE: QueryState<InfiniteData<never, never>> = IDLE as QueryState<
90
+ InfiniteData<never, never>
91
+ >
92
+ const idleInfiniteSnapshot = (): QueryState<InfiniteData<never, never>> => IDLE_INFINITE
41
93
  const noopSubscribe = (): (() => void) => () => {}
42
94
  const noopAsync = async (): Promise<never> => {
43
- throw new Error("[nifra/web-react] useQuery.refetch called with no query client (server?)")
95
+ throw new Error("[nifra/web-react] query action called with no query client (server?)")
96
+ }
97
+ const IDLE_HANDLE: QueryHandle<never> = {
98
+ snapshot: idleSnapshot,
99
+ subscribe: noopSubscribe,
100
+ fetch: noopAsync,
101
+ refetch: noopAsync,
102
+ }
103
+ const IDLE_INFINITE_HANDLE = {
104
+ snapshot: idleInfiniteSnapshot,
105
+ subscribe: noopSubscribe,
106
+ fetch: noopAsync,
107
+ refetch: noopAsync,
108
+ fetchNextPage: noopAsync,
109
+ fetchPreviousPage: noopAsync,
110
+ hasNextPage: () => false,
111
+ hasPreviousPage: () => false,
112
+ }
113
+
114
+ /** Options for {@link useQuery}. */
115
+ export interface UseQueryOptions extends QueryOptions {
116
+ /** When `false`, don't fetch (the query stays idle) — for dependent queries. Default `true`. */
117
+ readonly enabled?: boolean
44
118
  }
45
119
 
46
120
  /** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */
47
121
  export interface UseQueryResult<T> extends QueryState<T> {
48
122
  /** `status === "pending"` — no data yet (initial load). */
49
123
  readonly isPending: boolean
124
+ /** `status === "error"`. */
125
+ readonly isError: boolean
126
+ /** `status === "success"`. */
127
+ readonly isSuccess: boolean
50
128
  /** Force a refetch (ignores `staleTime`). */
51
129
  readonly refetch: () => Promise<T>
52
130
  }
53
131
 
54
132
  /**
55
133
  * Subscribe to the keyed query for `key`, fetched via `fn`. Returns `{ status, data, error, isFetching,
56
- * updatedAt, isPending, refetch }`. Concurrent `useQuery`s with the same key share one cache entry +
57
- * one in-flight fetch (dedup). Refetches on mount and when the key changes; SSR-idle.
134
+ * updatedAt, isPending, isError, isSuccess, refetch }`. Concurrent `useQuery`s with the same key share
135
+ * one cache entry + one in-flight fetch (dedup). Fetches on mount and when the key changes; `enabled:
136
+ * false` keeps it idle (dependent queries). SSR-idle unless a `QueryClientProvider` supplies a hydrated
137
+ * client.
58
138
  */
59
- export function useQuery<T>(key: unknown, fn: () => Promise<T>): UseQueryResult<T> {
60
- const handle: QueryHandle<T> | undefined = getClient()?.query<T>(key, fn)
139
+ export function useQuery<T>(
140
+ key: unknown,
141
+ fn: () => Promise<T>,
142
+ options?: UseQueryOptions,
143
+ ): UseQueryResult<T> {
144
+ const client = useQueryClient()
145
+ const enabled = options?.enabled !== false
146
+ const queryOpts: QueryOptions | undefined =
147
+ options?.staleTime !== undefined ? { staleTime: options.staleTime } : undefined
148
+ // A disabled query still binds a handle (so it re-renders when re-enabled), but never fetches.
149
+ const handle: QueryHandle<T> = client.query<T>(key, fn, queryOpts)
150
+ // Server snapshot = the handle's own snapshot: without a provider the client is the NOOP one (idle),
151
+ // but WITH a hydrated provider client it returns the server-seeded data — so a HydrationBoundary-fed
152
+ // query renders its data during SSR and the first client render matches (no loading flash, no drift).
61
153
  const state = useSyncExternalStore<QueryState<T>>(
62
- handle?.subscribe ?? noopSubscribe,
63
- handle ? handle.snapshot : idleSnapshot,
64
- idleSnapshot,
154
+ handle.subscribe,
155
+ handle.snapshot,
156
+ handle.snapshot,
65
157
  )
66
- // Fetch on mount + whenever the key changes (the handle is stable per key, so it changes with the key).
67
158
  useEffect(() => {
68
- if (handle !== undefined) handle.fetch().catch(() => {})
69
- }, [handle])
159
+ if (enabled) handle.fetch().catch(() => {})
160
+ }, [handle, enabled])
161
+ return {
162
+ ...state,
163
+ isPending: state.status === "pending",
164
+ isError: state.status === "error",
165
+ isSuccess: state.status === "success",
166
+ refetch: handle.refetch,
167
+ }
168
+ }
169
+
170
+ /** A mutation's reactive state + imperative controls (the TanStack `useMutation` shape). */
171
+ export interface UseMutationResult<TData, TVariables> extends MutationState<TData, TVariables> {
172
+ readonly isIdle: boolean
173
+ readonly isPending: boolean
174
+ readonly isError: boolean
175
+ readonly isSuccess: boolean
176
+ /** Fire-and-forget: runs the mutation and swallows rejection (read `error`/`isError` for failures). */
177
+ readonly mutate: (variables: TVariables) => void
178
+ /** Run the mutation and return the promise (rejects on failure) — for `await`. */
179
+ readonly mutateAsync: (variables: TVariables) => Promise<TData>
180
+ /** Reset back to idle. */
181
+ readonly reset: () => void
182
+ }
183
+
184
+ /**
185
+ * A mutation hook (create/update/delete). Returns `{ mutate, mutateAsync, data, error, variables, isIdle,
186
+ * isPending, isError, isSuccess, reset }`. Invalidate affected queries from `onSuccess` via
187
+ * `useQueryClient().invalidateQueries(...)`. The handle is stable across renders; the latest `fn`/
188
+ * callbacks re-bind each render.
189
+ */
190
+ export function useMutation<TData, TVariables = void>(
191
+ fn: (variables: TVariables) => Promise<TData>,
192
+ callbacks: MutationCallbacks<TData, TVariables> = {},
193
+ ): UseMutationResult<TData, TVariables> {
194
+ const ref = useRef<MutationHandle<TData, TVariables> | undefined>(undefined)
195
+ if (ref.current === undefined) ref.current = createMutation(fn, callbacks)
196
+ const handle = ref.current
197
+ handle.rebind(fn, callbacks) // pick up the latest closures each render
198
+ const state = useSyncExternalStore(handle.subscribe, handle.snapshot, handle.snapshot)
199
+ return {
200
+ ...state,
201
+ isIdle: state.status === "idle",
202
+ isPending: state.status === "pending",
203
+ isError: state.status === "error",
204
+ isSuccess: state.status === "success",
205
+ mutate: (variables) => {
206
+ handle.mutate(variables).catch(() => {}) // fire-and-forget: don't surface an unhandled rejection
207
+ },
208
+ mutateAsync: handle.mutate,
209
+ reset: handle.reset,
210
+ }
211
+ }
212
+
213
+ /** Options for {@link useInfiniteQuery} — the engine's {@link InfiniteQueryOptions} plus `enabled`. */
214
+ export interface UseInfiniteQueryOptions<T, P> extends InfiniteQueryOptions<T, P> {
215
+ readonly enabled?: boolean
216
+ }
217
+
218
+ /** An infinite query's reactive state + paging controls. */
219
+ export interface UseInfiniteQueryResult<T, P> extends QueryState<InfiniteData<T, P>> {
220
+ readonly isPending: boolean
221
+ readonly isError: boolean
222
+ readonly isSuccess: boolean
223
+ readonly fetchNextPage: () => Promise<InfiniteData<T, P>>
224
+ readonly fetchPreviousPage: () => Promise<InfiniteData<T, P>>
225
+ readonly hasNextPage: boolean
226
+ readonly hasPreviousPage: boolean
227
+ readonly refetch: () => Promise<InfiniteData<T, P>>
228
+ }
229
+
230
+ /**
231
+ * Subscribe to a paged (infinite-scroll) query. Returns the accumulated `data.pages` plus
232
+ * `fetchNextPage`/`fetchPreviousPage`/`hasNextPage`/`hasPreviousPage`. Fetches the first page on mount.
233
+ * SSR-idle unless a `QueryClientProvider` supplies a hydrated client.
234
+ */
235
+ export function useInfiniteQuery<T, P>(
236
+ key: unknown,
237
+ fn: (pageParam: P) => Promise<T>,
238
+ options: UseInfiniteQueryOptions<T, P>,
239
+ ): UseInfiniteQueryResult<T, P> {
240
+ const client = useQueryClient()
241
+ const enabled = options.enabled !== false
242
+ const handle = client.infiniteQuery<T, P>(key, fn, options)
243
+ const state = useSyncExternalStore(handle.subscribe, handle.snapshot, handle.snapshot)
244
+ useEffect(() => {
245
+ if (enabled) handle.fetch().catch(() => {})
246
+ }, [handle, enabled])
70
247
  return {
71
248
  ...state,
72
249
  isPending: state.status === "pending",
73
- refetch: handle ? handle.refetch : (noopAsync as () => Promise<T>),
250
+ isError: state.status === "error",
251
+ isSuccess: state.status === "success",
252
+ fetchNextPage: handle.fetchNextPage,
253
+ fetchPreviousPage: handle.fetchPreviousPage,
254
+ hasNextPage: handle.hasNextPage(),
255
+ hasPreviousPage: handle.hasPreviousPage(),
256
+ refetch: handle.refetch,
74
257
  }
75
258
  }
259
+
260
+ /**
261
+ * Seed the context's {@link QueryClient} from a server {@link dehydrate} snapshot — the SSR data bridge.
262
+ * Wrap the app (inside `QueryClientProvider`) so server-prefetched queries are in the cache before the
263
+ * first client render, avoiding a loading flash. Hydration runs during render (idempotent, fresher-wins),
264
+ * so the data is available synchronously to child `useQuery`s.
265
+ */
266
+ export function HydrationBoundary(props: {
267
+ readonly state: DehydratedState | undefined
268
+ readonly children?: ReactNode
269
+ }): ReactNode {
270
+ const client = useQueryClient()
271
+ const { state } = props
272
+ useMemo(() => {
273
+ if (state !== undefined) client.hydrate(state)
274
+ }, [client, state])
275
+ return props.children ?? null
276
+ }