@nifrajs/web-react 2.2.0 → 2.3.0

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.
Files changed (52) hide show
  1. package/README.md +3 -3
  2. package/dist/await.d.ts +2 -2
  3. package/dist/await.js +2 -2
  4. package/dist/client.d.ts +2 -2
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +13 -6
  7. package/dist/client.js.map +1 -1
  8. package/dist/compose.d.ts +4 -3
  9. package/dist/compose.d.ts.map +1 -1
  10. package/dist/compose.js +8 -4
  11. package/dist/compose.js.map +1 -1
  12. package/dist/content.d.ts +2 -2
  13. package/dist/content.js +2 -2
  14. package/dist/error.d.ts +1 -1
  15. package/dist/error.js +3 -3
  16. package/dist/fetcher.d.ts +2 -2
  17. package/dist/fetcher.js +4 -4
  18. package/dist/fn.d.ts +29 -0
  19. package/dist/fn.d.ts.map +1 -0
  20. package/dist/fn.js +31 -0
  21. package/dist/fn.js.map +1 -0
  22. package/dist/i18n.d.ts +1 -1
  23. package/dist/i18n.js +1 -1
  24. package/dist/image.d.ts +3 -3
  25. package/dist/image.js +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +7 -7
  28. package/dist/island.d.ts +3 -3
  29. package/dist/island.js +2 -2
  30. package/dist/query.d.ts +8 -8
  31. package/dist/query.js +5 -5
  32. package/dist/react-dom-server.d.ts +7 -7
  33. package/dist/react-dom-server.js +7 -7
  34. package/dist/router.d.ts +66 -18
  35. package/dist/router.d.ts.map +1 -1
  36. package/dist/router.js +80 -15
  37. package/dist/router.js.map +1 -1
  38. package/package.json +11 -6
  39. package/src/await.ts +2 -2
  40. package/src/client.ts +13 -7
  41. package/src/compose.ts +9 -4
  42. package/src/content.ts +2 -2
  43. package/src/error.ts +3 -3
  44. package/src/fetcher.ts +4 -4
  45. package/src/fn.ts +43 -0
  46. package/src/i18n.ts +1 -1
  47. package/src/image.ts +3 -3
  48. package/src/index.ts +7 -7
  49. package/src/island.ts +3 -3
  50. package/src/query.ts +9 -9
  51. package/src/react-dom-server.ts +9 -9
  52. package/src/router.ts +114 -24
package/src/compose.ts CHANGED
@@ -5,22 +5,26 @@ import { RouterContext } from "./router.ts"
5
5
  // Stable empty params so the provider value's `params` has a fixed reference when a route has none.
6
6
  const EMPTY_PARAMS: Readonly<Record<string, string>> = Object.freeze({})
7
7
 
8
+ // Stable empty search for the same reason: a render with no search context (or a non-router usage).
9
+ const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
10
+
8
11
  /**
9
12
  * Fold a layout chain (outermost layout → page) into a single React tree: the page
10
13
  * (innermost) receives `props` (the loader data); each layout wraps the child via its
11
14
  * `children`. Shared by the server adapter (renderToString) and the client (hydrateRoot).
12
15
  *
13
16
  * The whole tree is wrapped in a {@link RouterContext} provider carrying the matched `params`, the
14
- * current `path`, and the client `pending` flag (all threaded through `RenderProps` identically on SSR
15
- * and client), so the routing hooks (`useParams`/`useLocation`/`useNavigation`) read the same value on
16
- * both sides - no hydration mismatch (`pending` is `false` on SSR and on the initial client render).
17
+ * current `path`, the validated `search`, and the client `pending` flag (all threaded through
18
+ * `RenderProps` identically on SSR and client), so the routing hooks
19
+ * (`useParams`/`useLocation`/`useSearch`/`useNavigation`) read the same value on both sides - no
20
+ * hydration mismatch (`pending` is `false` on SSR and on the initial client render).
17
21
  * A render with no routing fields (a non-router adapter usage) provides the empty default.
18
22
  */
19
23
  export function compose(chain: readonly unknown[], props: RenderProps): ReactNode {
20
24
  const last = chain.length - 1
21
25
  let node: ReactNode = createElement(chain[last] as FunctionComponent<RenderProps>, props)
22
26
  for (let i = last - 1; i >= 0; i--) {
23
- // children passed as the 3rd arg (not a `children` prop) — React's canonical form.
27
+ // children passed as the 3rd arg (not a `children` prop) - React's canonical form.
24
28
  // Each layout receives its own loader data at its own index. Layouts are the chain's leading
25
29
  // prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
26
30
  // boundary marker, the page) reads `undefined` and is unaffected.
@@ -36,6 +40,7 @@ export function compose(chain: readonly unknown[], props: RenderProps): ReactNod
36
40
  value: {
37
41
  params: props.params ?? EMPTY_PARAMS,
38
42
  path: props.path ?? "",
43
+ search: props.search ?? EMPTY_SEARCH,
39
44
  pending: props.pending ?? false,
40
45
  pendingPath: props.pendingPath,
41
46
  },
package/src/content.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `@nifrajs/web-react/content` — render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
2
+ * `@nifrajs/web-react/content` - render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
3
3
  * `.html`, or any Markdown→HTML string) into the DOM via `dangerouslySetInnerHTML`. Pairs with content
4
4
  * collections: `<Content html={entry.html} />`.
5
5
  *
6
6
  * Security: this injects raw HTML, so the `html` MUST be trusted (your own Markdown/content, rendered
7
- * at build/server time) — never pass user-supplied HTML without sanitizing it first.
7
+ * at build/server time) - never pass user-supplied HTML without sanitizing it first.
8
8
  */
9
9
  import {
10
10
  type ComponentPropsWithoutRef,
package/src/error.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `errorBoundary` — the React error-boundary chain element for nifra's `_error.tsx`. Kept in its own
2
+ * `errorBoundary` - the React error-boundary chain element for nifra's `_error.tsx`. Kept in its own
3
3
  * module (imports only `react`, never `react-dom/client`) so it's unit-testable off the DOM and the
4
4
  * client codegen can import it from `@nifrajs/web-react/client` (which re-exports it).
5
5
  */
@@ -9,7 +9,7 @@ import { Component, createElement, type FunctionComponent, type ReactNode } from
9
9
  * Build an error-boundary chain element bound to `fallback` (a route's `_error` component). nifra's
10
10
  * client codegen inserts it before the page in the matched chain; a render error in the subtree renders
11
11
  * `fallback` with `{ data: { name, message } }` instead of crashing the app. DOM-transparent (it renders
12
- * its children directly — no wrapper element), so it adds no markup and never disturbs hydration.
12
+ * its children directly - no wrapper element), so it adds no markup and never disturbs hydration.
13
13
  */
14
14
  export function errorBoundary(fallback: unknown): unknown {
15
15
  type Props = { children?: ReactNode }
@@ -25,7 +25,7 @@ export function errorBoundary(fallback: unknown): unknown {
25
25
  override render(): ReactNode {
26
26
  const { error } = this.state
27
27
  if (error === null) return this.props.children
28
- // `fallback` is the route's `_error` default export — opaque to the core, a component here.
28
+ // `fallback` is the route's `_error` default export - opaque to the core, a component here.
29
29
  const Fallback = fallback as FunctionComponent<{ data: { name: string; message: string } }>
30
30
  return createElement(Fallback, { data: { name: error.name, message: error.message } })
31
31
  }
package/src/fetcher.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
2
2
  /**
3
- * `@nifrajs/web-react/fetcher` — React bindings for concurrent fetchers. `useFetcher(key)` subscribes a
3
+ * `@nifrajs/web-react/fetcher` - React bindings for concurrent fetchers. `useFetcher(key)` subscribes a
4
4
  * component to an independent {@link Fetcher} (via `useSyncExternalStore`) and returns its reactive
5
5
  * state plus `load`/`submit`; `useFetchers()` subscribes to the whole live collection. Imports only
6
6
  * `react` (never `react-dom/*`), so route components can use it on the server *and* client without
7
7
  * dragging either DOM build into the wrong bundle. No JSX (the package builds with plain `tsc`).
8
8
  *
9
- * The router that owns the fetchers is the one `mountRouter` hydrated — it registers itself here via
9
+ * The router that owns the fetchers is the one `mountRouter` hydrated - it registers itself here via
10
10
  * `setMountedRouter`. On the server (no mount) there is no router, so the hooks return an idle state
11
11
  * (fetchers are client-only); the first client render after `mountRouter` sees the real fetcher, so
12
12
  * there's no hydration mismatch.
@@ -17,7 +17,7 @@ import { useSyncExternalStore } from "react"
17
17
  // page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
18
18
  let mountedRouter: ClientRouter | undefined
19
19
 
20
- /** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
20
+ /** Register (or clear) the router that owns fetchers - called by `mountRouter`. Not for app use. */
21
21
  export function setMountedRouter(router: ClientRouter | undefined): void {
22
22
  mountedRouter = router
23
23
  }
@@ -53,7 +53,7 @@ export function useFetcher(key: string): FetcherHandle {
53
53
  }
54
54
 
55
55
  /**
56
- * Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
56
+ * Subscribe to the whole live fetcher collection - for a global busy view (e.g. "3 saving…"). Read
57
57
  * each entry's `.snapshot()` for its state. Re-renders whenever any fetcher transitions or a new one
58
58
  * is created.
59
59
  */
package/src/fn.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@nifrajs/web-react/fn` - React binding for a server function's pending/error state.
3
+ *
4
+ * Calling a server function needs no hook: the client stub is `(input) => Promise<Output>`. This adds
5
+ * the part a component usually wants around it - in flight, last result, last error - and nothing more.
6
+ *
7
+ * The state machine is `@nifrajs/web`'s `createServerFnStore`, shared with every other adapter, so
8
+ * "is it pending" has one answer rather than five that drift. This file contributes only the `useSyncExternalStore` subscription.
9
+ */
10
+
11
+ import type { ClientServerFn, ServerFnReference } from "@nifrajs/web/fn"
12
+ import { createServerFnStore, type ServerFnState } from "@nifrajs/web/fn-state"
13
+ import { useMemo, useRef, useSyncExternalStore } from "react"
14
+
15
+ /** A server function's state plus the call itself. */
16
+ export interface ServerFnHandle<Input, Output> extends ServerFnState<Output> {
17
+ /** Invoke it. Records state, and rejects on failure - attach `.catch` if you only render state. */
18
+ readonly call: (input: Input) => Promise<Output>
19
+ /** Back to idle. */
20
+ readonly reset: () => void
21
+ }
22
+
23
+ /**
24
+ * Track one server function's call state.
25
+ *
26
+ * const addTodo = useServerFn(fns.addTodo)
27
+ * <button disabled={addTodo.pending} onClick={() => addTodo.call({ text }).catch(() => {})}>
28
+ *
29
+ * The store is per component instance and created once; `fn` is read on the first render only, so a
30
+ * freshly-created inline function neither resets the state nor goes stale.
31
+ */
32
+ export function useServerFn<Input, Output>(
33
+ fn: ServerFnReference<Input, Output>,
34
+ ): ServerFnHandle<Input, Output> {
35
+ // The store is created once, but always calls the CURRENT `fn`. Capturing `fn` directly would
36
+ // either recreate the store on every render (losing the state) or hold the first render's closure
37
+ // (calling a stale one); the ref is what makes "created once" and "never stale" both true.
38
+ const latest = useRef(fn as ClientServerFn<Input, Output>)
39
+ latest.current = fn as ClientServerFn<Input, Output>
40
+ const store = useMemo(() => createServerFnStore((input: Input) => latest.current(input)), [])
41
+ const state = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot)
42
+ return { ...state, call: store.call, reset: store.reset }
43
+ }
package/src/i18n.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web-react/i18n` — React bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
2
+ * `@nifrajs/web-react/i18n` - React bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
3
3
  * `Formatter` (memoized) and provides it; `useT()` reads it. Both `locale` + `messages` are
4
4
  * serializable, so a loader returns them, the page renders with the negotiated catalog on the server,
5
5
  * and the client rebuilds the same formatter from the same props (no mismatch). Imports only `react` +
package/src/image.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web-react/image` — a CLS-safe responsive React `<Image>`. A thin wrapper over
2
+ * `@nifrajs/web-react/image` - a CLS-safe responsive React `<Image>`. A thin wrapper over
3
3
  * `resolveImage` from `@nifrajs/image`: it computes `src`/`srcSet`/`sizes`/`width`/`height`/`loading`/
4
4
  * `decoding`/`fetchpriority` and renders a plain `<img>`, forwarding any extra DOM props
5
5
  * (`className`, `style`, `id`, event handlers, `data-*`, …). Resizing is delegated to the `loader`
@@ -8,7 +8,7 @@
8
8
  import { type ImageLoader, type ImageProps, resolveImage } from "@nifrajs/image"
9
9
  import { type ComponentPropsWithoutRef, createElement, type ReactElement } from "react"
10
10
 
11
- /** Attributes `resolveImage` computes — callers can't override them via DOM passthrough. */
11
+ /** Attributes `resolveImage` computes - callers can't override them via DOM passthrough. */
12
12
  type ComputedAttrs =
13
13
  | "src"
14
14
  | "srcSet"
@@ -23,7 +23,7 @@ type ComputedAttrs =
23
23
  export interface ImageComponentProps
24
24
  extends ImageProps,
25
25
  Omit<ComponentPropsWithoutRef<"img">, ComputedAttrs | keyof ImageProps> {
26
- /** CDN URL builder. Defaults to the identity loader (no transform — still CLS-safe + lazy). */
26
+ /** CDN URL builder. Defaults to the identity loader (no transform - still CLS-safe + lazy). */
27
27
  readonly loader?: ImageLoader
28
28
  /** `data-*` attributes pass through to the `<img>` (React doesn't auto-allow these on a custom
29
29
  * component, so they're declared explicitly). */
package/src/index.ts CHANGED
@@ -1,21 +1,21 @@
1
1
  import type { RenderAdapter } from "@nifrajs/web"
2
2
  /**
3
- * @nifrajs/web-react — the React render adapter for @nifrajs/web (server side). Streaming SSR via
3
+ * @nifrajs/web-react - the React render adapter for @nifrajs/web (server side). Streaming SSR via
4
4
  * `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
5
- * `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
5
+ * `@nifrajs/web-react/client`. React's JSX is Bun-native - no build plugin needed.
6
6
  */
7
7
  import { compose } from "./compose.ts"
8
8
  import { reactDomServer } from "./react-dom-server.ts"
9
9
 
10
- /** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
10
+ /** The React server render adapter - pass to @nifrajs/web's `renderPage`. */
11
11
  export const reactAdapter: RenderAdapter = {
12
12
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). React's
13
13
  // `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
14
- // content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
14
+ // content and is markedly cheaper than `renderToReadableStream` - most visibly on Bun, where Fizz's
15
15
  // streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
16
16
  //
17
17
  // Async because the `react-dom/server` module is resolved lazily (from the consumer app's node_modules
18
- // under Bun runtime SSR — see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
18
+ // under Bun runtime SSR - see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
19
19
  // renderPage awaits it.
20
20
  async renderToString(chain, props) {
21
21
  const { renderToString } = await reactDomServer()
@@ -23,13 +23,13 @@ export const reactAdapter: RenderAdapter = {
23
23
  },
24
24
  async renderToStream(chain, props) {
25
25
  // Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
26
- // boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
26
+ // boundaries stream as they resolve. No `bootstrapModules` - nifra injects the client entry in
27
27
  // the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
28
28
  const { renderToReadableStream } = await reactDomServer()
29
29
  return renderToReadableStream(compose(chain, props))
30
30
  },
31
31
  // React reconciles against the existing DOM on hydrate, so no per-document bootstrap
32
- // script is needed (contrast Solid's generateHydrationScript) — the seam allows both.
32
+ // script is needed (contrast Solid's generateHydrationScript) - the seam allows both.
33
33
  hydrationHead() {
34
34
  return ""
35
35
  },
package/src/island.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { IslandStrategy } from "@nifrajs/web/islands"
2
2
  /**
3
- * `@nifrajs/web-react/island` — the `<Island>` marker for no-framework islands. Server-renders its
3
+ * `@nifrajs/web-react/island` - the `<Island>` marker for no-framework islands. Server-renders its
4
4
  * children inside a `<nifra-island>` element that the framework-agnostic `mountIslands` runtime
5
- * (`@nifrajs/web/islands`) picks up on the client. Imports only `react` (+ a type) — no `react-dom`,
5
+ * (`@nifrajs/web/islands`) picks up on the client. Imports only `react` (+ a type) - no `react-dom`,
6
6
  * no JSX (the package builds with plain `tsc`).
7
7
  *
8
8
  * Use on a route with `export const hydrate = false`: the page ships zero framework JS, and only the
@@ -18,7 +18,7 @@ export interface IslandProps {
18
18
  readonly props?: unknown
19
19
  /** When the enhancer runs (default `load`). `visible` (IntersectionObserver) wins TTI on long pages. */
20
20
  readonly strategy?: IslandStrategy
21
- /** The island's server-rendered content — real HTML, present with JS off. */
21
+ /** The island's server-rendered content - real HTML, present with JS off. */
22
22
  readonly children?: ReactNode
23
23
  }
24
24
 
package/src/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web-react/query` — React bindings for the keyed query-cache + mutations: `useQuery`,
2
+ * `@nifrajs/web-react/query` - React bindings for the keyed query-cache + mutations: `useQuery`,
3
3
  * `useInfiniteQuery`, `useMutation`, `useQueryClient`, `QueryClientProvider`, and the SSR
4
4
  * `HydrationBoundary` (+ `dehydrate` re-exported). A drop-in for the TanStack Query surface,
5
5
  * backed by `@nifrajs/web`'s agnostic engine. Imports only `react` + `@nifrajs/web` (never `react-dom/*`),
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Resolution order for the client a hook uses: a `QueryClientProvider` in the tree (required for SSR
9
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
10
+ * client-only app - the `typeof window` guard means the server has none, so hooks render idle/pending
11
11
  * and the first client render matches for a clean hydration).
12
12
  */
13
13
  import {
@@ -61,7 +61,7 @@ const NOOP_CLIENT: QueryClient = {
61
61
 
62
62
  const QueryClientContext = createContext<QueryClient | undefined>(undefined)
63
63
 
64
- /** Provide a {@link QueryClient} to the tree — required for SSR dehydrate/hydrate and for tests; a
64
+ /** Provide a {@link QueryClient} to the tree - required for SSR dehydrate/hydrate and for tests; a
65
65
  * client-only app can omit it and rely on the built-in client-side singleton. */
66
66
  export function QueryClientProvider(props: {
67
67
  readonly client: QueryClient
@@ -113,13 +113,13 @@ const IDLE_INFINITE_HANDLE = {
113
113
 
114
114
  /** Options for {@link useQuery}. */
115
115
  export interface UseQueryOptions extends QueryOptions {
116
- /** When `false`, don't fetch (the query stays idle) — for dependent queries. Default `true`. */
116
+ /** When `false`, don't fetch (the query stays idle) - for dependent queries. Default `true`. */
117
117
  readonly enabled?: boolean
118
118
  }
119
119
 
120
120
  /** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */
121
121
  export interface UseQueryResult<T> extends QueryState<T> {
122
- /** `status === "pending"` — no data yet (initial load). */
122
+ /** `status === "pending"` - no data yet (initial load). */
123
123
  readonly isPending: boolean
124
124
  /** `status === "error"`. */
125
125
  readonly isError: boolean
@@ -148,7 +148,7 @@ export function useQuery<T>(
148
148
  // A disabled query still binds a handle (so it re-renders when re-enabled), but never fetches.
149
149
  const handle: QueryHandle<T> = client.query<T>(key, fn, queryOpts)
150
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
151
+ // but WITH a hydrated provider client it returns the server-seeded data - so a HydrationBoundary-fed
152
152
  // query renders its data during SSR and the first client render matches (no loading flash, no drift).
153
153
  const state = useSyncExternalStore<QueryState<T>>(
154
154
  handle.subscribe,
@@ -175,7 +175,7 @@ export interface UseMutationResult<TData, TVariables> extends MutationState<TDat
175
175
  readonly isSuccess: boolean
176
176
  /** Fire-and-forget: runs the mutation and swallows rejection (read `error`/`isError` for failures). */
177
177
  readonly mutate: (variables: TVariables) => void
178
- /** Run the mutation and return the promise (rejects on failure) — for `await`. */
178
+ /** Run the mutation and return the promise (rejects on failure) - for `await`. */
179
179
  readonly mutateAsync: (variables: TVariables) => Promise<TData>
180
180
  /** Reset back to idle. */
181
181
  readonly reset: () => void
@@ -210,7 +210,7 @@ export function useMutation<TData, TVariables = void>(
210
210
  }
211
211
  }
212
212
 
213
- /** Options for {@link useInfiniteQuery} — the engine's {@link InfiniteQueryOptions} plus `enabled`. */
213
+ /** Options for {@link useInfiniteQuery} - the engine's {@link InfiniteQueryOptions} plus `enabled`. */
214
214
  export interface UseInfiniteQueryOptions<T, P> extends InfiniteQueryOptions<T, P> {
215
215
  readonly enabled?: boolean
216
216
  }
@@ -258,7 +258,7 @@ export function useInfiniteQuery<T, P>(
258
258
  }
259
259
 
260
260
  /**
261
- * Seed the context's {@link QueryClient} from a server {@link dehydrate} snapshot — the SSR data bridge.
261
+ * Seed the context's {@link QueryClient} from a server {@link dehydrate} snapshot - the SSR data bridge.
262
262
  * Wrap the app (inside `QueryClientProvider`) so server-prefetched queries are in the cache before the
263
263
  * first client render, avoiding a loading flash. Hydration runs during render (idempotent, fresher-wins),
264
264
  * so the data is available synchronously to child `useQuery`s.
@@ -1,23 +1,23 @@
1
1
  /**
2
- * Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
2
+ * Resolve `react-dom/server` so it shares ONE React core with the route components - the fix for the
3
3
  * dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
4
4
  * of React and the renderer").
5
5
  *
6
6
  * WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
7
7
  * a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
8
- * (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
8
+ * (symlinked) node_modules - which can be a DIFFERENT physical `react` than the one the consumer app's
9
9
  * route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
10
10
  * (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
11
11
  * react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
12
- * dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
12
+ * dispatcher, no crash. (Empirically verified against a two-copy install fixture - see
13
13
  * test/dual-react.test.ts.)
14
14
  *
15
- * Guarding precisely so the BUILT path is untouched: a bundle is detected two ways — `Bun.resolveSync` is
15
+ * Guarding precisely so the BUILT path is untouched: a bundle is detected two ways - `Bun.resolveSync` is
16
16
  * unavailable (Node / Deno / Cloudflare / Vercel), OR `buildServer` tagged the output with
17
17
  * `process.env.NIFRA_SSR_BUNDLED` (a `target:"bun"` bundle DOES keep `Bun.resolveSync` under the Bun
18
18
  * runtime, so the resolver test alone can't see it). In either case the build already bundled+deduped a
19
19
  * single `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct.
20
- * Re-rooting a bundle would instead re-import a SECOND react-dom from disk — a second React core whose hook
20
+ * Re-rooting a bundle would instead re-import a SECOND react-dom from disk - a second React core whose hook
21
21
  * dispatcher is null for the bundled components → the `…H.useRef of null` SSR crash. The app-root re-root
22
22
  * therefore runs ONLY under an UNBUNDLED Bun runtime (nifra dev/start, nifra_render), where the duplication
23
23
  * can occur, `Bun.resolveSync` exists, and no bundle marker is present.
@@ -26,7 +26,7 @@
26
26
  import type { ReactNode } from "react"
27
27
 
28
28
  /** The slice of `react-dom/server` this adapter uses. Typed locally so the dynamic import (which Bun
29
- * resolves to an absolute path string) stays strict — no `any` crosses the boundary. */
29
+ * resolves to an absolute path string) stays strict - no `any` crosses the boundary. */
30
30
  export interface ReactDomServer {
31
31
  renderToString(node: ReactNode): string
32
32
  renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>
@@ -51,7 +51,7 @@ function runtimeRealpath(path: string): string {
51
51
 
52
52
  // `globalThis.Bun` isn't in the ambient lib types; narrow exactly the one method we need so we never
53
53
  // reach for `any`. `resolveSync(specifier, from)` returns the absolute path the specifier resolves to
54
- // when required from `from` — Bun's runtime resolver, the only lever that re-roots a BARE specifier
54
+ // when required from `from` - Bun's runtime resolver, the only lever that re-roots a BARE specifier
55
55
  // (a runtime `Bun.plugin` onResolve does NOT fire for bare specifiers like `react-dom/server`, verified).
56
56
  interface BunResolver {
57
57
  resolveSync(specifier: string, from: string): string
@@ -150,7 +150,7 @@ export async function loadReactDomServer(
150
150
  // Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
151
151
  // is a PEER dependency of this adapter, so a correct install puts it at the app root and this
152
152
  // resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
153
- // rather than crashing — degraded (possible duplicate) but never a hard failure.
153
+ // rather than crashing - degraded (possible duplicate) but never a hard failure.
154
154
  try {
155
155
  const resolved = resolve("react-dom/server", appRoot())
156
156
  // Before returning the module, verify the react-dom we just re-rooted shares the components' React.
@@ -173,7 +173,7 @@ export async function loadReactDomServer(
173
173
  }
174
174
 
175
175
  /**
176
- * The resolver `loadReactDomServer` uses by default, or `undefined` when re-rooting must NOT happen — a
176
+ * The resolver `loadReactDomServer` uses by default, or `undefined` when re-rooting must NOT happen - a
177
177
  * non-Bun host (no `Bun.resolveSync`; the static import is the only path) OR a BUNDLED SSR output.
178
178
  * `buildServer` defines `process.env.NIFRA_SSR_BUNDLED` to `"1"` in every bundle, where react-dom is
179
179
  * already inlined + deduped to the components' React (reactDedupePlugin); re-rooting there would re-import
package/src/router.ts CHANGED
@@ -1,16 +1,28 @@
1
1
  /**
2
- * `@nifrajs/web-react/router` — React routing bindings over the agnostic `@nifrajs/web` router:
2
+ * `@nifrajs/web-react/router` - React routing bindings over the agnostic `@nifrajs/web` router:
3
3
  * `<Link>`/`<NavLink>`, `useNavigate`, `useParams`, `useLocation`, `useSearchParams`, and `<Navigate>`.
4
4
  *
5
5
  * These read the current route from a {@link RouterContext} that `compose` provides on BOTH the SSR
6
6
  * render and the client mount (seeded from the request match / router state respectively), so the read
7
7
  * hooks (`useParams`/`useLocation`/`useSearchParams`) are SSR-correct and hydrate with no mismatch.
8
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
9
+ * `installHistory`) - so this module imports only `react` (never `react-dom/*`), and a route component
10
10
  * can use these on the server and the client without dragging a DOM build into the wrong bundle. No JSX
11
11
  * (the package builds with plain `tsc`), so everything is `createElement`.
12
12
  */
13
- import { getBrowserNavigate, type NavigateOptions } from "@nifrajs/web"
13
+
14
+ import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
15
+ import {
16
+ type Blocker,
17
+ type BlockerFunction,
18
+ getBrowserNavigate,
19
+ IDLE_BLOCKER,
20
+ type NavigateFunction,
21
+ type NavigateOptions,
22
+ type NavigateTargetInput,
23
+ registerBlocker,
24
+ resolveNavigate,
25
+ } from "@nifrajs/web"
14
26
  import {
15
27
  type AnchorHTMLAttributes,
16
28
  type CSSProperties,
@@ -23,15 +35,23 @@ import {
23
35
  useContext,
24
36
  useEffect,
25
37
  useMemo,
38
+ useRef,
39
+ useState,
26
40
  } from "react"
27
41
 
42
+ export type { Blocker, BlockerFunction, BlockerState, NavigateFunction } from "@nifrajs/web"
43
+
28
44
  /** The current route the routing hooks read. Provided by `compose` on SSR + client mount alike. */
29
45
  export interface RouterContextValue {
30
46
  /** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). */
31
47
  readonly params: Readonly<Record<string, string>>
32
- /** The current URL's `pathname + search` (no hash — the router never carries one). */
48
+ /** The current URL's `pathname + search` (no hash - the router never carries one). */
33
49
  readonly path: string
34
- /** True while a client navigation (or revalidation) is in flight — the current route stays mounted
50
+ /** The route's typed, validated search params (the loader's `ctx.search`), derived from the URL via
51
+ * the shared `searchOf` on SSR + client alike, read by {@link useSearch}. `{}` when the route
52
+ * declares no `searchSchema` (then it is the raw parsed query) or outside a nifra route tree. */
53
+ readonly search: Record<string, unknown>
54
+ /** True while a client navigation (or revalidation) is in flight - the current route stays mounted
35
55
  * until the new one is ready. Always `false` on SSR (loaders block before render). Drives loading UI
36
56
  * via {@link useNavigation}. */
37
57
  readonly pending: boolean
@@ -40,14 +60,17 @@ export interface RouterContextValue {
40
60
  readonly pendingPath?: string | undefined
41
61
  }
42
62
 
43
- // Frozen empty params so the default context value has a stable reference (no needless re-renders).
63
+ // Frozen empty params/search so the default context value has a stable reference (no needless re-renders).
44
64
  const EMPTY_PARAMS: Readonly<Record<string, string>> = Object.freeze({})
65
+ const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
45
66
 
46
- /** Router context. The default ({} params, "" path) is what a component sees when rendered outside a
47
- * nifra route tree — the hooks stay defined (no throw) so a stray `useParams` degrades gracefully. */
67
+ /** Router context. The default ({} params, "" path, {} search) is what a component sees when rendered
68
+ * outside a nifra route tree (the hooks stay defined, no throw, so a stray `useParams` degrades
69
+ * gracefully). */
48
70
  export const RouterContext = createContext<RouterContextValue>({
49
71
  params: EMPTY_PARAMS,
50
72
  path: "",
73
+ search: EMPTY_SEARCH,
51
74
  pending: false,
52
75
  })
53
76
 
@@ -61,7 +84,7 @@ function splitPath(path: string): { readonly pathname: string; readonly search:
61
84
  }
62
85
 
63
86
  /**
64
- * The matched route's decoded path params — `/users/:id` on `/users/7` → `{ id: "7" }`. SSR-correct:
87
+ * The matched route's decoded path params - `/users/:id` on `/users/7` → `{ id: "7" }`. SSR-correct:
65
88
  * `compose` provides the same value server-side (from the request match) and client-side (from router
66
89
  * state), so a param rendered into markup doesn't flash on hydration.
67
90
  */
@@ -71,7 +94,30 @@ export function useParams<
71
94
  return useContext(RouterContext).params as Readonly<T>
72
95
  }
73
96
 
74
- /** The parsed current location. `hash` is always `""` — the fragment is client-only and never reaches
97
+ /**
98
+ * The route's typed, validated search params: the SAME value the loader received as `ctx.search`.
99
+ * SSR-correct: `compose` provides it from `searchOf(searchSchema, url.search)` server-side and from the
100
+ * identical derivation on the client mount, so a value rendered from it doesn't flash on hydration.
101
+ * Hostile input already failed closed to the schema's defaults at match time, so a component never
102
+ * parses `window.location.search` or guards against a bad query.
103
+ *
104
+ * Pass the route's `searchSchema` as the type argument to get its output type; bare, it's the raw parsed
105
+ * query (`Record<string, unknown>`), which is also what a route without a `searchSchema` yields.
106
+ *
107
+ * ```tsx
108
+ * export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
109
+ * const { page } = useSearch<typeof searchSchema>() // page: number
110
+ * ```
111
+ */
112
+ export function useSearch<
113
+ Schema extends StandardSchemaV1 | undefined = undefined,
114
+ >(): Schema extends StandardSchemaV1 ? InferOutput<Schema> : Record<string, unknown> {
115
+ return useContext(RouterContext).search as Schema extends StandardSchemaV1
116
+ ? InferOutput<Schema>
117
+ : Record<string, unknown>
118
+ }
119
+
120
+ /** The parsed current location. `hash` is always `""` - the fragment is client-only and never reaches
75
121
  * the router state / server, so exposing a live hash would hydration-mismatch; read `window.location.hash`
76
122
  * directly (in an effect) if you truly need it. */
77
123
  export interface Location {
@@ -130,18 +176,62 @@ export function usePending(): boolean {
130
176
  return useContext(RouterContext).pending
131
177
  }
132
178
 
133
- /** A programmatic navigate: a string path (push, or replace via `{ replace: true }`) or a history delta
134
- * (`-1`/`1`). A no-op on the server / before hydration (a render-time navigate isn't valid — use
135
- * {@link Navigate}, which navigates in an effect). */
136
- export type NavigateFunction = (to: string | number, options?: NavigateOptions) => void
137
-
138
- /** Get the {@link NavigateFunction}. Stable across renders; resolves the browser navigate at call time
139
- * (so it works as soon as `installHistory` has run, and no-ops before then / on the server). */
179
+ /** Get the {@link NavigateFunction} (a string path, a history delta, or a typed `{ to, search }` object;
180
+ * a render-time navigate isn't valid - use {@link Navigate}, which navigates in an effect). Stable across
181
+ * renders; resolves the browser navigate at call time (so it works as soon as `installHistory` has run,
182
+ * and no-ops before then / on the server). */
140
183
  export function useNavigate(): NavigateFunction {
141
- return useCallback((to, options) => {
184
+ return useCallback((to: string | number | NavigateTargetInput, options?: NavigateOptions) => {
142
185
  const navigate = getBrowserNavigate()
143
- if (navigate !== undefined) navigate(to, options)
144
- }, [])
186
+ if (navigate === undefined) return
187
+ const resolved = resolveNavigate(to, options)
188
+ navigate(resolved.to, resolved.options)
189
+ }, []) as NavigateFunction
190
+ }
191
+
192
+ /**
193
+ * Guard navigation away from a page with unsaved work, confirming with your OWN async UI. Mirrors
194
+ * react-router's `useBlocker`: pass a boolean (`useBlocker(isDirty)`) or a predicate
195
+ * `({ currentLocation, nextLocation }) => boolean`, and get back a {@link Blocker}. When a navigation
196
+ * (a `<Link>`/anchor click, `useNavigate`, or a browser back/forward) is intercepted, `blocker.state`
197
+ * becomes `"blocked"` and `proceed`/`reset` go live - render a dialog and call `proceed()` to continue
198
+ * or `reset()` to stay put. It also arms the browser's native "Leave site?" prompt on tab close / reload.
199
+ * Idle (never blocks, `proceed`/`reset` are `undefined`) on the server and before hydration, so it's
200
+ * SSR-safe and hydration-stable.
201
+ *
202
+ * ```tsx
203
+ * const blocker = useBlocker(form.isDirty)
204
+ * return (
205
+ * <>
206
+ * <MyForm />
207
+ * {blocker.state === "blocked" && (
208
+ * <ConfirmDialog
209
+ * onCancel={blocker.reset}
210
+ * onConfirm={blocker.proceed}
211
+ * message="Discard unsaved changes?"
212
+ * />
213
+ * )}
214
+ * </>
215
+ * )
216
+ * ```
217
+ */
218
+ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {
219
+ const [blocker, setBlocker] = useState<Blocker>(IDLE_BLOCKER)
220
+ // Track the latest predicate in a ref (updated every render) so the single registration always sees
221
+ // current component state (the dirty flag) WITHOUT re-registering - re-registering mid-prompt drops it.
222
+ const shouldBlockRef = useRef(shouldBlock)
223
+ shouldBlockRef.current = shouldBlock
224
+ // Register once on mount; the returned unregister is the effect cleanup. A boolean reads as itself, a
225
+ // function is called. A no-op before `installHistory` has run (blocker stays idle → native navigation).
226
+ useEffect(
227
+ () =>
228
+ registerBlocker((args) => {
229
+ const current = shouldBlockRef.current
230
+ return typeof current === "function" ? current(args) : current
231
+ }, setBlocker),
232
+ [],
233
+ )
234
+ return blocker
145
235
  }
146
236
 
147
237
  /** The value forms `setSearchParams` accepts. */
@@ -185,7 +275,7 @@ export function useSearchParams(): readonly [URLSearchParams, SetSearchParams] {
185
275
  return [searchParams, setSearchParams] as const
186
276
  }
187
277
 
188
- /** A left-click with no modifier and no new-tab target — the only click a client router should
278
+ /** A left-click with no modifier and no new-tab target - the only click a client router should
189
279
  * intercept (Cmd/Ctrl/Shift/Alt or a `_blank` target means the user wants native behavior). */
190
280
  function isPlainLeftClick(event: MouseEvent, target: string | undefined): boolean {
191
281
  if (event.button !== 0) return false
@@ -205,7 +295,7 @@ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>,
205
295
  * A client-navigating anchor. Renders a real `<a href={to}>` (so it's a working link before hydration
206
296
  * and for right-click / open-in-new-tab), and on a plain left-click navigates through the router
207
297
  * instead of a full reload. Calling `navigate` + `preventDefault` here means `installHistory`'s
208
- * document-level click handler sees `defaultPrevented` and stands down — exactly one navigation.
298
+ * document-level click handler sees `defaultPrevented` and stands down - exactly one navigation.
209
299
  */
210
300
  export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
211
301
  { to, replace, onClick, target, ...rest },
@@ -232,7 +322,7 @@ export interface NavLinkRenderProps {
232
322
  readonly isPending: boolean
233
323
  }
234
324
 
235
- /** {@link NavLink} props — like {@link LinkProps}, but `className`/`style`/`children` may be functions
325
+ /** {@link NavLink} props - like {@link LinkProps}, but `className`/`style`/`children` may be functions
236
326
  * of the active state, and `end`/`caseSensitive` tune matching. */
237
327
  export interface NavLinkProps extends Omit<LinkProps, "className" | "style" | "children"> {
238
328
  /** Match the full path exactly instead of as a prefix (use for `to="/"` so it isn't always active). */
@@ -309,7 +399,7 @@ export interface NavigateProps {
309
399
  }
310
400
 
311
401
  /**
312
- * Declaratively navigate on mount — the component analogue of `useNavigate` (e.g. a guard that renders
402
+ * Declaratively navigate on mount - the component analogue of `useNavigate` (e.g. a guard that renders
313
403
  * `<Navigate to="/login" replace />`). Navigates in an effect, so it's a safe no-op during SSR (renders
314
404
  * `null`); the redirect happens once on the client after hydration.
315
405
  */