@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.
- package/README.md +3 -3
- package/dist/await.d.ts +2 -2
- package/dist/await.js +2 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -6
- package/dist/client.js.map +1 -1
- package/dist/compose.d.ts +4 -3
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +12 -5
- package/dist/compose.js.map +1 -1
- package/dist/content.d.ts +2 -2
- package/dist/content.js +2 -2
- package/dist/error.d.ts +1 -1
- package/dist/error.js +3 -3
- package/dist/fetcher.d.ts +2 -2
- package/dist/fetcher.js +4 -4
- package/dist/fn.d.ts +29 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +31 -0
- package/dist/fn.js.map +1 -0
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -1
- package/dist/image.d.ts +3 -3
- package/dist/image.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/island.d.ts +3 -3
- package/dist/island.js +2 -2
- package/dist/query.d.ts +8 -8
- package/dist/query.js +5 -5
- package/dist/react-dom-server.d.ts +28 -7
- package/dist/react-dom-server.d.ts.map +1 -1
- package/dist/react-dom-server.js +69 -8
- package/dist/react-dom-server.js.map +1 -1
- package/dist/router.d.ts +66 -18
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +80 -15
- package/dist/router.js.map +1 -1
- package/package.json +11 -6
- package/src/await.ts +2 -2
- package/src/client.ts +13 -7
- package/src/compose.ts +17 -5
- package/src/content.ts +2 -2
- package/src/error.ts +3 -3
- package/src/fetcher.ts +4 -4
- package/src/fn.ts +43 -0
- package/src/i18n.ts +1 -1
- package/src/image.ts +3 -3
- package/src/index.ts +7 -7
- package/src/island.ts +3 -3
- package/src/query.ts +9 -9
- package/src/react-dom-server.ts +83 -10
- package/src/router.ts +114 -24
package/src/router.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web-react/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`)
|
|
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
|
-
|
|
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
|
|
48
|
+
/** The current URL's `pathname + search` (no hash - the router never carries one). */
|
|
33
49
|
readonly path: string
|
|
34
|
-
/**
|
|
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
|
|
47
|
-
* nifra route tree
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
*/
|