@nifrajs/web-react 2.2.0 → 2.4.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 +8 -4
- 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 +7 -7
- package/dist/react-dom-server.js +7 -7
- 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 +9 -4
- 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 +9 -9
- 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
|
|
15
|
-
* and client), so the routing hooks
|
|
16
|
-
*
|
|
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)
|
|
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`
|
|
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)
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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`
|
|
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)
|
|
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`
|
|
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)
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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)
|
|
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"`
|
|
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
|
|
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)
|
|
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}
|
|
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
|
|
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.
|
package/src/react-dom-server.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve `react-dom/server` so it shares ONE React core with the route components
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
*/
|