@nifrajs/web-preact 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -3
  2. package/dist/await.js +1 -1
  3. package/dist/client.d.ts +2 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +11 -5
  6. package/dist/client.js.map +1 -1
  7. package/dist/compose.d.ts +4 -2
  8. package/dist/compose.d.ts.map +1 -1
  9. package/dist/compose.js +15 -6
  10. package/dist/compose.js.map +1 -1
  11. package/dist/content.d.ts +2 -2
  12. package/dist/content.js +2 -2
  13. package/dist/error.d.ts +1 -1
  14. package/dist/error.js +3 -3
  15. package/dist/fetcher.d.ts +2 -2
  16. package/dist/fetcher.js +5 -5
  17. package/dist/fn.d.ts +26 -0
  18. package/dist/fn.d.ts.map +1 -0
  19. package/dist/fn.js +28 -0
  20. package/dist/fn.js.map +1 -0
  21. package/dist/i18n.d.ts +2 -2
  22. package/dist/i18n.js +3 -3
  23. package/dist/image.d.ts +4 -4
  24. package/dist/image.js +2 -2
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +4 -4
  27. package/dist/preact-render.d.ts +3 -3
  28. package/dist/preact-render.js +3 -3
  29. package/dist/query.d.ts +1 -1
  30. package/dist/query.js +2 -2
  31. package/dist/read-deferred.d.ts +2 -2
  32. package/dist/read-deferred.js +1 -1
  33. package/dist/router.d.ts +34 -0
  34. package/dist/router.d.ts.map +1 -0
  35. package/dist/router.js +64 -0
  36. package/dist/router.js.map +1 -0
  37. package/package.json +16 -5
  38. package/src/await.ts +1 -1
  39. package/src/client.ts +11 -6
  40. package/src/compose.ts +21 -6
  41. package/src/content.ts +2 -2
  42. package/src/error.ts +3 -3
  43. package/src/fetcher.ts +5 -5
  44. package/src/fn.ts +40 -0
  45. package/src/i18n.ts +3 -3
  46. package/src/image.ts +4 -4
  47. package/src/index.ts +4 -4
  48. package/src/preact-render.ts +4 -4
  49. package/src/query.ts +3 -3
  50. package/src/read-deferred.ts +3 -3
  51. package/src/router.ts +88 -0
package/dist/router.js ADDED
@@ -0,0 +1,64 @@
1
+ import { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, } from "@nifrajs/web";
2
+ /**
3
+ * `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
4
+ * `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
5
+ * (the route's typed, validated search). Navigation goes through `@nifrajs/web`'s DOM-free bridges
6
+ * (`getBrowserNavigate` / `registerBlocker`, populated by `installHistory`); `useSearch` reads the value
7
+ * `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
8
+ */
9
+ import { createContext } from "preact";
10
+ import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat";
11
+ // Frozen empty search so the default context value has a stable reference.
12
+ const EMPTY_SEARCH = Object.freeze({});
13
+ /** The current route's validated search, provided by `compose` on SSR + client mount alike (derived from
14
+ * the URL via the shared `searchOfChain`). `{}` outside a nifra route tree. Read via {@link useSearch}. */
15
+ export const SearchContext = createContext(EMPTY_SEARCH);
16
+ /**
17
+ * The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
18
+ * SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
19
+ * client mount, so a value rendered from it doesn't flash on hydration. Hostile input already failed
20
+ * closed to the schema's defaults at match time. Pass the route's `searchSchema` as the type argument to
21
+ * get its output type; bare, it's the raw parsed query (`Record<string, unknown>`).
22
+ *
23
+ * ```tsx
24
+ * export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
25
+ * const { page } = useSearch<typeof searchSchema>() // page: number
26
+ * ```
27
+ */
28
+ export function useSearch() {
29
+ return useContext(SearchContext);
30
+ }
31
+ /** Get the {@link NavigateFunction} (a string path, a history delta, or a typed `{ to, search }` object).
32
+ * Stable across renders; resolves the browser navigate at call time, so it works as soon as
33
+ * `installHistory` has run and no-ops before then / on the server. */
34
+ export function useNavigate() {
35
+ return useCallback((to, options) => {
36
+ const navigate = getBrowserNavigate();
37
+ if (navigate === undefined)
38
+ return;
39
+ const resolved = resolveNavigate(to, options);
40
+ navigate(resolved.to, resolved.options);
41
+ }, []);
42
+ }
43
+ /**
44
+ * Guard navigation away from a page with unsaved work, confirming with your OWN async UI. Mirrors
45
+ * react-router's `useBlocker`: pass a boolean (`useBlocker(isDirty)`) or a predicate
46
+ * `({ currentLocation, nextLocation }) => boolean`, and get back a {@link Blocker}. When a navigation
47
+ * (an anchor click, `useNavigate`, or a browser back/forward) is intercepted, `blocker.state` becomes
48
+ * `"blocked"` and `proceed`/`reset` go live - render a dialog and call `proceed()` to continue or
49
+ * `reset()` to stay put. It also arms the browser's native "Leave site?" prompt on tab close / reload.
50
+ * Idle (never blocks) on the server and before hydration.
51
+ */
52
+ export function useBlocker(shouldBlock) {
53
+ const [blocker, setBlocker] = useState(IDLE_BLOCKER);
54
+ // Track the latest predicate in a ref (updated every render) so the single registration always sees
55
+ // current component state without re-registering (which would drop an open prompt).
56
+ const shouldBlockRef = useRef(shouldBlock);
57
+ shouldBlockRef.current = shouldBlock;
58
+ useEffect(() => registerBlocker((args) => {
59
+ const current = shouldBlockRef.current;
60
+ return typeof current === "function" ? current(args) : current;
61
+ }, setBlocker), []);
62
+ return blocker;
63
+ }
64
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,kBAAkB,EAClB,YAAY,EAIZ,eAAe,EACf,eAAe,GAChB,MAAM,cAAc,CAAA;AACrB;;;;;;GAMG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAIpF,2EAA2E;AAC3E,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE;2GAC2G;AAC3G,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAA0B,YAAY,CAAC,CAAA;AAEjF;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAEJ,CAAA;AAC7B,CAAC;AAED;;sEAEsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,EAAyC,EAAE,OAAyB,EAAE,EAAE;QAC1F,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM;QAClC,MAAM,QAAQ,GAAG,eAAe,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,EAAE,CAAqB,CAAA;AAC5B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,WAAsC;IAC/D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAA;IAC7D,oGAAoG;IACpG,oFAAoF;IACpF,MAAM,cAAc,GAAG,MAAM,CAAC,WAAW,CAAC,CAAA;IAC1C,cAAc,CAAC,OAAO,GAAG,WAAW,CAAA;IACpC,SAAS,CACP,GAAG,EAAE,CACH,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACtC,OAAO,OAAO,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IAChE,CAAC,EAAE,UAAU,CAAC,EAChB,EAAE,CACH,CAAA;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nifrajs/web-preact",
3
- "version": "2.2.0",
4
- "description": "Preact render adapter for @nifrajs/web — streaming SSR + hydration (no build plugin).",
3
+ "version": "2.3.0",
4
+ "description": "Preact render adapter for @nifrajs/web - streaming SSR + hydration (no build plugin).",
5
5
  "keywords": [
6
6
  "bun",
7
7
  "typescript",
@@ -36,6 +36,11 @@
36
36
  "types": "./dist/fetcher.d.ts",
37
37
  "default": "./dist/fetcher.js"
38
38
  },
39
+ "./router": {
40
+ "bun": "./src/router.ts",
41
+ "types": "./dist/router.d.ts",
42
+ "default": "./dist/router.js"
43
+ },
39
44
  "./query": {
40
45
  "bun": "./src/query.ts",
41
46
  "types": "./dist/query.d.ts",
@@ -55,6 +60,11 @@
55
60
  "bun": "./src/content.ts",
56
61
  "types": "./dist/content.d.ts",
57
62
  "default": "./dist/content.js"
63
+ },
64
+ "./fn": {
65
+ "bun": "./src/fn.ts",
66
+ "types": "./dist/fn.d.ts",
67
+ "default": "./dist/fn.js"
58
68
  }
59
69
  },
60
70
  "files": [
@@ -70,9 +80,10 @@
70
80
  "access": "public"
71
81
  },
72
82
  "dependencies": {
73
- "@nifrajs/i18n": "2.2.0",
74
- "@nifrajs/image": "2.2.0",
75
- "@nifrajs/web": "2.2.0",
83
+ "@nifrajs/core": "2.3.0",
84
+ "@nifrajs/i18n": "2.3.0",
85
+ "@nifrajs/image": "2.3.0",
86
+ "@nifrajs/web": "2.3.0",
76
87
  "preact-render-to-string": "^6.5.0"
77
88
  },
78
89
  "peerDependencies": {
package/src/await.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Deferred } from "@nifrajs/web"
2
2
  import type { ComponentChildren, VNode } from "preact"
3
3
  /**
4
- * `@nifrajs/web-preact/await` — the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
4
+ * `@nifrajs/web-preact/await` - the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
5
5
  * and imports only `preact`/`preact/compat` + a type from `@nifrajs/web` (never the server renderer), so
6
6
  * route components can use it without dragging server code into the client bundle. No JSX (the package
7
7
  * builds with plain `tsc`).
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-preact/client — Preact client runtime. `hydrate` hydrates a single SSR'd route;
3
+ * @nifrajs/web-preact/client - Preact 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` from preact/compat) and re-renders the matched chain on every client
6
6
  * navigation (no full reload). Kept in its own entry so server code (preact-render-to-string) stays
@@ -11,7 +11,7 @@ import { useSyncExternalStore } from "preact/compat"
11
11
  import { compose } from "./compose.ts"
12
12
  import { setMountedRouter } from "./fetcher.ts"
13
13
 
14
- // The `_error` boundary chain element — defined in its own (DOM-free) module, re-exported here so
14
+ // The `_error` boundary chain element - defined in its own (DOM-free) module, re-exported here so
15
15
  // nifra's client codegen resolves it from `@nifrajs/web-preact/client` alongside `mountRouter`.
16
16
  export { errorBoundary } from "./error.ts"
17
17
 
@@ -22,20 +22,25 @@ export function hydrate(chain: readonly unknown[], props: RenderProps, container
22
22
 
23
23
  /**
24
24
  * Hydrate a stateful Preact Router. `useSyncExternalStore` (preact/compat) subscribes to the
25
- * agnostic store and re-renders the matched layout chain on each store change — so client
25
+ * agnostic store and re-renders the matched layout chain on each store change - so client
26
26
  * navigations swap routes without a full reload. Preact's compat `useSyncExternalStore` is 2-arg
27
27
  * (no `getServerSnapshot`); `router.snapshot` is deterministic, so hydration matches the SSR markup.
28
28
  */
29
29
  export function mountRouter(options: MountRouterOptions): void {
30
- const { router, routes, container } = options
30
+ const { router, routes, searchSchemas, container } = options
31
31
  setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
32
32
  const Router: FunctionComponent = () => {
33
33
  const state = useSyncExternalStore(router.subscribe, router.snapshot)
34
+ // Derive this route's typed `search` from the URL + the route's schema chain (the SAME `searchOfChain`
35
+ // the server ran), so `useSearch` reads an identical value and hydrates with no drift.
36
+ const q = state.path.indexOf("?")
37
+ const rawSearch = q === -1 ? "" : state.path.slice(q)
34
38
  return compose(routes[state.routeId] ?? [], {
35
39
  data: state.data,
36
40
  actionData: state.actionData,
37
41
  pending: state.pending,
38
- // The in-flight submission (for optimistic UI) — spread only when present.
42
+ search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
43
+ // The in-flight submission (for optimistic UI) - spread only when present.
39
44
  ...(state.submission ? { submission: state.submission } : {}),
40
45
  })
41
46
  }
package/src/compose.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  import type { RenderProps } from "@nifrajs/web"
2
- import { type ComponentType, h, type VNode } from "preact"
2
+ import { type ComponentChildren, type ComponentType, createElement, h, type VNode } from "preact"
3
+ import { SearchContext } from "./router.ts"
4
+
5
+ // Frozen empty search so a render with no search context has a stable provider value.
6
+ const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
7
+
8
+ // Preact's `createElement` overloads drop a typed `Context.Provider`'s required `value` under
9
+ // exactOptionalPropertyTypes (same issue i18n's provider hits); invoke it through a precisely-typed
10
+ // factory that states the real `{ value }` + children contract rather than scatter `any`.
11
+ const createSearchProvider = createElement as (
12
+ type: typeof SearchContext.Provider,
13
+ props: { value: Record<string, unknown> },
14
+ children?: ComponentChildren,
15
+ ) => VNode
3
16
 
4
17
  /**
5
18
  * Fold a layout chain (outermost layout → page) into a single Preact tree: the page (innermost)
6
19
  * receives `props` (the loader data); each layout wraps the child via its `children` (Preact passes
7
- * the 3rd `h` arg as `props.children`). Shared by the server adapter (renderToReadableStream) and
8
- * the client (hydrate / mountRouter) — the Preact analogue of the React adapter's `compose`.
20
+ * the 3rd `h` arg as `props.children`). The whole tree is wrapped in a {@link SearchContext} provider
21
+ * carrying the validated `search` (threaded through `RenderProps` identically on SSR + client), so
22
+ * `useSearch` reads the same value on both sides - no hydration mismatch. Shared by the server adapter
23
+ * (renderToReadableStream) and the client (hydrate / mountRouter) - the Preact analogue of React's `compose`.
9
24
  */
10
25
  export function compose(chain: readonly unknown[], props: RenderProps): VNode {
11
26
  const last = chain.length - 1
@@ -13,10 +28,10 @@ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
13
28
  // loader props (typed via ComponentType<RenderProps>), then we widen `h`'s VNode<Attributes &
14
29
  // RenderProps> to the uniform VNode the fold accumulates into. Preact's VNode<P> is invariant in P
15
30
  // under exactOptionalPropertyTypes, so the widen needs the explicit `as VNode` (a comparability
16
- // cast — Attributes & RenderProps is assignable to {}, so this is not an unsafe coercion).
31
+ // cast - Attributes & RenderProps is assignable to {}, so this is not an unsafe coercion).
17
32
  let node: VNode = h(chain[last] as ComponentType<RenderProps>, props) as VNode
18
33
  for (let i = last - 1; i >= 0; i--) {
19
- // children passed as the 3rd arg — Preact's canonical form (mirrors the React adapter).
34
+ // children passed as the 3rd arg - Preact's canonical form (mirrors the React adapter).
20
35
  // Each layout receives its own loader data at its own index. Layouts are the chain's leading
21
36
  // prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
22
37
  // boundary marker, the page) reads `undefined` and is unaffected.
@@ -26,5 +41,5 @@ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
26
41
  node,
27
42
  ) as VNode
28
43
  }
29
- return node
44
+ return createSearchProvider(SearchContext.Provider, { value: props.search ?? EMPTY_SEARCH }, node)
30
45
  }
package/src/content.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `@nifrajs/web-preact/content` — render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
2
+ * `@nifrajs/web-preact/content` - render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
3
3
  * `.html`) into the DOM via `dangerouslySetInnerHTML`. Pairs with content collections:
4
4
  * `<Content html={entry.html} />`.
5
5
  *
6
6
  * Security: injects raw HTML, so `html` MUST be trusted (your own Markdown/content, rendered at
7
- * build/server time) — never pass user-supplied HTML without sanitizing it first.
7
+ * build/server time) - never pass user-supplied HTML without sanitizing it first.
8
8
  */
9
9
  import { createElement, type JSX, type VNode } from "preact"
10
10
 
package/src/error.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `errorBoundary` — the Preact error-boundary chain element for nifra's `_error.tsx`. Its own module
2
+ * `errorBoundary` - the Preact error-boundary chain element for nifra's `_error.tsx`. Its own module
3
3
  * (imports only `preact`) so it's unit-testable off the DOM and the client codegen can import it from
4
4
  * `@nifrajs/web-preact/client` (which re-exports it).
5
5
  */
@@ -9,7 +9,7 @@ import { Component, type ComponentChildren, createElement, type FunctionComponen
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?: ComponentChildren }
@@ -25,7 +25,7 @@ export function errorBoundary(fallback: unknown): unknown {
25
25
  override render(): ComponentChildren {
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,16 +1,16 @@
1
1
  import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
2
2
  /**
3
- * `@nifrajs/web-preact/fetcher` — Preact bindings for concurrent fetchers. `useFetcher(key)` subscribes a
3
+ * `@nifrajs/web-preact/fetcher` - Preact bindings for concurrent fetchers. `useFetcher(key)` subscribes a
4
4
  * component to an independent {@link Fetcher} (via `useSyncExternalStore` from preact/compat) and
5
5
  * returns its reactive state plus `load`/`submit`; `useFetchers()` subscribes to the whole live
6
6
  * collection. Imports only `preact/compat` + `@nifrajs/web` types, so route components can use it on the
7
7
  * server *and* client. 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). Preact's compat `useSyncExternalStore` is 2-arg (no `getServerSnapshot`);
12
12
  * with no router the `getSnapshot` already returns idle, so the first client render after `mountRouter`
13
- * matches the SSR markup — no hydration mismatch.
13
+ * matches the SSR markup - no hydration mismatch.
14
14
  */
15
15
  import { useSyncExternalStore } from "preact/compat"
16
16
 
@@ -18,7 +18,7 @@ import { useSyncExternalStore } from "preact/compat"
18
18
  // page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
19
19
  let mountedRouter: ClientRouter | undefined
20
20
 
21
- /** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
21
+ /** Register (or clear) the router that owns fetchers - called by `mountRouter`. Not for app use. */
22
22
  export function setMountedRouter(router: ClientRouter | undefined): void {
23
23
  mountedRouter = router
24
24
  }
@@ -55,7 +55,7 @@ export function useFetcher(key: string): FetcherHandle {
55
55
  }
56
56
 
57
57
  /**
58
- * Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
58
+ * Subscribe to the whole live fetcher collection - for a global busy view (e.g. "3 saving…"). Read
59
59
  * each entry's `.snapshot()` for its state. Re-renders whenever any fetcher transitions or a new one
60
60
  * is created.
61
61
  */
package/src/fn.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `@nifrajs/web-preact/fn` - Preact 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 "preact/compat"
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
+ * Preact's compat `useSyncExternalStore` takes two arguments (no server snapshot); the store's idle
27
+ * state is shared by reference anyway, so a server render and the first client render agree.
28
+ */
29
+ export function useServerFn<Input, Output>(
30
+ fn: ServerFnReference<Input, Output>,
31
+ ): ServerFnHandle<Input, Output> {
32
+ // The store is created once, but always calls the CURRENT `fn`. Capturing `fn` directly would
33
+ // either recreate the store on every render (losing the state) or hold the first render's closure
34
+ // (calling a stale one); the ref is what makes "created once" and "never stale" both true.
35
+ const latest = useRef(fn as ClientServerFn<Input, Output>)
36
+ latest.current = fn as ClientServerFn<Input, Output>
37
+ const store = useMemo(() => createServerFnStore((input: Input) => latest.current(input)), [])
38
+ const state = useSyncExternalStore(store.subscribe, store.snapshot)
39
+ return { ...state, call: store.call, reset: store.reset }
40
+ }
package/src/i18n.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * `@nifrajs/web-preact/i18n` — Preact bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
2
+ * `@nifrajs/web-preact/i18n` - Preact 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). Uses `preact` +
6
- * `preact/hooks` — no JSX.
6
+ * `preact/hooks` - no JSX.
7
7
  */
8
8
  import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
9
9
  import { type ComponentChildren, createContext, createElement, type VNode } from "preact"
@@ -12,7 +12,7 @@ import { useContext, useMemo } from "preact/hooks"
12
12
  const I18nContext = createContext<Formatter | null>(null)
13
13
 
14
14
  // Preact's `createElement` overloads don't reconcile a typed `Context.Provider` (required `value`)
15
- // under `exactOptionalPropertyTypes` — overload resolution drops `value` and infers the props as `{}`.
15
+ // under `exactOptionalPropertyTypes` - overload resolution drops `value` and infers the props as `{}`.
16
16
  // The call is correct at runtime (proven by the tests), so invoke it through a precisely-typed element
17
17
  // factory that states the real contract (`{ value }` + children), rather than scatter `any`.
18
18
  const createProvider = createElement as (
package/src/image.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * `@nifrajs/web-preact/image` — a CLS-safe responsive Preact `<Image>`. A thin wrapper over `resolveImage`
2
+ * `@nifrajs/web-preact/image` - a CLS-safe responsive Preact `<Image>`. A thin wrapper over `resolveImage`
3
3
  * from `@nifrajs/image`: it computes the responsive `<img>` attributes (`src`/`srcset`/`sizes`/`width`/
4
- * `height`/`loading`/`decoding`/`fetchpriority` — Preact uses lowercase HTML attribute names, so it
4
+ * `height`/`loading`/`decoding`/`fetchpriority` - Preact uses lowercase HTML attribute names, so it
5
5
  * spreads `toHtmlAttrs`) and forwards any extra DOM props (`class`, `style`, `id`, `data-*`, handlers).
6
6
  * Resizing is delegated to the `loader` (an image CDN); nifra bundles no codec. No JSX (`h`).
7
7
  */
8
8
  import { type ImageLoader, type ImageProps, resolveImage, toHtmlAttrs } from "@nifrajs/image"
9
9
  import { h, type JSX, type VNode } from "preact"
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<JSX.IntrinsicElements["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
  }
29
29
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RenderAdapter } from "@nifrajs/web"
2
2
  /**
3
- * @nifrajs/web-preact — the Preact render adapter for @nifrajs/web (server side). Streaming SSR via
3
+ * @nifrajs/web-preact - the Preact render adapter for @nifrajs/web (server side). Streaming SSR via
4
4
  * `renderToReadableStream` from `preact-render-to-string/stream` (a Web `ReadableStream<Uint8Array>`,
5
5
  * the seam's native shape); the layout-chain fold is in `./compose`. Client hydration lives in
6
6
  * `@nifrajs/web-preact/client`. Preact uses `h()` render functions, so no JSX/build plugin is needed
@@ -9,13 +9,13 @@ import type { RenderAdapter } from "@nifrajs/web"
9
9
  import { compose } from "./compose.ts"
10
10
  import { preactRenderToStream, preactRenderToString } from "./preact-render.ts"
11
11
 
12
- /** The Preact server render adapter — pass to @nifrajs/web's `renderPage`. */
12
+ /** The Preact server render adapter - pass to @nifrajs/web's `renderPage`. */
13
13
  export const preactAdapter: RenderAdapter = {
14
14
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). The sync
15
15
  // `renderToString` emits the same markup as the stream renderer without the chunking machinery.
16
16
  //
17
17
  // Async because the renderer module is resolved lazily (from the consumer app's node_modules under Bun
18
- // runtime SSR — see ./preact-render). `RenderAdapter.renderToString` permits a Promise; renderPage awaits.
18
+ // runtime SSR - see ./preact-render). `RenderAdapter.renderToString` permits a Promise; renderPage awaits.
19
19
  async renderToString(chain, props) {
20
20
  const { renderToString } = await preactRenderToString()
21
21
  return renderToString(compose(chain, props))
@@ -27,7 +27,7 @@ export const preactAdapter: RenderAdapter = {
27
27
  return renderToReadableStream(compose(chain, props))
28
28
  },
29
29
  // Preact reconciles against the existing DOM on hydrate (like React), so there's no per-document
30
- // bootstrap script — the seam allows the empty string (contrast Solid's generateHydrationScript).
30
+ // bootstrap script - the seam allows the empty string (contrast Solid's generateHydrationScript).
31
31
  hydrationHead() {
32
32
  return ""
33
33
  },
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * Resolve `preact-render-to-string` (+ its `/stream` subpath) so the renderer shares ONE `preact`
3
- * module-global with the route components — the fix for the dual-Preact SSR crash (`undefined is not an
3
+ * module-global with the route components - the fix for the dual-Preact SSR crash (`undefined is not an
4
4
  * object (evaluating '…__H')` / silently-empty hook output).
5
5
  *
6
6
  * WHY this exists: `preact-render-to-string` imports `options` and `h` from `preact` and mutates the
7
7
  * shared `options` hooks; `preact/hooks` registers its render hook on the SAME `options` global. Under Bun
8
8
  * **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process), a static
9
9
  * `import "preact-render-to-string"` in this adapter is resolved from THIS package's own (symlinked)
10
- * node_modules — which can be a DIFFERENT physical `preact` than the consumer app's components import. Two
10
+ * node_modules - which can be a DIFFERENT physical `preact` than the consumer app's components import. Two
11
11
  * `preact` copies → two `options` globals → the renderer walks one while `preact/hooks` wrote the other →
12
12
  * the vnode never gets its hook-state list → the crash. Resolving the renderer from the consumer **app
13
13
  * root** makes it pull the app's `preact` transitively, matching the components: one `options`, one core,
14
- * no crash. (Empirically verified against a two-copy install fixture — see test/dual-preact.test.ts.)
14
+ * no crash. (Empirically verified against a two-copy install fixture - see test/dual-preact.test.ts.)
15
15
  *
16
16
  * Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
17
17
  * (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled a single
@@ -31,7 +31,7 @@ export interface PreactRenderToStream {
31
31
  }
32
32
 
33
33
  // Narrow only the one Bun method we need (it isn't in the ambient lib types) so no `any` is introduced.
34
- // `resolveSync(specifier, from)` is the only lever that re-roots a BARE specifier — a runtime
34
+ // `resolveSync(specifier, from)` is the only lever that re-roots a BARE specifier - a runtime
35
35
  // `Bun.plugin` onResolve does NOT fire for bare specifiers like `preact-render-to-string` (verified).
36
36
  interface BunResolver {
37
37
  resolveSync(specifier: string, from: string): string
package/src/query.ts CHANGED
@@ -5,12 +5,12 @@ import {
5
5
  type QueryState,
6
6
  } from "@nifrajs/web"
7
7
  /**
8
- * `@nifrajs/web-preact/query` — Preact bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
8
+ * `@nifrajs/web-preact/query` - Preact bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
9
9
  * component to a query (via `useSyncExternalStore` from preact/compat) and fetches on mount / key change;
10
10
  * `useQueryClient` exposes `invalidateQueries` for after a mutation. Imports only `preact/compat` +
11
11
  * `preact/hooks` + `@nifrajs/web`, so route components use it on the server *and* client. No JSX.
12
12
  *
13
- * The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
13
+ * The query client is created lazily **client-side only** (the `typeof window` guard) - on the server
14
14
  * there is none, so `useQuery` renders the idle/pending state (queries are client-first; loaders are
15
15
  * the SSR data source). The first client render uses the same idle snapshot, so no mismatch.
16
16
  */
@@ -45,7 +45,7 @@ const noopAsync = async (): Promise<never> => {
45
45
 
46
46
  /** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */
47
47
  export interface UseQueryResult<T> extends QueryState<T> {
48
- /** `status === "pending"` — no data yet (initial load). */
48
+ /** `status === "pending"` - no data yet (initial load). */
49
49
  readonly isPending: boolean
50
50
  /** Force a refetch (ignores `staleTime`). */
51
51
  readonly refetch: () => Promise<T>
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `readDeferred` — Preact's analogue of React 19's `use()` for a deferred promise (preact/compat has
2
+ * `readDeferred` - Preact's analogue of React 19's `use()` for a deferred promise (preact/compat has
3
3
  * no `use`). Reads a React-style tagged thenable synchronously: returns the value if fulfilled, throws
4
4
  * the reason if rejected, else throws the promise so `<Suspense>` awaits it. The core tags
5
5
  * `status`/`value`/`reason` via the streamed `__nifraResolve`/`__nifraReject`; this also self-tags on
6
6
  * settle so a server re-render (which has no `__nifraResolve`) reads "fulfilled" and stops re-throwing
7
- * the already-resolved promise. Pure + isomorphic — no `preact` import, so it unit-tests directly.
7
+ * the already-resolved promise. Pure + isomorphic - no `preact` import, so it unit-tests directly.
8
8
  */
9
9
  export type Thenable<T> = Promise<T> & {
10
10
  status?: "pending" | "fulfilled" | "rejected"
@@ -28,5 +28,5 @@ export function readDeferred<T>(promise: Thenable<T>): T {
28
28
  },
29
29
  )
30
30
  }
31
- throw promise // pending — <Suspense> awaits it; the self-tag above wakes the re-render.
31
+ throw promise // pending - <Suspense> awaits it; the self-tag above wakes the re-render.
32
32
  }
package/src/router.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
2
+ import {
3
+ type Blocker,
4
+ type BlockerFunction,
5
+ getBrowserNavigate,
6
+ IDLE_BLOCKER,
7
+ type NavigateFunction,
8
+ type NavigateOptions,
9
+ type NavigateTargetInput,
10
+ registerBlocker,
11
+ resolveNavigate,
12
+ } from "@nifrajs/web"
13
+ /**
14
+ * `@nifrajs/web-preact/router` - Preact routing bindings over the agnostic `@nifrajs/web` history layer:
15
+ * `useNavigate` (programmatic navigation), `useBlocker` (the unsaved-changes guard), and `useSearch`
16
+ * (the route's typed, validated search). Navigation goes through `@nifrajs/web`'s DOM-free bridges
17
+ * (`getBrowserNavigate` / `registerBlocker`, populated by `installHistory`); `useSearch` reads the value
18
+ * `compose` provides on SSR + client mount alike. Client-safe (no `preact-render-to-string`). No JSX.
19
+ */
20
+ import { createContext } from "preact"
21
+ import { useCallback, useContext, useEffect, useRef, useState } from "preact/compat"
22
+
23
+ export type { Blocker, BlockerFunction, BlockerState, NavigateFunction } from "@nifrajs/web"
24
+
25
+ // Frozen empty search so the default context value has a stable reference.
26
+ const EMPTY_SEARCH: Readonly<Record<string, unknown>> = Object.freeze({})
27
+
28
+ /** The current route's validated search, provided by `compose` on SSR + client mount alike (derived from
29
+ * the URL via the shared `searchOfChain`). `{}` outside a nifra route tree. Read via {@link useSearch}. */
30
+ export const SearchContext = createContext<Record<string, unknown>>(EMPTY_SEARCH)
31
+
32
+ /**
33
+ * The route's typed, validated search params - the SAME value the loader received as `ctx.search`.
34
+ * SSR-correct: `compose` provides it from the URL server-side and from the identical derivation on the
35
+ * client mount, so a value rendered from it doesn't flash on hydration. Hostile input already failed
36
+ * closed to the schema's defaults at match time. Pass the route's `searchSchema` as the type argument to
37
+ * get its output type; bare, it's the raw parsed query (`Record<string, unknown>`).
38
+ *
39
+ * ```tsx
40
+ * export const searchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1) })
41
+ * const { page } = useSearch<typeof searchSchema>() // page: number
42
+ * ```
43
+ */
44
+ export function useSearch<
45
+ Schema extends StandardSchemaV1 | undefined = undefined,
46
+ >(): Schema extends StandardSchemaV1 ? InferOutput<Schema> : Record<string, unknown> {
47
+ return useContext(SearchContext) as Schema extends StandardSchemaV1
48
+ ? InferOutput<Schema>
49
+ : Record<string, unknown>
50
+ }
51
+
52
+ /** Get the {@link NavigateFunction} (a string path, a history delta, or a typed `{ to, search }` object).
53
+ * Stable across renders; resolves the browser navigate at call time, so it works as soon as
54
+ * `installHistory` has run and no-ops before then / on the server. */
55
+ export function useNavigate(): NavigateFunction {
56
+ return useCallback((to: string | number | NavigateTargetInput, options?: NavigateOptions) => {
57
+ const navigate = getBrowserNavigate()
58
+ if (navigate === undefined) return
59
+ const resolved = resolveNavigate(to, options)
60
+ navigate(resolved.to, resolved.options)
61
+ }, []) as NavigateFunction
62
+ }
63
+
64
+ /**
65
+ * Guard navigation away from a page with unsaved work, confirming with your OWN async UI. Mirrors
66
+ * react-router's `useBlocker`: pass a boolean (`useBlocker(isDirty)`) or a predicate
67
+ * `({ currentLocation, nextLocation }) => boolean`, and get back a {@link Blocker}. When a navigation
68
+ * (an anchor click, `useNavigate`, or a browser back/forward) is intercepted, `blocker.state` becomes
69
+ * `"blocked"` and `proceed`/`reset` go live - render a dialog and call `proceed()` to continue or
70
+ * `reset()` to stay put. It also arms the browser's native "Leave site?" prompt on tab close / reload.
71
+ * Idle (never blocks) on the server and before hydration.
72
+ */
73
+ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {
74
+ const [blocker, setBlocker] = useState<Blocker>(IDLE_BLOCKER)
75
+ // Track the latest predicate in a ref (updated every render) so the single registration always sees
76
+ // current component state without re-registering (which would drop an open prompt).
77
+ const shouldBlockRef = useRef(shouldBlock)
78
+ shouldBlockRef.current = shouldBlock
79
+ useEffect(
80
+ () =>
81
+ registerBlocker((args) => {
82
+ const current = shouldBlockRef.current
83
+ return typeof current === "function" ? current(args) : current
84
+ }, setBlocker),
85
+ [],
86
+ )
87
+ return blocker
88
+ }