@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nifrajs/web-react",
3
- "version": "2.1.0",
4
- "description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
3
+ "version": "2.3.0",
4
+ "description": "React render adapter for @nifrajs/web - SSR + hydration (Bun-native JSX, no Babel).",
5
5
  "keywords": [
6
6
  "bun",
7
7
  "typescript",
@@ -65,6 +65,11 @@
65
65
  "bun": "./src/content.ts",
66
66
  "types": "./dist/content.d.ts",
67
67
  "default": "./dist/content.js"
68
+ },
69
+ "./fn": {
70
+ "bun": "./src/fn.ts",
71
+ "types": "./dist/fn.d.ts",
72
+ "default": "./dist/fn.js"
68
73
  }
69
74
  },
70
75
  "files": [
@@ -80,10 +85,10 @@
80
85
  "access": "public"
81
86
  },
82
87
  "dependencies": {
83
- "@nifrajs/core": "2.1.0",
84
- "@nifrajs/i18n": "2.1.0",
85
- "@nifrajs/image": "2.1.0",
86
- "@nifrajs/web": "2.1.0"
88
+ "@nifrajs/core": "2.3.0",
89
+ "@nifrajs/i18n": "2.3.0",
90
+ "@nifrajs/image": "2.3.0",
91
+ "@nifrajs/web": "2.3.0"
87
92
  },
88
93
  "peerDependencies": {
89
94
  "react": "^19.0.0",
package/src/await.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Deferred } from "@nifrajs/web"
2
2
  /**
3
- * `@nifrajs/web-react/await` — the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
3
+ * `@nifrajs/web-react/await` - the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
4
4
  * and imports only `react` + a type from `@nifrajs/web` (never `react-dom/server`), so route components
5
5
  * can use it without dragging server code into the client bundle. No JSX (the package builds with
6
6
  * plain `tsc`).
@@ -8,7 +8,7 @@ import type { Deferred } from "@nifrajs/web"
8
8
  * The server streams the boundary: `use(promise)` suspends, React flushes the fallback then the
9
9
  * resolved content mid-stream. On the client the registry promise is already settled (the core's
10
10
  * streamed `__nifraResolve` script tagged it `fulfilled`), so `use()` returns the value synchronously
11
- * — React hydrates the server-revealed boundary's content directly (no re-fetch, no fallback flash).
11
+ * - React hydrates the server-revealed boundary's content directly (no re-fetch, no fallback flash).
12
12
  */
13
13
  import { Component, createElement, type ReactNode, Suspense, use } from "react"
14
14
 
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { MountRouterOptions, RenderProps } from "@nifrajs/web"
1
+ import { type MountRouterOptions, type RenderProps, searchOfChain } from "@nifrajs/web"
2
2
  /**
3
- * @nifrajs/web-react/client — React client runtime. `hydrate` hydrates a single SSR'd route;
3
+ * @nifrajs/web-react/client - React client runtime. `hydrate` hydrates a single SSR'd route;
4
4
  * `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
5
5
  * `useSyncExternalStore`) and re-renders the matched chain on every client navigation (no full
6
6
  * reload). Kept in its own entry so server code stays out of the client bundle.
@@ -10,7 +10,7 @@ import { hydrateRoot } from "react-dom/client"
10
10
  import { compose } from "./compose.ts"
11
11
  import { setMountedRouter } from "./fetcher.ts"
12
12
 
13
- // The `_error` boundary chain element — defined in its own (react-dom-free) module, re-exported here so
13
+ // The `_error` boundary chain element - defined in its own (react-dom-free) module, re-exported here so
14
14
  // nifra's client codegen resolves it from `@nifrajs/web-react/client` alongside `mountRouter`.
15
15
  export { errorBoundary } from "./error.ts"
16
16
 
@@ -21,25 +21,31 @@ export function hydrate(chain: readonly unknown[], props: RenderProps, container
21
21
 
22
22
  /**
23
23
  * Hydrate a stateful React Router. `useSyncExternalStore` subscribes to the agnostic store and
24
- * re-renders the matched layout chain on each store change — so client navigations swap routes
24
+ * re-renders the matched layout chain on each store change - so client navigations swap routes
25
25
  * without a full reload. `getServerSnapshot` (3rd arg) returns the initial state, matching the
26
26
  * SSR markup on hydration.
27
27
  */
28
28
  export function mountRouter(options: MountRouterOptions): void {
29
- const { router, routes, container } = options
29
+ const { router, routes, searchSchemas, container } = options
30
30
  setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
31
31
  const Router: FunctionComponent = () => {
32
32
  const state = useSyncExternalStore(router.subscribe, router.snapshot, router.snapshot)
33
+ // Derive this route's typed `search` from the URL query + the route's schema CHAIN (layout schemas +
34
+ // page, merged page-wins), the SAME `searchOfChain` the server ran, so `useSearch` reads an identical
35
+ // value and hydrates with no drift.
36
+ const q = state.path.indexOf("?")
37
+ const rawSearch = q === -1 ? "" : state.path.slice(q)
33
38
  return compose(routes[state.routeId] ?? [], {
34
39
  data: state.data,
35
40
  actionData: state.actionData,
36
41
  pending: state.pending,
37
42
  ...(state.pendingPath !== undefined ? { pendingPath: state.pendingPath } : {}),
38
- // `params`/`path` feed the routing hooks (useParams/useLocation) via compose's RouterContext —
43
+ // `params`/`path` feed the routing hooks (useParams/useLocation) via compose's RouterContext -
39
44
  // sourced from router state here, matching the SSR render's request-derived values on hydration.
40
45
  params: state.params,
41
46
  path: state.path,
42
- // The in-flight submission (for optimistic UI) — spread only when present.
47
+ search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
48
+ // The in-flight submission (for optimistic UI) - spread only when present.
43
49
  ...(state.submission ? { submission: state.submission } : {}),
44
50
  })
45
51
  }
package/src/compose.ts CHANGED
@@ -5,23 +5,34 @@ 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.
24
- node = createElement(chain[i] as FunctionComponent, null, node)
27
+ // children passed as the 3rd arg (not a `children` prop) - React's canonical form.
28
+ // Each layout receives its own loader data at its own index. Layouts are the chain's leading
29
+ // prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
30
+ // boundary marker, the page) reads `undefined` and is unaffected.
31
+ node = createElement(
32
+ chain[i] as FunctionComponent<{ data: unknown }>,
33
+ { data: props.layoutData?.[i] ?? null },
34
+ node,
35
+ )
25
36
  }
26
37
  return createElement(
27
38
  RouterContext.Provider,
@@ -29,6 +40,7 @@ export function compose(chain: readonly unknown[], props: RenderProps): ReactNod
29
40
  value: {
30
41
  params: props.params ?? EMPTY_PARAMS,
31
42
  path: props.path ?? "",
43
+ search: props.search ?? EMPTY_SEARCH,
32
44
  pending: props.pending ?? false,
33
45
  pendingPath: props.pendingPath,
34
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,15 +26,32 @@
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>>
33
33
  }
34
34
 
35
+ interface RuntimeProcess {
36
+ getBuiltinModule?(specifier: string): unknown
37
+ }
38
+
39
+ function dirnamePortable(path: string): string {
40
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
41
+ return slash <= 0 ? "." : path.slice(0, slash)
42
+ }
43
+
44
+ function runtimeRealpath(path: string): string {
45
+ const runtimeProcess = (globalThis as { process?: RuntimeProcess }).process
46
+ const fs = runtimeProcess?.getBuiltinModule?.("node:fs") as
47
+ | { realpathSync?: (value: string) => string }
48
+ | undefined
49
+ return fs?.realpathSync?.(path) ?? path
50
+ }
51
+
35
52
  // `globalThis.Bun` isn't in the ambient lib types; narrow exactly the one method we need so we never
36
53
  // reach for `any`. `resolveSync(specifier, from)` returns the absolute path the specifier resolves to
37
- // 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
38
55
  // (a runtime `Bun.plugin` onResolve does NOT fire for bare specifiers like `react-dom/server`, verified).
39
56
  interface BunResolver {
40
57
  resolveSync(specifier: string, from: string): string
@@ -72,6 +89,54 @@ export function reactDomServer(): Promise<ReactDomServer> {
72
89
  /** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
73
90
  type ResolveSync = (specifier: string, from: string) => string
74
91
 
92
+ /**
93
+ * The dual-React crash, caught on the RESOLVED graph instead of after it detonates.
94
+ *
95
+ * The re-root above makes `react-dom/server` load from the app root, which is right almost always. What it
96
+ * cannot guarantee is that react-dom then pulls in the SAME physical `react` the route components import -
97
+ * a nested `react` under react-dom, or a components tree resolving react elsewhere, still yields two cores.
98
+ * Two cores is two hook dispatchers, and the renderer sees a null (or foreign) one: the SSR throws
99
+ * `resolveDispatcher().useState is null` from deep inside react-dom-server, naming a React internal and
100
+ * nothing about the two directories that actually caused it. That is hours of inference from a message
101
+ * that points nowhere useful.
102
+ *
103
+ * `nifra doctor` checks what is INSTALLED; this checks what SSR actually RESOLVED, which is the only thing
104
+ * that can catch a duplicate the two dev pipelines introduce (Bun for SSR, Vite for the client) rather
105
+ * than the install. It compares the realpath of the `react` react-dom will render with against the
106
+ * `react` the components import, and if they differ throws with BOTH paths - turning a five-hour hunt into
107
+ * a five-second read. Silent when they agree, which is the single-copy common case.
108
+ *
109
+ * Never manufactures a failure: if either side cannot be resolved it returns, because a resolver that
110
+ * cannot answer is not evidence of a duplicate. Exported for direct unit testing.
111
+ */
112
+ export function assertSingleReactCore(
113
+ reactDomServerPath: string,
114
+ resolve: ResolveSync,
115
+ realpath: (path: string) => string = runtimeRealpath,
116
+ ): void {
117
+ let rendererReact: string
118
+ let componentsReact: string
119
+ try {
120
+ // The `react` react-dom/server itself resolves - the core whose dispatcher the renderer sets.
121
+ rendererReact = realpath(resolve("react", dirnamePortable(reactDomServerPath)))
122
+ // The `react` the app's route components import - the core they call hooks on.
123
+ componentsReact = realpath(resolve("react", appRoot()))
124
+ } catch {
125
+ return
126
+ }
127
+ if (rendererReact === componentsReact) return
128
+ throw new Error(
129
+ "[nifra/web-react] two copies of React reached SSR, so hooks render against a null dispatcher " +
130
+ "(the `resolveDispatcher().useState is null` crash). react-dom renders with a DIFFERENT React " +
131
+ "than your components import:\n" +
132
+ ` react-dom's react: ${rendererReact}\n` +
133
+ ` components' react: ${componentsReact}\n` +
134
+ "Module identity is path-based, so two copies fail even at the same version. Dedupe react to one " +
135
+ "physical copy - usually a single hoisted install at the workspace root (`nifra doctor` locates " +
136
+ "the duplicate). A Vite `resolve.dedupe`/alias fixes only the client bundle, not this SSR path.",
137
+ )
138
+ }
139
+
75
140
  /**
76
141
  * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
77
142
  * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
@@ -85,11 +150,19 @@ export async function loadReactDomServer(
85
150
  // Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
86
151
  // is a PEER dependency of this adapter, so a correct install puts it at the app root and this
87
152
  // resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
88
- // rather than crashing — degraded (possible duplicate) but never a hard failure.
153
+ // rather than crashing - degraded (possible duplicate) but never a hard failure.
89
154
  try {
90
155
  const resolved = resolve("react-dom/server", appRoot())
156
+ // Before returning the module, verify the react-dom we just re-rooted shares the components' React.
157
+ // The re-root fixes the common case; this catches the residual duplicate loudly, with both paths,
158
+ // instead of leaving it to throw a dispatcher-null error on the first hook render. Runs once (this
159
+ // whole function is cached), dev/unbundled Bun only (the branch `resolve` gates).
160
+ assertSingleReactCore(resolved, resolve)
91
161
  return (await import(resolved)) as ReactDomServer
92
- } catch {
162
+ } catch (err) {
163
+ // A genuine duplicate is surfaced (re-thrown with both paths); any other failure (react-dom not at
164
+ // the app root, an odd nested layout) falls through to the bundled specifier rather than crashing.
165
+ if (err instanceof Error && err.message.includes("two copies of React")) throw err
93
166
  // App-root resolution failed; use the specifier resolved from this module's own location below.
94
167
  }
95
168
  }
@@ -100,7 +173,7 @@ export async function loadReactDomServer(
100
173
  }
101
174
 
102
175
  /**
103
- * 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
104
177
  * non-Bun host (no `Bun.resolveSync`; the static import is the only path) OR a BUNDLED SSR output.
105
178
  * `buildServer` defines `process.env.NIFRA_SSR_BUNDLED` to `"1"` in every bundle, where react-dom is
106
179
  * already inlined + deduped to the components' React (reactDedupePlugin); re-rooting there would re-import