@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/await.d.ts +18 -0
- package/dist/await.d.ts.map +1 -0
- package/dist/await.js +52 -0
- package/dist/await.js.map +1 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +40 -0
- package/dist/client.js.map +1 -0
- package/dist/compose.d.ts +10 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +22 -0
- package/dist/compose.js.map +1 -0
- package/dist/content.d.ts +18 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +20 -0
- package/dist/content.js.map +1 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +32 -0
- package/dist/error.js.map +1 -0
- package/dist/fetcher.d.ts +23 -0
- package/dist/fetcher.d.ts.map +1 -0
- package/dist/fetcher.js +48 -0
- package/dist/fetcher.js.map +1 -0
- package/dist/i18n.d.ts +20 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +31 -0
- package/dist/i18n.js.map +1 -0
- package/dist/image.d.ts +22 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +23 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/query.d.ts +17 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +58 -0
- package/dist/query.js.map +1 -0
- package/dist/read-deferred.d.ts +15 -0
- package/dist/read-deferred.d.ts.map +1 -0
- package/dist/read-deferred.js +18 -0
- package/dist/read-deferred.js.map +1 -0
- package/package.json +93 -0
- package/src/await.ts +78 -0
- package/src/client.ts +43 -0
- package/src/compose.ts +23 -0
- package/src/content.ts +28 -0
- package/src/error.ts +33 -0
- package/src/fetcher.ts +67 -0
- package/src/i18n.ts +47 -0
- package/src/image.ts +42 -0
- package/src/index.ts +30 -0
- package/src/query.ts +74 -0
- 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
|
+
}
|