|
1 | 1 | # @fictjs/react |
2 | 2 |
|
3 | | -A React interoperability layer for Fict, based on a controlled React Islands model. |
| 3 | +React interoperability layer for [Fict](https://github.com/nicepkg/fict) — embed React components inside Fict applications as controlled islands with SSR, lazy loading, and fine-grained prop reactivity. |
| 4 | + |
| 5 | +## Why |
| 6 | + |
| 7 | +Fict uses its own compiler-driven reactivity model. When you need to reuse an existing React component (a design system, a charting library, a rich text editor), `@fictjs/react` bridges the gap: the React subtree runs in its own React root while the surrounding Fict app feeds it reactive props. |
4 | 8 |
|
5 | 9 | ## Features |
6 | 10 |
|
7 | | -- `reactify`: Wrap a React component as a Fict component (CSR + SSR). |
8 | | -- `ReactIsland`: Declarative island component with `props` getter support. |
9 | | -- `reactify$`: QRL-serializable React island with lazy loading support. |
10 | | -- `installReactIslands`: Scan and mount `data-fict-react` islands on the client. |
11 | | -- `reactAction$`: Pass Fict QRL actions as serializable React callbacks. |
12 | | -- `fictReactPreset`: Isolate React JSX transform by directory in Vite (default `src/react/**`). |
| 11 | +| Capability | API | When to use | |
| 12 | +|---|---|---| |
| 13 | +| Eager wrapping | `reactify` | The React component is already imported | |
| 14 | +| Declarative island | `ReactIsland` | Inline island with a `props` getter | |
| 15 | +| Resumable / lazy | `reactify$` | The component should be lazy-loaded via QRL | |
| 16 | +| Static loader | `installReactIslands` | Mount islands from plain HTML attributes (no Fict runtime) | |
| 17 | +| Serializable callbacks | `reactAction$` | Pass Fict actions across the serialization boundary | |
| 18 | +| Vite preset | `fictReactPreset` | Isolate React JSX transform from Fict's compiler | |
13 | 19 |
|
14 | 20 | ## Install |
15 | 21 |
|
16 | 22 | ```bash |
17 | 23 | pnpm add @fictjs/react react react-dom @fictjs/runtime |
18 | 24 | ``` |
19 | 25 |
|
20 | | -## Usage |
| 26 | +For the Vite preset (optional): |
| 27 | + |
| 28 | +```bash |
| 29 | +pnpm add -D @vitejs/plugin-react vite |
| 30 | +``` |
| 31 | + |
| 32 | +### Requirements |
21 | 33 |
|
22 | | -### 1) `reactify` (Eager) |
| 34 | +- Node 20+ |
| 35 | +- React 18.2+ or 19 |
| 36 | +- `@fictjs/runtime` >= 0.10.0 |
| 37 | + |
| 38 | +## Quick Start |
| 39 | + |
| 40 | +### 1. Vite Configuration |
| 41 | + |
| 42 | +If your project mixes Fict and React files, use the preset to scope the React JSX transform to a specific directory (default: `src/react/**`): |
23 | 43 |
|
24 | 44 | ```ts |
| 45 | +// vite.config.ts |
| 46 | +import { defineConfig } from 'vite' |
| 47 | +import { fictReactPreset } from '@fictjs/react/preset' |
| 48 | +import fict from '@fictjs/vite-plugin' |
| 49 | + |
| 50 | +export default defineConfig({ |
| 51 | + plugins: [ |
| 52 | + fict(), |
| 53 | + ...fictReactPreset(), |
| 54 | + ], |
| 55 | +}) |
| 56 | +``` |
| 57 | + |
| 58 | +Custom scope: |
| 59 | + |
| 60 | +```ts |
| 61 | +fictReactPreset({ |
| 62 | + include: [/components\/react\/.*\.[jt]sx?$/], |
| 63 | +}) |
| 64 | +``` |
| 65 | + |
| 66 | +### 2. Wrap a React Component (Eager) |
| 67 | + |
| 68 | +```tsx |
25 | 69 | import { reactify } from '@fictjs/react' |
26 | 70 | import { prop } from '@fictjs/runtime' |
| 71 | +import { MyButton } from './react/MyButton' |
27 | 72 |
|
28 | | -function ReactButton(props: { text: string }) { |
29 | | - return <button>{props.text}</button> |
30 | | -} |
31 | | - |
32 | | -const FictButton = reactify(ReactButton) |
| 73 | +const FictButton = reactify(MyButton) |
33 | 74 |
|
34 | | -// Use in Fict |
35 | | -;<FictButton text={prop(() => state.text)} /> |
| 75 | +// In a Fict component |
| 76 | +function App() { |
| 77 | + let count = $state(0) |
| 78 | + return <FictButton label={prop(() => `Clicked ${count} times`)} /> |
| 79 | +} |
36 | 80 | ``` |
37 | 81 |
|
38 | | -### 2) `ReactIsland` |
| 82 | +The React component re-renders whenever the reactive props change — without re-running the Fict component function. |
39 | 83 |
|
40 | | -```ts |
41 | | -import { ReactIsland } from '@fictjs/react' |
| 84 | +### 3. Declarative Island |
42 | 85 |
|
43 | | -<ReactIsland |
44 | | - component={ReactButton} |
45 | | - props={() => ({ text: state.text })} |
46 | | - client="visible" |
47 | | - ssr |
48 | | -/> |
| 86 | +```tsx |
| 87 | +import { ReactIsland } from '@fictjs/react' |
| 88 | +import { Chart } from './react/Chart' |
| 89 | + |
| 90 | +function Dashboard() { |
| 91 | + let data = $state([]) |
| 92 | + |
| 93 | + return ( |
| 94 | + <ReactIsland |
| 95 | + component={Chart} |
| 96 | + props={() => ({ data, height: 300 })} |
| 97 | + client="visible" |
| 98 | + ssr |
| 99 | + /> |
| 100 | + ) |
| 101 | +} |
49 | 102 | ``` |
50 | 103 |
|
51 | | -### 3) `reactify$` (Resumable) |
| 104 | +### 4. Lazy-Loaded Island (Resumable) |
52 | 105 |
|
53 | 106 | ```ts |
54 | 107 | import { reactify$ } from '@fictjs/react' |
55 | 108 |
|
56 | | -export const FictButton$ = reactify$({ |
| 109 | +export const LazyChart = reactify$({ |
57 | 110 | module: import.meta.url, |
58 | | - export: 'ReactButton', |
| 111 | + export: 'Chart', |
59 | 112 | client: 'idle', |
60 | 113 | ssr: true, |
61 | 114 | }) |
62 | 115 | ``` |
63 | 116 |
|
64 | | -> `reactify$` outputs `data-fict-react` + `data-fict-react-props`, which can be mounted on the client by strategy. |
| 117 | +The component module is loaded only when the client strategy fires. On the server the optional `component` reference is used for SSR; on the client the QRL triggers a dynamic import. |
| 118 | + |
| 119 | +Serialized props are written to `data-fict-react-props` on the host element, making the island fully resumable from server-rendered HTML. |
| 120 | + |
| 121 | +### 5. Static Islands (Loader) |
65 | 122 |
|
66 | | -### 4) Client Loader |
| 123 | +Mount React components from plain HTML without any Fict runtime involvement: |
| 124 | + |
| 125 | +```html |
| 126 | +<div |
| 127 | + data-fict-react="./components/Widget.js#Widget" |
| 128 | + data-fict-react-client="visible" |
| 129 | + data-fict-react-props="%7B%22title%22%3A%22Hello%22%7D" |
| 130 | +></div> |
| 131 | +``` |
67 | 132 |
|
68 | 133 | ```ts |
69 | | -import { installReactIslands } from '@fictjs/react' |
| 134 | +import { installReactIslands } from '@fictjs/react/loader' |
70 | 135 |
|
71 | | -installReactIslands() |
| 136 | +const cleanup = installReactIslands({ |
| 137 | + observe: true, // Watch for dynamically added islands |
| 138 | + defaultClient: 'idle', // Fallback client strategy |
| 139 | + visibleRootMargin: '200px', |
| 140 | +}) |
| 141 | + |
| 142 | +// Later: cleanup() to disconnect observer and unmount all islands |
72 | 143 | ``` |
73 | 144 |
|
74 | | -`installReactIslands` host attribute constraints: |
| 145 | +The loader uses `MutationObserver` to detect new island hosts and attribute changes. Updating `data-fict-react-props` on a mounted host triggers a React re-render. Changing the QRL (`data-fict-react`) disposes the old root and mounts a fresh one. |
75 | 146 |
|
76 | | -- Dynamically updatable (triggers refresh): `data-fict-react-props`, `data-fict-react-action-props` |
77 | | -- Immutable after initialization (recreate the island host to apply changes): |
78 | | - `data-fict-react-client`, `data-fict-react-ssr`, `data-fict-react-prefix` |
79 | | -- Mutable and triggers runtime rebuild: `data-fict-react` (QRL changes cause dispose + remount) |
80 | | -- Runtime mutations of immutable attributes: warning in development, silent ignore in production |
| 147 | +### 6. Serializable Actions |
81 | 148 |
|
82 | | -### 5) Serializable Callback (Action) |
| 149 | +Pass callbacks from Fict to React across the serialization boundary: |
83 | 150 |
|
84 | 151 | ```ts |
85 | 152 | import { reactAction$ } from '@fictjs/react' |
86 | 153 |
|
87 | | -<RemoteReactIsland |
88 | | - onAction={reactAction$(import.meta.url, 'handleAction')} |
| 154 | +// In a Fict component |
| 155 | +<RemoteEditor |
| 156 | + onSave={reactAction$(import.meta.url, 'handleSave')} |
89 | 157 | /> |
90 | 158 | ``` |
91 | 159 |
|
92 | | -By default, action refs in callback-like props (`/^on[A-Z]/`) are materialized into callable functions. |
93 | | -If your callback prop is not named like `onX`, declare it explicitly via `actionProps`: |
| 160 | +```ts |
| 161 | +// Same module — the exported handler |
| 162 | +export function handleSave(content: string) { |
| 163 | + console.log('Saved:', content) |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +Props matching `/^on[A-Z]/` are automatically detected as action refs. For non-standard callback prop names, declare them explicitly: |
94 | 168 |
|
95 | 169 | ```ts |
96 | | -const RemoteReactIsland = reactify$({ |
| 170 | +const RemoteEditor = reactify$({ |
97 | 171 | module: import.meta.url, |
98 | | - export: 'RemoteReactIsland', |
99 | | - actionProps: ['submitAction'], |
| 172 | + export: 'Editor', |
| 173 | + actionProps: ['submitHandler', 'validateFn'], |
100 | 174 | }) |
101 | 175 | ``` |
102 | 176 |
|
103 | | -For loader-based usage, pass this through host attributes: |
| 177 | +## Client Strategies |
104 | 178 |
|
105 | | -```html |
106 | | -<div |
107 | | - data-fict-react="...#RemoteReactIsland" |
108 | | - data-fict-react-action-props="%5B%22submitAction%22%5D" |
109 | | -></div> |
110 | | -``` |
| 179 | +Control when each island mounts on the client: |
111 | 180 |
|
112 | | -### 6) Vite Preset (React Lane) |
| 181 | +| Strategy | Behavior | |
| 182 | +|---|---| |
| 183 | +| `'load'` | Mount immediately (via microtask). **Default.** | |
| 184 | +| `'idle'` | Mount during idle time (`requestIdleCallback`, falls back to `setTimeout(…, 1)`) | |
| 185 | +| `'visible'` | Mount when the host element enters the viewport (`IntersectionObserver` with configurable `rootMargin`, default `200px`) | |
| 186 | +| `'only'` | Client-only rendering — no SSR, no hydration | |
113 | 187 |
|
114 | | -```ts |
115 | | -import { defineConfig } from 'vite' |
116 | | -import { fictReactPreset } from '@fictjs/react/preset' |
| 188 | +When `ssr` is `true` (the default), the React subtree is rendered to HTML on the server. On the client, the island hydrates (`hydrateRoot`) if SSR content is present, otherwise it creates a fresh root (`createRoot`). |
117 | 189 |
|
118 | | -export default defineConfig({ |
119 | | - plugins: [ |
120 | | - ...fictReactPreset({ |
121 | | - include: [/src\/react\/.*\.[jt]sx?$/], |
122 | | - }), |
123 | | - ], |
124 | | -}) |
| 190 | +## API Reference |
| 191 | + |
| 192 | +### `reactify<P>(component, options?)` |
| 193 | + |
| 194 | +Wraps a React component as a Fict component. Props flow reactively from the Fict side; the React root updates when props change. |
| 195 | + |
| 196 | +**Options** (`ReactInteropOptions`): |
| 197 | + |
| 198 | +| Option | Type | Default | Description | |
| 199 | +|---|---|---|---| |
| 200 | +| `ssr` | `boolean` | `true` | Server-side render the React subtree | |
| 201 | +| `client` | `ClientDirective` | `'load'` | Client mount strategy | |
| 202 | +| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy | |
| 203 | +| `identifierPrefix` | `string` | `''` | React `useId` prefix for multi-root pages | |
| 204 | +| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize | |
| 205 | + |
| 206 | +### `ReactIsland<P>(props)` |
| 207 | + |
| 208 | +Declarative island component. Accepts `component`, `props` (value or getter), and all `ReactInteropOptions`. |
| 209 | + |
| 210 | +### `reactify$<P>(options)` |
| 211 | + |
| 212 | +Creates a lazy-loadable Fict component backed by a QRL. |
| 213 | + |
| 214 | +**Additional options** (`ReactifyQrlOptions<P>`): |
| 215 | + |
| 216 | +| Option | Type | Description | |
| 217 | +|---|---|---| |
| 218 | +| `module` | `string` | Module URL, usually `import.meta.url` | |
| 219 | +| `export` | `string` | Export name (default: `'default'`) | |
| 220 | +| `component` | `ComponentType<P>` | Optional eager reference for SSR | |
| 221 | + |
| 222 | +### `installReactIslands(options?)` |
| 223 | + |
| 224 | +Scans the document for `[data-fict-react]` hosts and mounts them. Returns a cleanup function. |
| 225 | + |
| 226 | +**Options** (`ReactIslandsLoaderOptions`): |
| 227 | + |
| 228 | +| Option | Type | Default | Description | |
| 229 | +|---|---|---|---| |
| 230 | +| `document` | `Document` | `document` | Document to scan | |
| 231 | +| `selector` | `string` | `'[data-fict-react]'` | CSS selector for island hosts | |
| 232 | +| `observe` | `boolean` | `true` | Watch for dynamic additions/removals | |
| 233 | +| `defaultClient` | `ClientDirective` | `'load'` | Fallback client strategy | |
| 234 | +| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy | |
| 235 | + |
| 236 | +### `reactAction$(moduleId, exportName?)` |
| 237 | + |
| 238 | +Creates a serializable action ref from a module export. The ref is materialized into a callable function when the React component mounts. |
| 239 | + |
| 240 | +### `reactActionFromQrl(qrl)` |
| 241 | + |
| 242 | +Creates an action ref from a raw QRL string. |
| 243 | + |
| 244 | +### `fictReactPreset(options?)` |
| 245 | + |
| 246 | +Returns Vite plugins that scope the React JSX transform to a directory. |
| 247 | + |
| 248 | +| Option | Type | Default | Description | |
| 249 | +|---|---|---|---| |
| 250 | +| `include` | `FilterPattern` | `[/src\/react\/.*\.[jt]sx?$/]` | Files to transform with React JSX | |
| 251 | +| `exclude` | `FilterPattern` | — | Files to exclude | |
| 252 | +| `react` | `ReactPluginOptions` | — | Additional `@vitejs/plugin-react` options | |
| 253 | + |
| 254 | +## Host Attributes |
| 255 | + |
| 256 | +When using the loader or resumable mode, the following data attributes control island behavior: |
| 257 | + |
| 258 | +| Attribute | Mutable | Purpose | |
| 259 | +|---|---|---| |
| 260 | +| `data-fict-react` | `*` | QRL pointing to the React component module | |
| 261 | +| `data-fict-react-props` | yes | URL-encoded serialized props | |
| 262 | +| `data-fict-react-action-props` | yes | URL-encoded JSON array of custom action prop names | |
| 263 | +| `data-fict-react-client` | no | Client strategy (`load` / `idle` / `visible` / `only`) | |
| 264 | +| `data-fict-react-ssr` | no | `'1'` if SSR content is present | |
| 265 | +| `data-fict-react-prefix` | no | React `useId` identifier prefix | |
| 266 | +| `data-fict-react-host` | — | Marks element as a React island host | |
| 267 | +| `data-fict-react-mounted` | — | Set to `'1'` after the island mounts | |
| 268 | + |
| 269 | +`*` Changing the QRL disposes the current root and creates a new one. |
| 270 | + |
| 271 | +Immutable attributes (`data-fict-react-client`, `data-fict-react-ssr`, `data-fict-react-prefix`) emit a warning in development if mutated at runtime. To change them, recreate the host element. |
| 272 | + |
| 273 | +## Package Exports |
| 274 | + |
| 275 | +``` |
| 276 | +@fictjs/react → Main API (reactify, ReactIsland, reactify$, reactAction$, …) |
| 277 | +@fictjs/react/loader → installReactIslands |
| 278 | +@fictjs/react/preset → fictReactPreset |
125 | 279 | ``` |
126 | 280 |
|
127 | | -## Client Strategies |
| 281 | +## Development |
128 | 282 |
|
129 | | -- `load`: Mount as soon as possible. |
130 | | -- `idle`: Mount when idle (`requestIdleCallback` preferred). |
131 | | -- `visible`: Mount when entering the viewport (`IntersectionObserver`). |
132 | | -- `only`: Client-only rendering (no SSR hydrate). |
| 283 | +```bash |
| 284 | +pnpm install |
| 285 | +pnpm dev # Watch mode |
| 286 | +pnpm build # Production build |
| 287 | +pnpm test # Unit tests (vitest) |
| 288 | +pnpm test:it # Integration tests |
| 289 | +pnpm test:e2e # E2E tests (Playwright + Chromium) |
| 290 | +pnpm lint # ESLint |
| 291 | +pnpm typecheck # TypeScript validation |
| 292 | +``` |
133 | 293 |
|
134 | | -## Internal Hooks |
| 294 | +## License |
135 | 295 |
|
136 | | -- `src/testing.ts` provides test injection hooks for this repository only. |
137 | | -- These hooks are internal/unstable, not part of package exports, and not covered by compatibility guarantees. |
| 296 | +[MIT](./LICENSE) |
0 commit comments