@nifrajs/web-react 1.6.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/client.d.ts.map +1 -1
- package/dist/client.js +4 -0
- package/dist/client.js.map +1 -1
- package/dist/compose.d.ts +5 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +9 -1
- package/dist/compose.js.map +1 -1
- package/dist/query.d.ts +89 -6
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +147 -29
- package/dist/query.js.map +1 -1
- package/dist/router.d.ts +108 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +165 -0
- package/dist/router.js.map +1 -0
- package/package.json +10 -5
- package/src/client.ts +4 -0
- package/src/compose.ts +14 -1
- package/src/query.ts +234 -33
- package/src/router.ts +259 -0
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.
|
|
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.
|
|
79
|
-
"@nifrajs/i18n": "1.
|
|
80
|
-
"@nifrajs/image": "1.
|
|
81
|
-
"@nifrajs/web": "1.
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
57
|
-
* one in-flight fetch (dedup).
|
|
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>(
|
|
60
|
-
|
|
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
|
|
63
|
-
handle
|
|
64
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|