@nifrajs/web-preact 0.1.0-alpha.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/await.d.ts +18 -0
  4. package/dist/await.d.ts.map +1 -0
  5. package/dist/await.js +52 -0
  6. package/dist/await.js.map +1 -0
  7. package/dist/client.d.ts +12 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +40 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/compose.d.ts +10 -0
  12. package/dist/compose.d.ts.map +1 -0
  13. package/dist/compose.js +22 -0
  14. package/dist/compose.js.map +1 -0
  15. package/dist/content.d.ts +18 -0
  16. package/dist/content.d.ts.map +1 -0
  17. package/dist/content.js +20 -0
  18. package/dist/content.js.map +1 -0
  19. package/dist/error.d.ts +8 -0
  20. package/dist/error.d.ts.map +1 -0
  21. package/dist/error.js +32 -0
  22. package/dist/error.js.map +1 -0
  23. package/dist/fetcher.d.ts +23 -0
  24. package/dist/fetcher.d.ts.map +1 -0
  25. package/dist/fetcher.js +48 -0
  26. package/dist/fetcher.js.map +1 -0
  27. package/dist/i18n.d.ts +20 -0
  28. package/dist/i18n.d.ts.map +1 -0
  29. package/dist/i18n.js +31 -0
  30. package/dist/i18n.js.map +1 -0
  31. package/dist/image.d.ts +22 -0
  32. package/dist/image.d.ts.map +1 -0
  33. package/dist/image.js +23 -0
  34. package/dist/image.js.map +1 -0
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +29 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/query.d.ts +17 -0
  40. package/dist/query.d.ts.map +1 -0
  41. package/dist/query.js +58 -0
  42. package/dist/query.js.map +1 -0
  43. package/dist/read-deferred.d.ts +15 -0
  44. package/dist/read-deferred.d.ts.map +1 -0
  45. package/dist/read-deferred.js +18 -0
  46. package/dist/read-deferred.js.map +1 -0
  47. package/package.json +93 -0
  48. package/src/await.ts +78 -0
  49. package/src/client.ts +43 -0
  50. package/src/compose.ts +23 -0
  51. package/src/content.ts +28 -0
  52. package/src/error.ts +33 -0
  53. package/src/fetcher.ts +67 -0
  54. package/src/i18n.ts +47 -0
  55. package/src/image.ts +42 -0
  56. package/src/index.ts +30 -0
  57. package/src/query.ts +74 -0
  58. package/src/read-deferred.ts +32 -0
package/dist/query.js ADDED
@@ -0,0 +1,58 @@
1
+ import { createQueryClient, } from "@nifrajs/web";
2
+ /**
3
+ * `@nifrajs/web-preact/query` — Preact bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
4
+ * component to a query (via `useSyncExternalStore` from preact/compat) and fetches on mount / key change;
5
+ * `useQueryClient` exposes `invalidateQueries` for after a mutation. Imports only `preact/compat` +
6
+ * `preact/hooks` + `@nifrajs/web`, so route components use it on the server *and* client. No JSX.
7
+ *
8
+ * The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
9
+ * there is none, so `useQuery` renders the idle/pending state (queries are client-first; loaders are
10
+ * the SSR data source). The first client render uses the same idle snapshot, so no mismatch.
11
+ */
12
+ import { useSyncExternalStore } from "preact/compat";
13
+ import { useEffect } from "preact/hooks";
14
+ let client;
15
+ function getClient() {
16
+ if (typeof window === "undefined")
17
+ return undefined; // SSR → no client (queries render idle)
18
+ if (client === undefined)
19
+ client = createQueryClient({ now: () => Date.now() });
20
+ return client;
21
+ }
22
+ /** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
23
+ export function useQueryClient() {
24
+ return getClient() ?? { invalidateQueries: () => { } };
25
+ }
26
+ // Stable idle snapshot for the server / pre-fetch render (stable ref → no loop, no hydration mismatch).
27
+ const IDLE = Object.freeze({
28
+ status: "pending",
29
+ data: undefined,
30
+ error: undefined,
31
+ isFetching: false,
32
+ updatedAt: Number.NEGATIVE_INFINITY,
33
+ });
34
+ const idleSnapshot = () => IDLE;
35
+ const noopSubscribe = () => () => { };
36
+ const noopAsync = async () => {
37
+ throw new Error("[nifra/web-preact] useQuery.refetch called with no query client (server?)");
38
+ };
39
+ /**
40
+ * Subscribe to the keyed query for `key`, fetched via `fn`. Returns `{ status, data, error, isFetching,
41
+ * updatedAt, isPending, refetch }`. Concurrent `useQuery`s with the same key share one cache entry +
42
+ * one in-flight fetch (dedup). Refetches on mount and when the key changes; SSR-idle.
43
+ */
44
+ export function useQuery(key, fn) {
45
+ const handle = getClient()?.query(key, fn);
46
+ const state = useSyncExternalStore(handle?.subscribe ?? noopSubscribe, handle ? handle.snapshot : idleSnapshot);
47
+ // Fetch on mount + whenever the key changes (the handle is stable per key, so it changes with the key).
48
+ useEffect(() => {
49
+ if (handle !== undefined)
50
+ handle.fetch().catch(() => { });
51
+ }, [handle]);
52
+ return {
53
+ ...state,
54
+ isPending: state.status === "pending",
55
+ refetch: handle ? handle.refetch : noopAsync,
56
+ };
57
+ }
58
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,GAIlB,MAAM,cAAc,CAAA;AACrB;;;;;;;;;GASG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAExC,IAAI,MAA+B,CAAA;AACnC,SAAS,SAAS;IAChB,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,SAAS,CAAA,CAAC,wCAAwC;IAC5F,IAAI,MAAM,KAAK,SAAS;QAAE,MAAM,GAAG,iBAAiB,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC/E,OAAO,MAAM,CAAA;AACf,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,cAAc;IAC5B,OAAO,SAAS,EAAE,IAAI,EAAE,iBAAiB,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,CAAA;AACvD,CAAC;AAED,wGAAwG;AACxG,MAAM,IAAI,GAAsB,MAAM,CAAC,MAAM,CAAC;IAC5C,MAAM,EAAE,SAAS;IACjB,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,SAAS;IAChB,UAAU,EAAE,KAAK;IACjB,SAAS,EAAE,MAAM,CAAC,iBAAiB;CACpC,CAAC,CAAA;AACF,MAAM,YAAY,GAAG,GAAsB,EAAE,CAAC,IAAI,CAAA;AAClD,MAAM,aAAa,GAAG,GAAiB,EAAE,CAAC,GAAG,EAAE,GAAE,CAAC,CAAA;AAClD,MAAM,SAAS,GAAG,KAAK,IAAoB,EAAE;IAC3C,MAAM,IAAI,KAAK,CAAC,2EAA2E,CAAC,CAAA;AAC9F,CAAC,CAAA;AAUD;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAI,GAAY,EAAE,EAAoB;IAC5D,MAAM,MAAM,GAA+B,SAAS,EAAE,EAAE,KAAK,CAAI,GAAG,EAAE,EAAE,CAAC,CAAA;IACzE,MAAM,KAAK,GAAG,oBAAoB,CAChC,MAAM,EAAE,SAAS,IAAI,aAAa,EAClC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,YAAY,CACxC,CAAA;IACD,wGAAwG;IACxG,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,MAAM,KAAK,SAAS;YAAE,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC1D,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IACZ,OAAO;QACL,GAAG,KAAK;QACR,SAAS,EAAE,KAAK,CAAC,MAAM,KAAK,SAAS;QACrC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAE,SAA8B;KACnE,CAAA;AACH,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `readDeferred` — Preact's analogue of React 19's `use()` for a deferred promise (preact/compat has
3
+ * no `use`). Reads a React-style tagged thenable synchronously: returns the value if fulfilled, throws
4
+ * the reason if rejected, else throws the promise so `<Suspense>` awaits it. The core tags
5
+ * `status`/`value`/`reason` via the streamed `__nifraResolve`/`__nifraReject`; this also self-tags on
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.
8
+ */
9
+ export type Thenable<T> = Promise<T> & {
10
+ status?: "pending" | "fulfilled" | "rejected";
11
+ value?: T;
12
+ reason?: unknown;
13
+ };
14
+ export declare function readDeferred<T>(promise: Thenable<T>): T;
15
+ //# sourceMappingURL=read-deferred.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-deferred.d.ts","sourceRoot":"","sources":["../src/read-deferred.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,GAAG;IACrC,MAAM,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,UAAU,CAAA;IAC7C,KAAK,CAAC,EAAE,CAAC,CAAA;IACT,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAiBvD"}
@@ -0,0 +1,18 @@
1
+ export function readDeferred(promise) {
2
+ if (promise.status === "fulfilled")
3
+ return promise.value;
4
+ if (promise.status === "rejected")
5
+ throw promise.reason;
6
+ if (promise.status === undefined) {
7
+ promise.status = "pending";
8
+ promise.then((v) => {
9
+ promise.status = "fulfilled";
10
+ promise.value = v;
11
+ }, (e) => {
12
+ promise.status = "rejected";
13
+ promise.reason = e;
14
+ });
15
+ }
16
+ throw promise; // pending — <Suspense> awaits it; the self-tag above wakes the re-render.
17
+ }
18
+ //# sourceMappingURL=read-deferred.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-deferred.js","sourceRoot":"","sources":["../src/read-deferred.ts"],"names":[],"mappings":"AAcA,MAAM,UAAU,YAAY,CAAI,OAAoB;IAClD,IAAI,OAAO,CAAC,MAAM,KAAK,WAAW;QAAE,OAAO,OAAO,CAAC,KAAU,CAAA;IAC7D,IAAI,OAAO,CAAC,MAAM,KAAK,UAAU;QAAE,MAAM,OAAO,CAAC,MAAM,CAAA;IACvD,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QACjC,OAAO,CAAC,MAAM,GAAG,SAAS,CAAA;QAC1B,OAAO,CAAC,IAAI,CACV,CAAC,CAAC,EAAE,EAAE;YACJ,OAAO,CAAC,MAAM,GAAG,WAAW,CAAA;YAC5B,OAAO,CAAC,KAAK,GAAG,CAAC,CAAA;QACnB,CAAC,EACD,CAAC,CAAC,EAAE,EAAE;YACJ,OAAO,CAAC,MAAM,GAAG,UAAU,CAAA;YAC3B,OAAO,CAAC,MAAM,GAAG,CAAC,CAAA;QACpB,CAAC,CACF,CAAA;IACH,CAAC;IACD,MAAM,OAAO,CAAA,CAAC,0EAA0E;AAC1F,CAAC"}
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@nifrajs/web-preact",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Preact render adapter for @nifrajs/web — streaming SSR + hydration (no build plugin).",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr"
13
+ ],
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "bun": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./client": {
25
+ "bun": "./src/client.ts",
26
+ "types": "./dist/client.d.ts",
27
+ "default": "./dist/client.js"
28
+ },
29
+ "./await": {
30
+ "bun": "./src/await.ts",
31
+ "types": "./dist/await.d.ts",
32
+ "default": "./dist/await.js"
33
+ },
34
+ "./fetcher": {
35
+ "bun": "./src/fetcher.ts",
36
+ "types": "./dist/fetcher.d.ts",
37
+ "default": "./dist/fetcher.js"
38
+ },
39
+ "./query": {
40
+ "bun": "./src/query.ts",
41
+ "types": "./dist/query.d.ts",
42
+ "default": "./dist/query.js"
43
+ },
44
+ "./i18n": {
45
+ "bun": "./src/i18n.ts",
46
+ "types": "./dist/i18n.d.ts",
47
+ "default": "./dist/i18n.js"
48
+ },
49
+ "./image": {
50
+ "bun": "./src/image.ts",
51
+ "types": "./dist/image.d.ts",
52
+ "default": "./dist/image.js"
53
+ },
54
+ "./content": {
55
+ "bun": "./src/content.ts",
56
+ "types": "./dist/content.d.ts",
57
+ "default": "./dist/content.js"
58
+ }
59
+ },
60
+ "files": [
61
+ "dist",
62
+ "src"
63
+ ],
64
+ "sideEffects": false,
65
+ "scripts": {
66
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
67
+ },
68
+ "license": "MIT",
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "dependencies": {
73
+ "@nifrajs/i18n": "workspace:*",
74
+ "@nifrajs/image": "workspace:*",
75
+ "@nifrajs/web": "workspace:*",
76
+ "preact-render-to-string": "^6.5.0"
77
+ },
78
+ "peerDependencies": {
79
+ "preact": "^10.19.0"
80
+ },
81
+ "devDependencies": {
82
+ "preact": "^10.19.0"
83
+ },
84
+ "repository": {
85
+ "type": "git",
86
+ "url": "git+https://github.com/nifrajs/nifra.git",
87
+ "directory": "packages/web-preact"
88
+ },
89
+ "bugs": {
90
+ "url": "https://github.com/nifrajs/nifra/issues"
91
+ },
92
+ "homepage": "https://github.com/nifrajs/nifra#readme"
93
+ }
package/src/await.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { Deferred } from "@nifrajs/web"
2
+ import type { ComponentChildren, VNode } from "preact"
3
+ /**
4
+ * `@nifrajs/web-preact/await` — the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
5
+ * and imports only `preact`/`preact/compat` + a type from `@nifrajs/web` (never the server renderer), so
6
+ * route components can use it without dragging server code into the client bundle. No JSX (the package
7
+ * builds with plain `tsc`).
8
+ *
9
+ * Preact's compat has no React 19 `use()` hook, so `readDeferred` (in `./read-deferred`) reads the
10
+ * React-style thenable the core tags (`status`/`value`/`reason`, via the streamed
11
+ * `__nifraResolve`/`__nifraReject`) synchronously: on the client a settled deferred renders its content
12
+ * directly into the boundary the server streamed (no re-suspend, no fallback flash). While pending it
13
+ * throws the promise so `<Suspense>` streams the fallback (server) / shows it until settle (client).
14
+ */
15
+ import { Component, createElement, Suspense } from "preact/compat"
16
+ import { readDeferred, type Thenable } from "./read-deferred.ts"
17
+
18
+ export interface AwaitProps<T> {
19
+ /** A `Deferred<T>` from a loader's `defer(...)`, or an already-resolved value (client nav). */
20
+ readonly resolve: Deferred<T> | T
21
+ readonly fallback?: ComponentChildren
22
+ /** Rendered if the deferred rejects (the streamed `__nifraReject`). Without it the error
23
+ * propagates to the nearest error boundary. */
24
+ readonly errorFallback?: (error: unknown) => ComponentChildren
25
+ readonly children: (value: T) => ComponentChildren
26
+ }
27
+
28
+ // Catches the rejection `readDeferred` throws when the deferred rejects → renders `errorFallback`. A
29
+ // class is required (error boundaries have no hook form). Local so `<Await>` is self-contained.
30
+ class AwaitBoundary extends Component<
31
+ { fallback: (error: unknown) => ComponentChildren; children?: ComponentChildren },
32
+ { caught: boolean; error: unknown }
33
+ > {
34
+ override state: { caught: boolean; error: unknown } = { caught: false, error: undefined }
35
+ static override getDerivedStateFromError(error: unknown): { caught: boolean; error: unknown } {
36
+ return { caught: true, error }
37
+ }
38
+ override render(): ComponentChildren {
39
+ return this.state.caught ? this.props.fallback(this.state.error) : this.props.children
40
+ }
41
+ }
42
+
43
+ function isDeferred<T>(value: Deferred<T> | T): value is Deferred<T> {
44
+ return (
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ (value as { __nifra_deferred?: unknown }).__nifra_deferred === true
48
+ )
49
+ }
50
+
51
+ // Type-erased (non-generic) so `createElement` infers cleanly; `Await<T>` casts at the call site.
52
+ function Resolved(props: {
53
+ marker: Deferred<unknown>
54
+ render: (value: unknown) => ComponentChildren
55
+ }): ComponentChildren {
56
+ return props.render(readDeferred(props.marker.promise as Thenable<unknown>))
57
+ }
58
+
59
+ /**
60
+ * Render deferred loader data: show `fallback` until the `Deferred` settles (streamed in by the
61
+ * server), then `children(value)`. An already-resolved `resolve` (a client navigation awaited it)
62
+ * renders immediately. Pairs with a loader's `defer(...)`.
63
+ */
64
+ export function Await<T>(props: AwaitProps<T>): VNode | ComponentChildren {
65
+ if (!isDeferred(props.resolve)) return props.children(props.resolve)
66
+ const suspense = createElement(
67
+ Suspense,
68
+ { fallback: props.fallback },
69
+ createElement(Resolved, {
70
+ marker: props.resolve,
71
+ // safe: the registry promise resolves to T; Resolved is type-erased to satisfy createElement.
72
+ render: props.children as (value: unknown) => ComponentChildren,
73
+ }),
74
+ )
75
+ return props.errorFallback
76
+ ? createElement(AwaitBoundary, { fallback: props.errorFallback }, suspense)
77
+ : suspense
78
+ }
package/src/client.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { MountRouterOptions, RenderProps } from "@nifrajs/web"
2
+ /**
3
+ * @nifrajs/web-preact/client — Preact client runtime. `hydrate` hydrates a single SSR'd route;
4
+ * `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
5
+ * `useSyncExternalStore` from preact/compat) and re-renders the matched chain on every client
6
+ * navigation (no full reload). Kept in its own entry so server code (preact-render-to-string) stays
7
+ * out of the client bundle.
8
+ */
9
+ import { type FunctionComponent, h, hydrate as preactHydrate } from "preact"
10
+ import { useSyncExternalStore } from "preact/compat"
11
+ import { compose } from "./compose.ts"
12
+ import { setMountedRouter } from "./fetcher.ts"
13
+
14
+ // The `_error` boundary chain element — defined in its own (DOM-free) module, re-exported here so
15
+ // nifra's client codegen resolves it from `@nifrajs/web-preact/client` alongside `mountRouter`.
16
+ export { errorBoundary } from "./error.ts"
17
+
18
+ /** Hydrate a server-rendered Preact layout `chain` (with the loader `props`) inside `container`. */
19
+ export function hydrate(chain: readonly unknown[], props: RenderProps, container: unknown): void {
20
+ preactHydrate(compose(chain, props), container as Element)
21
+ }
22
+
23
+ /**
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
26
+ * navigations swap routes without a full reload. Preact's compat `useSyncExternalStore` is 2-arg
27
+ * (no `getServerSnapshot`); `router.snapshot` is deterministic, so hydration matches the SSR markup.
28
+ */
29
+ export function mountRouter(options: MountRouterOptions): void {
30
+ const { router, routes, container } = options
31
+ setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
32
+ const Router: FunctionComponent = () => {
33
+ const state = useSyncExternalStore(router.subscribe, router.snapshot)
34
+ return compose(routes[state.routeId] ?? [], {
35
+ data: state.data,
36
+ actionData: state.actionData,
37
+ pending: state.pending,
38
+ // The in-flight submission (for optimistic UI) — spread only when present.
39
+ ...(state.submission ? { submission: state.submission } : {}),
40
+ })
41
+ }
42
+ preactHydrate(h(Router, null), container as Element)
43
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { RenderProps } from "@nifrajs/web"
2
+ import { type ComponentType, h, type VNode } from "preact"
3
+
4
+ /**
5
+ * Fold a layout chain (outermost layout → page) into a single Preact tree: the page (innermost)
6
+ * 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`.
9
+ */
10
+ export function compose(chain: readonly unknown[], props: RenderProps): VNode {
11
+ const last = chain.length - 1
12
+ // The chain holds opaque framework components; cast at this plumbing boundary. The page gets the
13
+ // loader props (typed via ComponentType<RenderProps>), then we widen `h`'s VNode<Attributes &
14
+ // RenderProps> to the uniform VNode the fold accumulates into. Preact's VNode<P> is invariant in P
15
+ // 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).
17
+ let node: VNode = h(chain[last] as ComponentType<RenderProps>, props) as VNode
18
+ for (let i = last - 1; i >= 0; i--) {
19
+ // children passed as the 3rd arg — Preact's canonical form (mirrors the React adapter).
20
+ node = h(chain[i] as ComponentType, null, node)
21
+ }
22
+ return node
23
+ }
package/src/content.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@nifrajs/web-preact/content` — render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
3
+ * `.html`) into the DOM via `dangerouslySetInnerHTML`. Pairs with content collections:
4
+ * `<Content html={entry.html} />`.
5
+ *
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.
8
+ */
9
+ import { createElement, type JSX, type VNode } from "preact"
10
+
11
+ export interface ContentProps
12
+ extends Omit<JSX.HTMLAttributes<HTMLElement>, "dangerouslySetInnerHTML"> {
13
+ /** Trusted, pre-rendered HTML (e.g. `entry.html`). */
14
+ readonly html: string
15
+ /** Wrapper element (default `"div"`). */
16
+ readonly as?: string
17
+ }
18
+
19
+ /** Render trusted HTML into a wrapper element. Extra props (`class`, `id`, `style`, …) pass through. */
20
+ export function Content({ html, as, ...rest }: ContentProps): VNode {
21
+ // Typed intermediate (like `<Image>`) keeps the inferred `VNode` generic stable under
22
+ // exactOptionalPropertyTypes (a bare object literal infers an over-specific, non-assignable VNode).
23
+ const props: JSX.HTMLAttributes<HTMLElement> = {
24
+ ...rest,
25
+ dangerouslySetInnerHTML: { __html: html },
26
+ }
27
+ return createElement(as ?? "div", props)
28
+ }
package/src/error.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `errorBoundary` — the Preact error-boundary chain element for nifra's `_error.tsx`. Its own module
3
+ * (imports only `preact`) so it's unit-testable off the DOM and the client codegen can import it from
4
+ * `@nifrajs/web-preact/client` (which re-exports it).
5
+ */
6
+ import { Component, type ComponentChildren, createElement, type FunctionComponent } from "preact"
7
+
8
+ /**
9
+ * Build an error-boundary chain element bound to `fallback` (a route's `_error` component). nifra's
10
+ * client codegen inserts it before the page in the matched chain; a render error in the subtree renders
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.
13
+ */
14
+ export function errorBoundary(fallback: unknown): unknown {
15
+ type Props = { children?: ComponentChildren }
16
+ type State = { error: Error | null }
17
+ return class NifraErrorBoundary extends Component<Props, State> {
18
+ constructor(props: Props) {
19
+ super(props)
20
+ this.state = { error: null }
21
+ }
22
+ static override getDerivedStateFromError(error: Error): State {
23
+ return { error }
24
+ }
25
+ override render(): ComponentChildren {
26
+ const { error } = this.state
27
+ if (error === null) return this.props.children
28
+ // `fallback` is the route's `_error` default export — opaque to the core, a component here.
29
+ const Fallback = fallback as FunctionComponent<{ data: { name: string; message: string } }>
30
+ return createElement(Fallback, { data: { name: error.name, message: error.message } })
31
+ }
32
+ }
33
+ }
package/src/fetcher.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
2
+ /**
3
+ * `@nifrajs/web-preact/fetcher` — Preact bindings for concurrent fetchers. `useFetcher(key)` subscribes a
4
+ * component to an independent {@link Fetcher} (via `useSyncExternalStore` from preact/compat) and
5
+ * returns its reactive state plus `load`/`submit`; `useFetchers()` subscribes to the whole live
6
+ * collection. Imports only `preact/compat` + `@nifrajs/web` types, so route components can use it on the
7
+ * server *and* client. No JSX (the package builds with plain `tsc`).
8
+ *
9
+ * The router that owns the fetchers is the one `mountRouter` hydrated — it registers itself here via
10
+ * `setMountedRouter`. On the server (no mount) there is no router, so the hooks return an idle state
11
+ * (fetchers are client-only). Preact's compat `useSyncExternalStore` is 2-arg (no `getServerSnapshot`);
12
+ * with no router the `getSnapshot` already returns idle, so the first client render after `mountRouter`
13
+ * matches the SSR markup — no hydration mismatch.
14
+ */
15
+ import { useSyncExternalStore } from "preact/compat"
16
+
17
+ // The active client router (set by `mountRouter`). Module-scoped: the browser mounts one app per
18
+ // page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
19
+ let mountedRouter: ClientRouter | undefined
20
+
21
+ /** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
22
+ export function setMountedRouter(router: ClientRouter | undefined): void {
23
+ mountedRouter = router
24
+ }
25
+
26
+ // Stable idle values for the server / pre-mount snapshot (stable refs → no `useSyncExternalStore`
27
+ // loop, no hydration mismatch).
28
+ const IDLE: FetcherState = { pending: false, data: undefined }
29
+ const NO_FETCHERS: readonly Fetcher[] = []
30
+ const noopSubscribe = (): (() => void) => () => {}
31
+ const noopAsync = async (): Promise<void> => {}
32
+ const idleSnapshot = (): FetcherState => IDLE
33
+ const noFetchers = (): readonly Fetcher[] => NO_FETCHERS
34
+
35
+ /** A fetcher's reactive {@link FetcherState} plus its imperative `load`/`submit`. */
36
+ export interface FetcherHandle extends FetcherState {
37
+ /** Load a route path's loader data into this fetcher (concurrent; doesn't touch the active view). */
38
+ readonly load: (path: string) => Promise<void>
39
+ /** Submit an action into this fetcher (concurrent); honors `X-Nifra-Revalidate`. */
40
+ readonly submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
41
+ }
42
+
43
+ /**
44
+ * Subscribe to the independent fetcher for `key` (created lazily, stable across renders). Returns its
45
+ * state (`pending`/`data`/`actionData`/`submission`) + `load`/`submit`. Multiple `useFetcher` calls
46
+ * with different keys run concurrently without disturbing the active route or each other.
47
+ */
48
+ export function useFetcher(key: string): FetcherHandle {
49
+ const fetcher = mountedRouter?.fetcher(key)
50
+ const state = useSyncExternalStore(
51
+ fetcher?.subscribe ?? noopSubscribe,
52
+ fetcher?.snapshot ?? idleSnapshot,
53
+ )
54
+ return { ...state, load: fetcher?.load ?? noopAsync, submit: fetcher?.submit ?? noopAsync }
55
+ }
56
+
57
+ /**
58
+ * Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
59
+ * each entry's `.snapshot()` for its state. Re-renders whenever any fetcher transitions or a new one
60
+ * is created.
61
+ */
62
+ export function useFetchers(): readonly Fetcher[] {
63
+ return useSyncExternalStore(
64
+ mountedRouter?.subscribeFetchers ?? noopSubscribe,
65
+ mountedRouter?.fetchers ?? noFetchers,
66
+ )
67
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `@nifrajs/web-preact/i18n` — Preact bindings for `@nifrajs/i18n`. `<I18nProvider locale messages>` builds a
3
+ * `Formatter` (memoized) and provides it; `useT()` reads it. Both `locale` + `messages` are
4
+ * serializable, so a loader returns them, the page renders with the negotiated catalog on the server,
5
+ * and the client rebuilds the same formatter from the same props (no mismatch). Uses `preact` +
6
+ * `preact/hooks` — no JSX.
7
+ */
8
+ import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
9
+ import { type ComponentChildren, createContext, createElement, type VNode } from "preact"
10
+ import { useContext, useMemo } from "preact/hooks"
11
+
12
+ const I18nContext = createContext<Formatter | null>(null)
13
+
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 `{}`.
16
+ // The call is correct at runtime (proven by the tests), so invoke it through a precisely-typed element
17
+ // factory that states the real contract (`{ value }` + children), rather than scatter `any`.
18
+ const createProvider = createElement as (
19
+ type: typeof I18nContext.Provider,
20
+ props: { value: Formatter | null },
21
+ children?: ComponentChildren,
22
+ ) => VNode
23
+
24
+ export interface I18nProviderProps {
25
+ readonly locale: string
26
+ readonly messages: Messages
27
+ readonly children?: ComponentChildren
28
+ }
29
+
30
+ /** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
31
+ * `locale`/`messages`, so switching locale rebuilds it and re-renders consumers. */
32
+ export function I18nProvider(props: I18nProviderProps): VNode {
33
+ const formatter = useMemo(
34
+ () => createFormatter(props.locale, props.messages),
35
+ [props.locale, props.messages],
36
+ )
37
+ return createProvider(I18nContext.Provider, { value: formatter }, props.children)
38
+ }
39
+
40
+ /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
41
+ export function useT(): Formatter {
42
+ const formatter = useContext(I18nContext)
43
+ if (formatter === null) {
44
+ throw new Error("[nifra/web-preact] useT() must be used within an <I18nProvider>")
45
+ }
46
+ return formatter
47
+ }
package/src/image.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `@nifrajs/web-preact/image` — a CLS-safe responsive Preact `<Image>`. A thin wrapper over `resolveImage`
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
5
+ * spreads `toHtmlAttrs`) and forwards any extra DOM props (`class`, `style`, `id`, `data-*`, handlers).
6
+ * Resizing is delegated to the `loader` (an image CDN); nifra bundles no codec. No JSX (`h`).
7
+ */
8
+ import { type ImageLoader, type ImageProps, resolveImage, toHtmlAttrs } from "@nifrajs/image"
9
+ import { h, type JSX, type VNode } from "preact"
10
+
11
+ /** Attributes `resolveImage` computes — callers can't override them via DOM passthrough. */
12
+ type ComputedAttrs =
13
+ | "src"
14
+ | "srcset"
15
+ | "sizes"
16
+ | "width"
17
+ | "height"
18
+ | "alt"
19
+ | "loading"
20
+ | "decoding"
21
+ | "fetchpriority"
22
+
23
+ export interface ImageComponentProps
24
+ extends ImageProps,
25
+ Omit<JSX.IntrinsicElements["img"], ComputedAttrs | keyof ImageProps> {
26
+ /** CDN URL builder. Defaults to the identity loader (no transform — still CLS-safe + lazy). */
27
+ readonly loader?: ImageLoader
28
+ }
29
+
30
+ /**
31
+ * Render a responsive, CLS-safe `<img>`. `width`+`height` are required (reserve layout space);
32
+ * `priority` marks the LCP image (`eager` + `fetchpriority="high"`). Extra DOM props pass through.
33
+ */
34
+ export function Image(props: ImageComponentProps): VNode {
35
+ // Strip the nifra-only props (not valid DOM attributes) before forwarding the rest to <img>.
36
+ const { loader, widths, quality, priority, ...rest } = props
37
+ const attrs = toHtmlAttrs(resolveImage(props, loader))
38
+ // `attrs` wins over the raw src/width/height/alt/sizes/loading still present in `rest`. The typed
39
+ // local pins Preact's `h` to its string-tag overload (spreads skip excess-property checks).
40
+ const imgProps: JSX.IntrinsicElements["img"] = { ...rest, ...attrs }
41
+ return h("img", imgProps)
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { RenderAdapter } from "@nifrajs/web"
2
+ /**
3
+ * @nifrajs/web-preact — the Preact render adapter for @nifrajs/web (server side). Streaming SSR via
4
+ * `renderToReadableStream` from `preact-render-to-string/stream` (a Web `ReadableStream<Uint8Array>`,
5
+ * the seam's native shape); the layout-chain fold is in `./compose`. Client hydration lives in
6
+ * `@nifrajs/web-preact/client`. Preact uses `h()` render functions, so no JSX/build plugin is needed
7
+ * (contrast `@nifrajs/web-solid`'s Babel plugin).
8
+ */
9
+ import { renderToString as preactRenderToString } from "preact-render-to-string"
10
+ import { renderToReadableStream } from "preact-render-to-string/stream"
11
+ import { compose } from "./compose.ts"
12
+
13
+ /** The Preact server render adapter — pass to @nifrajs/web's `renderPage`. */
14
+ export const preactAdapter: RenderAdapter = {
15
+ // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). The sync
16
+ // `renderToString` emits the same markup as the stream renderer without the chunking machinery.
17
+ renderToString(chain, props) {
18
+ return preactRenderToString(compose(chain, props))
19
+ },
20
+ renderToStream(chain, props) {
21
+ // `renderToReadableStream` yields a Web ReadableStream<Uint8Array>; <Suspense> boundaries
22
+ // (preact/compat) stream as they resolve, mirroring the React adapter.
23
+ return renderToReadableStream(compose(chain, props))
24
+ },
25
+ // Preact reconciles against the existing DOM on hydrate (like React), so there's no per-document
26
+ // bootstrap script — the seam allows the empty string (contrast Solid's generateHydrationScript).
27
+ hydrationHead() {
28
+ return ""
29
+ },
30
+ }