@nifrajs/web-react 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 +29 -0
- package/dist/await.d.ts.map +1 -0
- package/dist/await.js +50 -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 +39 -0
- package/dist/client.js.map +1 -0
- package/dist/compose.d.ts +9 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +16 -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 +14 -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 +45 -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 +25 -0
- package/dist/i18n.js.map +1 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +21 -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/island.d.ts +27 -0
- package/dist/island.d.ts.map +1 -0
- package/dist/island.js +23 -0
- package/dist/island.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/package.json +101 -0
- package/src/await.ts +75 -0
- package/src/client.ts +42 -0
- package/src/compose.ts +17 -0
- package/src/content.ts +27 -0
- package/src/error.ts +33 -0
- package/src/fetcher.ts +66 -0
- package/src/i18n.ts +36 -0
- package/src/image.ts +43 -0
- package/src/index.ts +30 -0
- package/src/island.ts +39 -0
- package/src/query.ts +75 -0
package/dist/island.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/island` — the `<Island>` marker for no-framework islands. Server-renders its
|
|
3
|
+
* children inside a `<nifra-island>` element that the framework-agnostic `mountIslands` runtime
|
|
4
|
+
* (`@nifrajs/web/islands`) picks up on the client. Imports only `react` (+ a type) — no `react-dom`,
|
|
5
|
+
* no JSX (the package builds with plain `tsc`).
|
|
6
|
+
*
|
|
7
|
+
* Use on a route with `export const hydrate = false`: the page ships zero framework JS, and only the
|
|
8
|
+
* island bundle (your enhancers + the tiny runtime) loads via the route's `islandScripts`. The island's
|
|
9
|
+
* SSR markup is real content (works with JS off); the enhancer upgrades it when it loads.
|
|
10
|
+
*/
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
/** Render a `<nifra-island>` marker around server-rendered `children`. The client enhancer mounts the
|
|
13
|
+
* interactivity (see `mountIslands`). Props are JSON-encoded into `data-props`; React escapes the
|
|
14
|
+
* attribute value and the client reads the decoded `dataset.props`. */
|
|
15
|
+
export function Island({ id, props, strategy = "load", children }) {
|
|
16
|
+
return createElement("nifra-island", {
|
|
17
|
+
"data-id": id,
|
|
18
|
+
"data-strategy": strategy,
|
|
19
|
+
// Omit `data-props` entirely when there are no props (keeps markup minimal + parse a no-op).
|
|
20
|
+
...(props === undefined ? {} : { "data-props": JSON.stringify(props) }),
|
|
21
|
+
}, children);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=island.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"island.js","sourceRoot":"","sources":["../src/island.ts"],"names":[],"mappings":"AACA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAkB,MAAM,OAAO,CAAA;AAarD;;uEAEuE;AACvE,MAAM,UAAU,MAAM,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,GAAG,MAAM,EAAE,QAAQ,EAAe;IAC5E,OAAO,aAAa,CAClB,cAAc,EACd;QACE,SAAS,EAAE,EAAE;QACb,eAAe,EAAE,QAAQ;QACzB,6FAA6F;QAC7F,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;KACxE,EACD,QAAQ,CACT,CAAA;AACH,CAAC"}
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type QueryClient, type QueryState } from "@nifrajs/web";
|
|
2
|
+
/** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
|
|
3
|
+
export declare function useQueryClient(): Pick<QueryClient, "invalidateQueries">;
|
|
4
|
+
/** A query's reactive {@link QueryState} plus `isPending` + `refetch`. */
|
|
5
|
+
export interface UseQueryResult<T> extends QueryState<T> {
|
|
6
|
+
/** `status === "pending"` — no data yet (initial load). */
|
|
7
|
+
readonly isPending: boolean;
|
|
8
|
+
/** Force a refetch (ignores `staleTime`). */
|
|
9
|
+
readonly refetch: () => Promise<T>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to the keyed query for `key`, fetched via `fn`. Returns `{ status, data, error, isFetching,
|
|
13
|
+
* updatedAt, isPending, refetch }`. Concurrent `useQuery`s with the same key share one cache entry +
|
|
14
|
+
* one in-flight fetch (dedup). Refetches on mount and when the key changes; SSR-idle.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useQuery<T>(key: unknown, fn: () => Promise<T>): UseQueryResult<T>;
|
|
17
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAEhB,KAAK,UAAU,EAChB,MAAM,cAAc,CAAA;AAqBrB,wGAAwG;AACxG,wBAAgB,cAAc,IAAI,IAAI,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAEvE;AAgBD,0EAA0E;AAC1E,MAAM,WAAW,cAAc,CAAC,CAAC,CAAE,SAAQ,UAAU,CAAC,CAAC,CAAC;IACtD,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,6CAA6C;IAC7C,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,CAAA;CACnC;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAgBjF"}
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createQueryClient, } from "@nifrajs/web";
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-react/query` — React bindings for the keyed query-cache. `useQuery(key, fn)` subscribes a
|
|
4
|
+
* component to a query (via `useSyncExternalStore`) and fetches on mount / key change; `useQueryClient`
|
|
5
|
+
* exposes `invalidateQueries` for after a mutation. Imports only `react` + `@nifrajs/web` (never
|
|
6
|
+
* `react-dom/*`), so route components use it on the server *and* client. No JSX (the package builds
|
|
7
|
+
* with plain `tsc`).
|
|
8
|
+
*
|
|
9
|
+
* The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
|
|
10
|
+
* there is none, so `useQuery` renders the idle/pending state (queries are client-first; loaders are
|
|
11
|
+
* the SSR data source). The first client render uses the same idle server-snapshot, so no mismatch.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
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-react] 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, 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;;;;;;;;;;GAUG;AACH,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AAEvD,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,0EAA0E,CAAC,CAAA;AAC7F,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,EACvC,YAAY,CACb,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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/web-react",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
|
|
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
|
+
"./island": {
|
|
35
|
+
"bun": "./src/island.ts",
|
|
36
|
+
"types": "./dist/island.d.ts",
|
|
37
|
+
"default": "./dist/island.js"
|
|
38
|
+
},
|
|
39
|
+
"./fetcher": {
|
|
40
|
+
"bun": "./src/fetcher.ts",
|
|
41
|
+
"types": "./dist/fetcher.d.ts",
|
|
42
|
+
"default": "./dist/fetcher.js"
|
|
43
|
+
},
|
|
44
|
+
"./query": {
|
|
45
|
+
"bun": "./src/query.ts",
|
|
46
|
+
"types": "./dist/query.d.ts",
|
|
47
|
+
"default": "./dist/query.js"
|
|
48
|
+
},
|
|
49
|
+
"./i18n": {
|
|
50
|
+
"bun": "./src/i18n.ts",
|
|
51
|
+
"types": "./dist/i18n.d.ts",
|
|
52
|
+
"default": "./dist/i18n.js"
|
|
53
|
+
},
|
|
54
|
+
"./image": {
|
|
55
|
+
"bun": "./src/image.ts",
|
|
56
|
+
"types": "./dist/image.d.ts",
|
|
57
|
+
"default": "./dist/image.js"
|
|
58
|
+
},
|
|
59
|
+
"./content": {
|
|
60
|
+
"bun": "./src/content.ts",
|
|
61
|
+
"types": "./dist/content.d.ts",
|
|
62
|
+
"default": "./dist/content.js"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"files": [
|
|
66
|
+
"dist",
|
|
67
|
+
"src"
|
|
68
|
+
],
|
|
69
|
+
"sideEffects": false,
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
72
|
+
},
|
|
73
|
+
"license": "MIT",
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"@nifrajs/i18n": "workspace:*",
|
|
79
|
+
"@nifrajs/image": "workspace:*",
|
|
80
|
+
"@nifrajs/web": "workspace:*"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"react": "^19.0.0",
|
|
84
|
+
"react-dom": "^19.0.0"
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@types/react": "^19.0.0",
|
|
88
|
+
"@types/react-dom": "^19.0.0",
|
|
89
|
+
"react": "^19.0.0",
|
|
90
|
+
"react-dom": "^19.0.0"
|
|
91
|
+
},
|
|
92
|
+
"repository": {
|
|
93
|
+
"type": "git",
|
|
94
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
95
|
+
"directory": "packages/web-react"
|
|
96
|
+
},
|
|
97
|
+
"bugs": {
|
|
98
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
99
|
+
},
|
|
100
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
101
|
+
}
|
package/src/await.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Deferred } from "@nifrajs/web"
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-react/await` — the `<Await>` primitive for deferred loader data (`defer()`). Isomorphic
|
|
4
|
+
* and imports only `react` + a type from `@nifrajs/web` (never `react-dom/server`), so route components
|
|
5
|
+
* can use it without dragging server code into the client bundle. No JSX (the package builds with
|
|
6
|
+
* plain `tsc`).
|
|
7
|
+
*
|
|
8
|
+
* The server streams the boundary: `use(promise)` suspends, React flushes the fallback then the
|
|
9
|
+
* resolved content mid-stream. On the client the registry promise is already settled (the core's
|
|
10
|
+
* streamed `__nifraResolve` script tagged it `fulfilled`), so `use()` returns the value synchronously
|
|
11
|
+
* — React hydrates the server-revealed boundary's content directly (no re-fetch, no fallback flash).
|
|
12
|
+
*/
|
|
13
|
+
import { Component, createElement, type ReactNode, Suspense, use } from "react"
|
|
14
|
+
|
|
15
|
+
export interface AwaitProps<T> {
|
|
16
|
+
/** A `Deferred<T>` from a loader's `defer(...)`, or an already-resolved value (client nav). */
|
|
17
|
+
readonly resolve: Deferred<T> | T
|
|
18
|
+
readonly fallback?: ReactNode
|
|
19
|
+
/** Rendered if the deferred rejects (the streamed `__nifraReject`). Without it the error
|
|
20
|
+
* propagates to the nearest error boundary. */
|
|
21
|
+
readonly errorFallback?: (error: unknown) => ReactNode
|
|
22
|
+
readonly children: (value: T) => ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Catches the rejection `use()` throws when the deferred rejects → renders `errorFallback`. A class
|
|
26
|
+
// is required (React error boundaries have no hook form). Local so `<Await>` is self-contained.
|
|
27
|
+
class AwaitBoundary extends Component<
|
|
28
|
+
{ fallback: (error: unknown) => ReactNode; children?: ReactNode },
|
|
29
|
+
{ caught: boolean; error: unknown }
|
|
30
|
+
> {
|
|
31
|
+
override state: { caught: boolean; error: unknown } = { caught: false, error: undefined }
|
|
32
|
+
static getDerivedStateFromError(error: unknown): { caught: boolean; error: unknown } {
|
|
33
|
+
return { caught: true, error }
|
|
34
|
+
}
|
|
35
|
+
override render(): ReactNode {
|
|
36
|
+
return this.state.caught ? this.props.fallback(this.state.error) : this.props.children
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDeferred<T>(value: Deferred<T> | T): value is Deferred<T> {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === "object" &&
|
|
43
|
+
value !== null &&
|
|
44
|
+
(value as { __nifra_deferred?: unknown }).__nifra_deferred === true
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Type-erased (non-generic) so `createElement` infers cleanly; `Await<T>` casts at the call site.
|
|
49
|
+
function Resolved(props: {
|
|
50
|
+
marker: Deferred<unknown>
|
|
51
|
+
render: (value: unknown) => ReactNode
|
|
52
|
+
}): ReactNode {
|
|
53
|
+
return props.render(use(props.marker.promise))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render deferred loader data: show `fallback` until the `Deferred` settles (streamed in by the
|
|
58
|
+
* server), then `children(value)`. An already-resolved `resolve` (a client navigation awaited it)
|
|
59
|
+
* renders immediately. Pairs with a loader's `defer(...)`.
|
|
60
|
+
*/
|
|
61
|
+
export function Await<T>(props: AwaitProps<T>): ReactNode {
|
|
62
|
+
if (!isDeferred(props.resolve)) return props.children(props.resolve)
|
|
63
|
+
const suspense = createElement(
|
|
64
|
+
Suspense,
|
|
65
|
+
{ fallback: props.fallback },
|
|
66
|
+
createElement(Resolved, {
|
|
67
|
+
marker: props.resolve,
|
|
68
|
+
// safe: the registry promise resolves to T; Resolved is type-erased to satisfy createElement.
|
|
69
|
+
render: props.children as (value: unknown) => ReactNode,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
return props.errorFallback
|
|
73
|
+
? createElement(AwaitBoundary, { fallback: props.errorFallback }, suspense)
|
|
74
|
+
: suspense
|
|
75
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MountRouterOptions, RenderProps } from "@nifrajs/web"
|
|
2
|
+
/**
|
|
3
|
+
* @nifrajs/web-react/client — React client runtime. `hydrate` hydrates a single SSR'd route;
|
|
4
|
+
* `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
|
|
5
|
+
* `useSyncExternalStore`) and re-renders the matched chain on every client navigation (no full
|
|
6
|
+
* reload). Kept in its own entry so server code stays out of the client bundle.
|
|
7
|
+
*/
|
|
8
|
+
import { createElement, type FunctionComponent, useSyncExternalStore } from "react"
|
|
9
|
+
import { hydrateRoot } from "react-dom/client"
|
|
10
|
+
import { compose } from "./compose.ts"
|
|
11
|
+
import { setMountedRouter } from "./fetcher.ts"
|
|
12
|
+
|
|
13
|
+
// The `_error` boundary chain element — defined in its own (react-dom-free) module, re-exported here so
|
|
14
|
+
// nifra's client codegen resolves it from `@nifrajs/web-react/client` alongside `mountRouter`.
|
|
15
|
+
export { errorBoundary } from "./error.ts"
|
|
16
|
+
|
|
17
|
+
/** Hydrate a server-rendered React layout `chain` (with the loader `props`) inside `container`. */
|
|
18
|
+
export function hydrate(chain: readonly unknown[], props: RenderProps, container: unknown): void {
|
|
19
|
+
hydrateRoot(container as Element, compose(chain, props))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hydrate a stateful React Router. `useSyncExternalStore` subscribes to the agnostic store and
|
|
24
|
+
* re-renders the matched layout chain on each store change — so client navigations swap routes
|
|
25
|
+
* without a full reload. `getServerSnapshot` (3rd arg) returns the initial state, matching the
|
|
26
|
+
* SSR markup on hydration.
|
|
27
|
+
*/
|
|
28
|
+
export function mountRouter(options: MountRouterOptions): void {
|
|
29
|
+
const { router, routes, container } = options
|
|
30
|
+
setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
|
|
31
|
+
const Router: FunctionComponent = () => {
|
|
32
|
+
const state = useSyncExternalStore(router.subscribe, router.snapshot, router.snapshot)
|
|
33
|
+
return compose(routes[state.routeId] ?? [], {
|
|
34
|
+
data: state.data,
|
|
35
|
+
actionData: state.actionData,
|
|
36
|
+
pending: state.pending,
|
|
37
|
+
// The in-flight submission (for optimistic UI) — spread only when present.
|
|
38
|
+
...(state.submission ? { submission: state.submission } : {}),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
hydrateRoot(container as Element, createElement(Router))
|
|
42
|
+
}
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RenderProps } from "@nifrajs/web"
|
|
2
|
+
import { createElement, type FunctionComponent, type ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fold a layout chain (outermost layout → page) into a single React tree: the page
|
|
6
|
+
* (innermost) receives `props` (the loader data); each layout wraps the child via its
|
|
7
|
+
* `children`. Shared by the server adapter (renderToString) and the client (hydrateRoot).
|
|
8
|
+
*/
|
|
9
|
+
export function compose(chain: readonly unknown[], props: RenderProps): ReactNode {
|
|
10
|
+
const last = chain.length - 1
|
|
11
|
+
let node: ReactNode = createElement(chain[last] as FunctionComponent<RenderProps>, props)
|
|
12
|
+
for (let i = last - 1; i >= 0; i--) {
|
|
13
|
+
// children passed as the 3rd arg (not a `children` prop) — React's canonical form.
|
|
14
|
+
node = createElement(chain[i] as FunctionComponent, null, node)
|
|
15
|
+
}
|
|
16
|
+
return node
|
|
17
|
+
}
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/content` — render pre-rendered HTML (e.g. a `@nifrajs/content` collection entry's
|
|
3
|
+
* `.html`, or any Markdown→HTML string) into the DOM via `dangerouslySetInnerHTML`. Pairs with content
|
|
4
|
+
* collections: `<Content html={entry.html} />`.
|
|
5
|
+
*
|
|
6
|
+
* Security: this injects raw HTML, so the `html` MUST be trusted (your own Markdown/content, rendered
|
|
7
|
+
* at build/server time) — never pass user-supplied HTML without sanitizing it first.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
type ComponentPropsWithoutRef,
|
|
11
|
+
createElement,
|
|
12
|
+
type ElementType,
|
|
13
|
+
type ReactElement,
|
|
14
|
+
} from "react"
|
|
15
|
+
|
|
16
|
+
export interface ContentProps
|
|
17
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "dangerouslySetInnerHTML" | "children"> {
|
|
18
|
+
/** Trusted, pre-rendered HTML (e.g. `entry.html`). */
|
|
19
|
+
readonly html: string
|
|
20
|
+
/** Wrapper element (default `"div"`). */
|
|
21
|
+
readonly as?: ElementType
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Render trusted HTML into a wrapper element. Extra props (`className`, `id`, `style`, …) pass through. */
|
|
25
|
+
export function Content({ html, as, ...rest }: ContentProps): ReactElement {
|
|
26
|
+
return createElement(as ?? "div", { ...rest, dangerouslySetInnerHTML: { __html: html } })
|
|
27
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `errorBoundary` — the React error-boundary chain element for nifra's `_error.tsx`. Kept in its own
|
|
3
|
+
* module (imports only `react`, never `react-dom/client`) so it's unit-testable off the DOM and the
|
|
4
|
+
* client codegen can import it from `@nifrajs/web-react/client` (which re-exports it).
|
|
5
|
+
*/
|
|
6
|
+
import { Component, createElement, type FunctionComponent, type ReactNode } from "react"
|
|
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?: ReactNode }
|
|
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 getDerivedStateFromError(error: Error): State {
|
|
23
|
+
return { error }
|
|
24
|
+
}
|
|
25
|
+
override render(): ReactNode {
|
|
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,66 @@
|
|
|
1
|
+
import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-react/fetcher` — React bindings for concurrent fetchers. `useFetcher(key)` subscribes a
|
|
4
|
+
* component to an independent {@link Fetcher} (via `useSyncExternalStore`) and returns its reactive
|
|
5
|
+
* state plus `load`/`submit`; `useFetchers()` subscribes to the whole live collection. Imports only
|
|
6
|
+
* `react` (never `react-dom/*`), so route components can use it on the server *and* client without
|
|
7
|
+
* dragging either DOM build into the wrong bundle. 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); the first client render after `mountRouter` sees the real fetcher, so
|
|
12
|
+
* there's no hydration mismatch.
|
|
13
|
+
*/
|
|
14
|
+
import { useSyncExternalStore } from "react"
|
|
15
|
+
|
|
16
|
+
// The active client router (set by `mountRouter`). Module-scoped: the browser mounts one app per
|
|
17
|
+
// page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
|
|
18
|
+
let mountedRouter: ClientRouter | undefined
|
|
19
|
+
|
|
20
|
+
/** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
|
|
21
|
+
export function setMountedRouter(router: ClientRouter | undefined): void {
|
|
22
|
+
mountedRouter = router
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Stable idle values for the server / pre-mount snapshot (stable refs → no `useSyncExternalStore`
|
|
26
|
+
// loop, no hydration mismatch).
|
|
27
|
+
const IDLE: FetcherState = { pending: false, data: undefined }
|
|
28
|
+
const NO_FETCHERS: readonly Fetcher[] = []
|
|
29
|
+
const noopSubscribe = (): (() => void) => () => {}
|
|
30
|
+
const noopAsync = async (): Promise<void> => {}
|
|
31
|
+
|
|
32
|
+
/** A fetcher's reactive {@link FetcherState} plus its imperative `load`/`submit`. */
|
|
33
|
+
export interface FetcherHandle extends FetcherState {
|
|
34
|
+
/** Load a route path's loader data into this fetcher (concurrent; doesn't touch the active view). */
|
|
35
|
+
readonly load: (path: string) => Promise<void>
|
|
36
|
+
/** Submit an action into this fetcher (concurrent); honors `X-Nifra-Revalidate`. */
|
|
37
|
+
readonly submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to the independent fetcher for `key` (created lazily, stable across renders). Returns its
|
|
42
|
+
* state (`pending`/`data`/`actionData`/`submission`) + `load`/`submit`. Multiple `useFetcher` calls
|
|
43
|
+
* with different keys run concurrently without disturbing the active route or each other.
|
|
44
|
+
*/
|
|
45
|
+
export function useFetcher(key: string): FetcherHandle {
|
|
46
|
+
const fetcher = mountedRouter?.fetcher(key)
|
|
47
|
+
const state = useSyncExternalStore(
|
|
48
|
+
fetcher?.subscribe ?? noopSubscribe,
|
|
49
|
+
fetcher?.snapshot ?? (() => IDLE),
|
|
50
|
+
() => IDLE, // server: fetchers are client-only → idle (matches the first client render)
|
|
51
|
+
)
|
|
52
|
+
return { ...state, load: fetcher?.load ?? noopAsync, submit: fetcher?.submit ?? noopAsync }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
|
|
57
|
+
* each entry's `.snapshot()` for its state. Re-renders whenever any fetcher transitions or a new one
|
|
58
|
+
* is created.
|
|
59
|
+
*/
|
|
60
|
+
export function useFetchers(): readonly Fetcher[] {
|
|
61
|
+
return useSyncExternalStore(
|
|
62
|
+
mountedRouter?.subscribeFetchers ?? noopSubscribe,
|
|
63
|
+
mountedRouter?.fetchers ?? (() => NO_FETCHERS),
|
|
64
|
+
() => NO_FETCHERS,
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/i18n` — React 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). Imports only `react` +
|
|
6
|
+
* `@nifrajs/i18n`; no JSX (the package builds with plain `tsc`).
|
|
7
|
+
*/
|
|
8
|
+
import { createFormatter, type Formatter, type Messages } from "@nifrajs/i18n"
|
|
9
|
+
import { createContext, createElement, type ReactNode, useContext, useMemo } from "react"
|
|
10
|
+
|
|
11
|
+
const I18nContext = createContext<Formatter | null>(null)
|
|
12
|
+
|
|
13
|
+
export interface I18nProviderProps {
|
|
14
|
+
readonly locale: string
|
|
15
|
+
readonly messages: Messages
|
|
16
|
+
readonly children?: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Provide a {@link Formatter} (built from `locale` + `messages`) to the subtree. Memoized on
|
|
20
|
+
* `locale`/`messages`, so switching locale rebuilds it and re-renders consumers. */
|
|
21
|
+
export function I18nProvider(props: I18nProviderProps): ReactNode {
|
|
22
|
+
const formatter = useMemo(
|
|
23
|
+
() => createFormatter(props.locale, props.messages),
|
|
24
|
+
[props.locale, props.messages],
|
|
25
|
+
)
|
|
26
|
+
return createElement(I18nContext.Provider, { value: formatter }, props.children)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
|
|
30
|
+
export function useT(): Formatter {
|
|
31
|
+
const formatter = useContext(I18nContext)
|
|
32
|
+
if (formatter === null) {
|
|
33
|
+
throw new Error("[nifra/web-react] useT() must be used within an <I18nProvider>")
|
|
34
|
+
}
|
|
35
|
+
return formatter
|
|
36
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/image` — a CLS-safe responsive React `<Image>`. A thin wrapper over
|
|
3
|
+
* `resolveImage` from `@nifrajs/image`: it computes `src`/`srcSet`/`sizes`/`width`/`height`/`loading`/
|
|
4
|
+
* `decoding`/`fetchpriority` and renders a plain `<img>`, forwarding any extra DOM props
|
|
5
|
+
* (`className`, `style`, `id`, event handlers, `data-*`, …). Resizing is delegated to the `loader`
|
|
6
|
+
* (an image CDN); nifra bundles no codec. Imports only `react` + `@nifrajs/image`; no JSX.
|
|
7
|
+
*/
|
|
8
|
+
import { type ImageLoader, type ImageProps, resolveImage } from "@nifrajs/image"
|
|
9
|
+
import { type ComponentPropsWithoutRef, createElement, type ReactElement } from "react"
|
|
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<ComponentPropsWithoutRef<"img">, ComputedAttrs | keyof ImageProps> {
|
|
26
|
+
/** CDN URL builder. Defaults to the identity loader (no transform — still CLS-safe + lazy). */
|
|
27
|
+
readonly loader?: ImageLoader
|
|
28
|
+
/** `data-*` attributes pass through to the `<img>` (React doesn't auto-allow these on a custom
|
|
29
|
+
* component, so they're declared explicitly). */
|
|
30
|
+
readonly [key: `data-${string}`]: string | number | boolean | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render a responsive, CLS-safe `<img>`. `width`+`height` are required (reserve layout space);
|
|
35
|
+
* `priority` marks the LCP image (`eager` + `fetchpriority="high"`). Extra DOM props pass through.
|
|
36
|
+
*/
|
|
37
|
+
export function Image(props: ImageComponentProps): ReactElement {
|
|
38
|
+
// Strip the nifra-only props (not valid DOM attributes) before forwarding the rest to <img>.
|
|
39
|
+
const { loader, widths, quality, priority, ...rest } = props
|
|
40
|
+
const resolved = resolveImage(props, loader)
|
|
41
|
+
// `resolved` wins over the raw src/width/height/alt/sizes/loading still present in `rest`.
|
|
42
|
+
return createElement("img", { ...rest, ...resolved })
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RenderAdapter } from "@nifrajs/web"
|
|
2
|
+
/**
|
|
3
|
+
* @nifrajs/web-react — the React render adapter for @nifrajs/web (server side). Streaming SSR via
|
|
4
|
+
* `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
|
|
5
|
+
* `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
|
|
6
|
+
*/
|
|
7
|
+
import { renderToString as reactRenderToString, renderToReadableStream } from "react-dom/server"
|
|
8
|
+
import { compose } from "./compose.ts"
|
|
9
|
+
|
|
10
|
+
/** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
|
|
11
|
+
export const reactAdapter: RenderAdapter = {
|
|
12
|
+
// Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). React's
|
|
13
|
+
// `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
|
|
14
|
+
// content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
|
|
15
|
+
// streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
|
|
16
|
+
renderToString(chain, props) {
|
|
17
|
+
return reactRenderToString(compose(chain, props))
|
|
18
|
+
},
|
|
19
|
+
renderToStream(chain, props) {
|
|
20
|
+
// Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
|
|
21
|
+
// boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
|
|
22
|
+
// the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
|
|
23
|
+
return renderToReadableStream(compose(chain, props))
|
|
24
|
+
},
|
|
25
|
+
// React reconciles against the existing DOM on hydrate, so no per-document bootstrap
|
|
26
|
+
// script is needed (contrast Solid's generateHydrationScript) — the seam allows both.
|
|
27
|
+
hydrationHead() {
|
|
28
|
+
return ""
|
|
29
|
+
},
|
|
30
|
+
}
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IslandStrategy } from "@nifrajs/web/islands"
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-react/island` — the `<Island>` marker for no-framework islands. Server-renders its
|
|
4
|
+
* children inside a `<nifra-island>` element that the framework-agnostic `mountIslands` runtime
|
|
5
|
+
* (`@nifrajs/web/islands`) picks up on the client. Imports only `react` (+ a type) — no `react-dom`,
|
|
6
|
+
* no JSX (the package builds with plain `tsc`).
|
|
7
|
+
*
|
|
8
|
+
* Use on a route with `export const hydrate = false`: the page ships zero framework JS, and only the
|
|
9
|
+
* island bundle (your enhancers + the tiny runtime) loads via the route's `islandScripts`. The island's
|
|
10
|
+
* SSR markup is real content (works with JS off); the enhancer upgrades it when it loads.
|
|
11
|
+
*/
|
|
12
|
+
import { createElement, type ReactNode } from "react"
|
|
13
|
+
|
|
14
|
+
export interface IslandProps {
|
|
15
|
+
/** Stable id matching the key in the client's `mountIslands({ <id>: enhancer })`. */
|
|
16
|
+
readonly id: string
|
|
17
|
+
/** Serializable props handed to the enhancer (JSON-encoded inline in `data-props`; no closures). */
|
|
18
|
+
readonly props?: unknown
|
|
19
|
+
/** When the enhancer runs (default `load`). `visible` (IntersectionObserver) wins TTI on long pages. */
|
|
20
|
+
readonly strategy?: IslandStrategy
|
|
21
|
+
/** The island's server-rendered content — real HTML, present with JS off. */
|
|
22
|
+
readonly children?: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render a `<nifra-island>` marker around server-rendered `children`. The client enhancer mounts the
|
|
26
|
+
* interactivity (see `mountIslands`). Props are JSON-encoded into `data-props`; React escapes the
|
|
27
|
+
* attribute value and the client reads the decoded `dataset.props`. */
|
|
28
|
+
export function Island({ id, props, strategy = "load", children }: IslandProps): ReactNode {
|
|
29
|
+
return createElement(
|
|
30
|
+
"nifra-island",
|
|
31
|
+
{
|
|
32
|
+
"data-id": id,
|
|
33
|
+
"data-strategy": strategy,
|
|
34
|
+
// Omit `data-props` entirely when there are no props (keeps markup minimal + parse a no-op).
|
|
35
|
+
...(props === undefined ? {} : { "data-props": JSON.stringify(props) }),
|
|
36
|
+
},
|
|
37
|
+
children,
|
|
38
|
+
)
|
|
39
|
+
}
|