/** * `@nifrajs/web-react/query` - React bindings for the keyed query-cache + mutations: `useQuery`, * `useInfiniteQuery`, `useMutation`, `useQueryClient`, `QueryClientProvider`, and the SSR * `HydrationBoundary` (+ `dehydrate` re-exported). A drop-in for the TanStack Query surface, * backed by `@nifrajs/web`'s agnostic engine. Imports only `react` + `@nifrajs/web` (never `react-dom/*`), * so route components use it on the server *and* client. No JSX (the package builds with plain `tsc`). * * Resolution order for the client a hook uses: a `QueryClientProvider` in the tree (required for SSR * dehydrate/hydrate and for tests), else a lazily-created **client-side** module singleton (the simple * client-only app - the `typeof window` guard means the server has none, so hooks render idle/pending * and the first client render matches for a clean hydration). */ import type { DehydratedState, InfiniteData, InfiniteQueryOptions, MutationCallbacks, MutationHandle, MutationState, QueryClient, QueryHandle, QueryOptions, QueryState, } from "@nifrajs/web" // `/client`, not the root - these are DOM values, and the root's graph carries the // server, which Vite's dev server evaluates rather than tree-shakes. import { createMutation } from "@nifrajs/web/client" import { createNoClientRefetch, getQueryClientSingleton, IDLE_QUERY_STATE, } from "@nifrajs/web/internal/query-runtime" import { createContext, createElement, type ReactNode, useContext, useEffect, useMemo, useRef, useSyncExternalStore, } from "react" // See the note in router.ts: the `export type { … } from` form would keep a side-effect import. export type { DehydratedState } // A no-op client for the server / pre-hydration (all reads empty, all writes ignored). Stable ref so a // hook's `useSyncExternalStore` server snapshot is consistent. const NOOP_CLIENT: QueryClient = { query: () => IDLE_HANDLE as QueryHandle, infiniteQuery: () => IDLE_INFINITE_HANDLE as never, invalidateQueries: () => {}, getQueryData: () => undefined, setQueryData: () => {}, prefetchQuery: async () => {}, dehydrate: () => ({ queries: [] }), hydrate: () => {}, } const QueryClientContext = createContext(undefined) /** Provide a {@link QueryClient} to the tree - required for SSR dehydrate/hydrate and for tests; a * client-only app can omit it and rely on the built-in client-side singleton. */ export function QueryClientProvider(props: { readonly client: QueryClient readonly children?: ReactNode }): ReactNode { return createElement(QueryClientContext.Provider, { value: props.client }, props.children) } /** The active {@link QueryClient}: a `QueryClientProvider`'s client, else the client-side singleton, * else a no-op (server / pre-hydration). Use it to `invalidateQueries`/`setQueryData`/`prefetchQuery`. */ export function useQueryClient(): QueryClient { const provided = useContext(QueryClientContext) return provided ?? getQueryClientSingleton() ?? NOOP_CLIENT } // Stable idle snapshots + handles for the server / pre-fetch render (stable refs → no loop, no mismatch). const idleSnapshot = (): QueryState => IDLE_QUERY_STATE const IDLE_INFINITE: QueryState> = IDLE_QUERY_STATE as QueryState< InfiniteData > const idleInfiniteSnapshot = (): QueryState> => IDLE_INFINITE const noopSubscribe = (): (() => void) => () => {} const noopAsync = createNoClientRefetch("[nifra/web-react]", "query action") const IDLE_HANDLE: QueryHandle = { snapshot: idleSnapshot, subscribe: noopSubscribe, fetch: noopAsync, refetch: noopAsync, } const IDLE_INFINITE_HANDLE = { snapshot: idleInfiniteSnapshot, subscribe: noopSubscribe, fetch: noopAsync, refetch: noopAsync, fetchNextPage: noopAsync, fetchPreviousPage: noopAsync, hasNextPage: () => false, hasPreviousPage: () => false, } /** Options for {@link useQuery}. */ export interface UseQueryOptions extends QueryOptions { /** When `false`, don't fetch (the query stays idle) - for dependent queries. Default `true`. */ readonly enabled?: boolean } /** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */ export interface UseQueryResult extends QueryState { /** `status === "pending"` - no data yet (initial load). */ readonly isPending: boolean /** `status === "error"`. */ readonly isError: boolean /** `status === "success"`. */ readonly isSuccess: boolean /** Force a refetch (ignores `staleTime`). */ readonly refetch: () => Promise } /** * Subscribe to the keyed query for `key`, fetched via `fn`. Returns `{ status, data, error, isFetching, * updatedAt, isPending, isError, isSuccess, refetch }`. Concurrent `useQuery`s with the same key share * one cache entry + one in-flight fetch (dedup). Fetches on mount and when the key changes; `enabled: * false` keeps it idle (dependent queries). SSR-idle unless a `QueryClientProvider` supplies a hydrated * client. */ export function useQuery( key: unknown, fn: () => Promise, options?: UseQueryOptions, ): UseQueryResult { const client = useQueryClient() const enabled = options?.enabled !== false const queryOpts: QueryOptions | undefined = options?.staleTime !== undefined ? { staleTime: options.staleTime } : undefined // A disabled query still binds a handle (so it re-renders when re-enabled), but never fetches. const handle: QueryHandle = client.query(key, fn, queryOpts) // Server snapshot = the handle's own snapshot: without a provider the client is the NOOP one (idle), // but WITH a hydrated provider client it returns the server-seeded data - so a HydrationBoundary-fed // query renders its data during SSR and the first client render matches (no loading flash, no drift). const state = useSyncExternalStore>( handle.subscribe, handle.snapshot, handle.snapshot, ) useEffect(() => { if (enabled) handle.fetch().catch(() => {}) }, [handle, enabled]) return { ...state, isPending: state.status === "pending", isError: state.status === "error", isSuccess: state.status === "success", refetch: handle.refetch, } } /** A mutation's reactive state + imperative controls (the TanStack `useMutation` shape). */ export interface UseMutationResult extends MutationState { readonly isIdle: boolean readonly isPending: boolean readonly isError: boolean readonly isSuccess: boolean /** Fire-and-forget: runs the mutation and swallows rejection (read `error`/`isError` for failures). */ readonly mutate: (variables: TVariables) => void /** Run the mutation and return the promise (rejects on failure) - for `await`. */ readonly mutateAsync: (variables: TVariables) => Promise /** Reset back to idle. */ readonly reset: () => void } /** * A mutation hook (create/update/delete). Returns `{ mutate, mutateAsync, data, error, variables, isIdle, * isPending, isError, isSuccess, reset }`. Invalidate affected queries from `onSuccess` via * `useQueryClient().invalidateQueries(...)`. The handle is stable across renders; the latest `fn`/ * callbacks re-bind each render. */ export function useMutation( fn: (variables: TVariables) => Promise, callbacks: MutationCallbacks = {}, ): UseMutationResult { const ref = useRef | undefined>(undefined) if (ref.current === undefined) ref.current = createMutation(fn, callbacks) const handle = ref.current handle.rebind(fn, callbacks) // pick up the latest closures each render const state = useSyncExternalStore(handle.subscribe, handle.snapshot, handle.snapshot) return { ...state, isIdle: state.status === "idle", isPending: state.status === "pending", isError: state.status === "error", isSuccess: state.status === "success", mutate: (variables) => { handle.mutate(variables).catch(() => {}) // fire-and-forget: don't surface an unhandled rejection }, mutateAsync: handle.mutate, reset: handle.reset, } } /** Options for {@link useInfiniteQuery} - the engine's {@link InfiniteQueryOptions} plus `enabled`. */ export interface UseInfiniteQueryOptions extends InfiniteQueryOptions { readonly enabled?: boolean } /** An infinite query's reactive state + paging controls. */ export interface UseInfiniteQueryResult extends QueryState> { readonly isPending: boolean readonly isError: boolean readonly isSuccess: boolean readonly fetchNextPage: () => Promise> readonly fetchPreviousPage: () => Promise> readonly hasNextPage: boolean readonly hasPreviousPage: boolean readonly refetch: () => Promise> } /** * Subscribe to a paged (infinite-scroll) query. Returns the accumulated `data.pages` plus * `fetchNextPage`/`fetchPreviousPage`/`hasNextPage`/`hasPreviousPage`. Fetches the first page on mount. * SSR-idle unless a `QueryClientProvider` supplies a hydrated client. */ export function useInfiniteQuery( key: unknown, fn: (pageParam: P) => Promise, options: UseInfiniteQueryOptions, ): UseInfiniteQueryResult { const client = useQueryClient() const enabled = options.enabled !== false const handle = client.infiniteQuery(key, fn, options) const state = useSyncExternalStore(handle.subscribe, handle.snapshot, handle.snapshot) useEffect(() => { if (enabled) handle.fetch().catch(() => {}) }, [handle, enabled]) return { ...state, isPending: state.status === "pending", isError: state.status === "error", isSuccess: state.status === "success", fetchNextPage: handle.fetchNextPage, fetchPreviousPage: handle.fetchPreviousPage, hasNextPage: handle.hasNextPage(), hasPreviousPage: handle.hasPreviousPage(), refetch: handle.refetch, } } /** * Seed the context's {@link QueryClient} from a server {@link dehydrate} snapshot - the SSR data bridge. * Wrap the app (inside `QueryClientProvider`) so server-prefetched queries are in the cache before the * first client render, avoiding a loading flash. Hydration runs during render (idempotent, fresher-wins), * so the data is available synchronously to child `useQuery`s. */ export function HydrationBoundary(props: { readonly state: DehydratedState | undefined readonly children?: ReactNode }): ReactNode { const client = useQueryClient() const { state } = props useMemo(() => { if (state !== undefined) client.hydrate(state) }, [client, state]) return props.children ?? null }