/**
* `@nifrajs/web-react/router` - React routing bindings over the agnostic `@nifrajs/web` router:
* ``/``, `useNavigate`, `useParams`, `useLocation`, `useSearchParams`, and ``.
*
* These read the current route from a {@link RouterContext} that `compose` provides on BOTH the SSR
* render and the client mount (seeded from the request match / router state respectively), so the read
* hooks (`useParams`/`useLocation`/`useSearchParams`) are SSR-correct and hydrate with no mismatch.
* Navigation goes through `@nifrajs/web`'s DOM-free navigate bridge (`getBrowserNavigate`, populated by
* `installHistory`) - so this module imports only `react` (never `react-dom/*`), and a route component
* can use these on the server and the client without dragging a DOM build into the wrong bundle. No JSX
* (the package builds with plain `tsc`), so everything is `createElement`.
*/
import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
// `import type` + a local re-export, NOT `export type { … } from "@nifrajs/web"`: that form leaves a
// bare `import "@nifrajs/web"` in the output, which pulls the server graph into the browser under
// Vite's dev server. Sourced from the ROOT so the generated `RouteSearch` augmentation applies.
import type {
Blocker,
BlockerFunction,
BlockerState,
NavigateFunction,
NavigateOptions,
NavigateTargetInput,
} 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 {
getBrowserNavigate,
IDLE_BLOCKER,
registerBlocker,
resolveNavigate,
} from "@nifrajs/web/client"
import {
type AnchorHTMLAttributes,
type CSSProperties,
createContext,
createElement,
forwardRef,
type MouseEvent,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
export type { Blocker, BlockerFunction, BlockerState, NavigateFunction }
/** The current route the routing hooks read. Provided by `compose` on SSR + client mount alike. */
export interface RouterContextValue {
/** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). */
readonly params: Readonly>
/** The current URL's `pathname + search` (no hash - the router never carries one). */
readonly path: string
/** The route's typed, validated search params (the loader's `ctx.search`), derived from the URL via
* the shared `searchOf` on SSR + client alike, read by {@link useSearch}. `{}` when the route
* declares no `searchSchema` (then it is the raw parsed query) or outside a nifra route tree. */
readonly search: Record
/** True while a client navigation (or revalidation) is in flight - the current route stays mounted
* until the new one is ready. Always `false` on SSR (loaders block before render). Drives loading UI
* via {@link useNavigation}. */
readonly pending: boolean
/** The `pathname + search` a navigation is transitioning TO while `pending` (`undefined` when idle or
* during a same-route revalidation). Powers {@link NavLink}'s per-link `isPending`. */
readonly pendingPath?: string | undefined
}
// Frozen empty params/search so the default context value has a stable reference (no needless re-renders).
const EMPTY_PARAMS: Readonly> = Object.freeze({})
const EMPTY_SEARCH: Readonly> = Object.freeze({})
/** Router context. The default ({} params, "" path, {} search) is what a component sees when rendered
* outside a nifra route tree (the hooks stay defined, no throw, so a stray `useParams` degrades
* gracefully).
*
* A `globalThis` singleton (same registry idiom as `@nifrajs/web`'s `Symbol.for` signals), because in
* dev this module is evaluated TWICE in one process: the app's server code imports it under Bun (the
* adapter whose `compose` provides the context) while route modules import it through Vite's SSR
* runner (the components whose hooks read it). Two evaluations means two `createContext` results, and
* React matches providers to readers by object identity - so without the singleton, every routing hook
* SSR-renders its empty default in dev while the loader (same request!) sees the real values. React
* itself is deduped to one copy (`resolve.dedupe` in dev, `reactDedupePlugin` in builds), so the one
* shared context object is read by the one shared React. */
const ROUTER_CONTEXT_SLOT = Symbol.for("nifra.web-react.router-context")
const contextSlot = globalThis as {
[ROUTER_CONTEXT_SLOT]?: ReturnType>
}
export const RouterContext =
contextSlot[ROUTER_CONTEXT_SLOT] ??
createContext({
params: EMPTY_PARAMS,
path: "",
search: EMPTY_SEARCH,
pending: false,
})
contextSlot[ROUTER_CONTEXT_SLOT] = RouterContext
/** Split a `pathname + search` into its parts. `search` keeps its leading `?` (like `location.search`);
* an empty query yields `""`. */
function splitPath(path: string): { readonly pathname: string; readonly search: string } {
const q = path.indexOf("?")
return q === -1
? { pathname: path, search: "" }
: { pathname: path.slice(0, q), search: path.slice(q) }
}
/**
* The matched route's decoded path params - `/users/:id` on `/users/7` → `{ id: "7" }`. SSR-correct:
* `compose` provides the same value server-side (from the request match) and client-side (from router
* state), so a param rendered into markup doesn't flash on hydration.
*/
export function useParams<
T extends Record = Record,
>(): Readonly {
return useContext(RouterContext).params as Readonly
}
/**
* The route's typed, validated search params: the SAME value the loader received as `ctx.search`.
* SSR-correct: `compose` provides it from `searchOf(searchSchema, url.search)` server-side and from the
* identical derivation on the client mount, so a value rendered from it doesn't flash on hydration.
* Hostile input already failed closed to the schema's defaults at match time, so a component never
* parses `window.location.search` or guards against a bad query.
*
* Pass the route's `searchSchema` as the type argument to get its output type; bare, it's the raw parsed
* query (`Record`), which is also what a route without a `searchSchema` yields.
*
* ```tsx
* export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
* const { page } = useSearch() // page: number
* ```
*/
export function useSearch<
Schema extends StandardSchemaV1 | undefined = undefined,
>(): Schema extends StandardSchemaV1 ? InferOutput : Record {
return useContext(RouterContext).search as Schema extends StandardSchemaV1
? InferOutput
: Record
}
/** The parsed current location. `hash` is always `""` - the fragment is client-only and never reaches
* the router state / server, so exposing a live hash would hydration-mismatch; read `window.location.hash`
* directly (in an effect) if you truly need it. */
export interface Location {
readonly pathname: string
readonly search: string
readonly hash: string
}
/** The current {@link Location} (`pathname`/`search`/`hash`), derived from the router context. */
export function useLocation(): Location {
const { path } = useContext(RouterContext)
return useMemo(() => {
const { pathname, search } = splitPath(path)
return { pathname, search, hash: "" }
}, [path])
}
/** The current navigation state, mirroring the Remix `useNavigation()` shape for familiarity. */
export interface Navigation {
/** True while a client navigation (or revalidation) is in flight. The current route stays mounted
* until the new one is ready, so this drives a loading indicator, not a route swap. */
readonly pending: boolean
/** `"loading"` while a navigation is in flight, else `"idle"`. */
readonly state: "idle" | "loading"
/** The `pathname + search` being navigated TO while `pending`; `undefined` when idle or during a
* same-route revalidation. Mirrors Remix's `useNavigation().location`. */
readonly location: string | undefined
}
/**
* Observe client navigation to drive loading UI (a top-bar spinner, dimmed content, a skeleton). nifra
* navigates imperatively - it fetches the next route's chunk + loader data while the current route stays
* on screen, then swaps - so `pending` is the signal for "a transition is in flight," not a Suspense
* boundary. Always `{ pending: false, state: "idle" }` on the server (loaders block before render), so
* it is hydration-safe.
*
* ```tsx
* const { pending } = useNavigation()
* return