@nifrajs/web-react 2.1.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 (54) 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 +12 -5
  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 +28 -7
  33. package/dist/react-dom-server.d.ts.map +1 -1
  34. package/dist/react-dom-server.js +69 -8
  35. package/dist/react-dom-server.js.map +1 -1
  36. package/dist/router.d.ts +66 -18
  37. package/dist/router.d.ts.map +1 -1
  38. package/dist/router.js +80 -15
  39. package/dist/router.js.map +1 -1
  40. package/package.json +11 -6
  41. package/src/await.ts +2 -2
  42. package/src/client.ts +13 -7
  43. package/src/compose.ts +17 -5
  44. package/src/content.ts +2 -2
  45. package/src/error.ts +3 -3
  46. package/src/fetcher.ts +4 -4
  47. package/src/fn.ts +43 -0
  48. package/src/i18n.ts +1 -1
  49. package/src/image.ts +3 -3
  50. package/src/index.ts +7 -7
  51. package/src/island.ts +3 -3
  52. package/src/query.ts +9 -9
  53. package/src/react-dom-server.ts +83 -10
  54. package/src/router.ts +114 -24
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
  */