ilha 0.13.0 → 0.14.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/README.md CHANGED
@@ -1,355 +1,172 @@
1
1
  # `ilha`
2
2
 
3
- A tiny, isomorphic island framework for building reactive UI components. Runs in the browser with fine-grained signal reactivity and on the server as a synchronous HTML string renderer. Powered by [alien-signals](https://github.com/stackblitz/alien-signals) — zero virtual DOM, no compiler required.
3
+ A tiny, isomorphic UI library. You write a function component, declare `atom()` values, and render with JSX. Runs in the browser with signal reactivity and on the server as HTML. Powered by [Effect](https://effect.website) — no virtual DOM, no compiler.
4
4
 
5
5
  ---
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```bash
10
- npm install ilha
10
+ npm install ilha effect
11
11
  # or Bun
12
- bun add ilha
12
+ bun add ilha effect
13
13
  ```
14
14
 
15
- ---
16
-
17
- ## Quick Start
18
-
19
- ```ts
20
- import { ilha, state, action, html, mount } from "ilha";
21
-
22
- const Counter = ilha(() => {
23
- const count = state(0);
24
-
25
- const increment = action(() => {
26
- count.update((value) => value + 1);
27
- });
28
-
29
- return html`
30
- <div>
31
- <p>Count: ${count()}</p>
32
- <button onclick=${increment}>Increment</button>
33
- </div>
34
- `;
35
- });
36
-
37
- // SSR
38
- Counter.toString(); // → '<div><p>Count: 0</p><button>Increment</button></div>'
39
-
40
- // Client
41
- Counter.mount(document.getElementById("app"));
42
- ```
15
+ `effect` is a peer dependency.
43
16
 
44
17
  ---
45
18
 
46
- ## Core Concepts
47
-
48
- Islands are **function components** that know how to render themselves to an HTML string (SSR) and mount themselves into the DOM (client). Create one by passing the component to `ilha()`.
49
-
50
- An island reruns when a reactive value it reads during rendering changes. Reactive primitives — `state()`, `derived()`, `action()`, `effect()`, `effect.once()`, and `onError()` — are registered by call order and persist across rerenders. Declare them at the component's top level in a stable order.
19
+ ## Quick start
51
20
 
52
- ### Choose the smallest component form
21
+ ```tsx
22
+ import { atom, mount } from "ilha";
53
23
 
54
- | Form | Ownership |
55
- | ------------------------------ | ------------------------------------------------------------------- |
56
- | `const View = () => JSX` | The containing island owns rendering, events, and cleanup |
57
- | `const View = ilha(() => JSX)` | `View` owns an independent reactive scope, lifecycle, and hydration |
24
+ const Counter = () => {
25
+ const count = atom(0);
26
+ return (
27
+ <button type="button" onclick={() => count.update((n: number) => n + 1)}>
28
+ Count: {count}
29
+ </button>
30
+ );
31
+ };
58
32
 
59
- ```tsx
60
- const Label = ilha<{ label: string }>(({ label }) => <span>{label}</span>);
33
+ mount(document.getElementById("app")!, Counter);
61
34
  ```
62
35
 
63
- A plain function component stays transparent: its rendering, events, and cleanup belong to the containing island. Wrap it with `ilha()` when it needs independent ownership.
36
+ A **component** is a function, async function, or generator that returns a view. Nested functions share the parent fiber. `mount` / `renderToString` on a function makes it a root.
64
37
 
65
- ## Primitives
66
-
67
- ### `state(init?)`
38
+ ---
68
39
 
69
- Island-local reactive state. Returns a signal accessor — call it to read, use `.set()` / `.update()` to write:
40
+ ## Atoms
70
41
 
71
42
  ```tsx
72
- const Counter = ilha(() => {
73
- const count = state(0);
74
- return <p>{count()}</p>;
75
- });
43
+ import { atom } from "ilha";
76
44
 
45
+ const count = atom(0);
77
46
  count(); // read
78
- count.set(5); // write
79
- count.update((previous) => previous + 1); // update from the latest value
47
+ count.set(1); // replace
48
+ count.update((n) => n + 1); // patch
80
49
  ```
81
50
 
82
- A function argument is a lazy initializer. To store a function value, pass it to `.set`: `onSave.set(nextCallback)`.
83
-
84
- A state initializer applies only when the instance is created. Later prop changes rerender the component but do not reset state:
51
+ In JSX, `{count}` subscribes the render. Derived values use Effect's `Atom.map` or `Atom.transform`:
85
52
 
86
53
  ```tsx
87
- const Counter = ilha<{ start: number }>(({ start }) => {
88
- const count = state(start);
89
- return <p>{count()}</p>;
90
- });
91
- ```
92
-
93
- ### `derived(fn)`
54
+ import * as Atom from "effect/unstable/reactivity/Atom";
55
+ import { atom } from "ilha";
94
56
 
95
- Compute a value from state or props. Supports synchronous values, promises, and async generators, with a built-in `{ loading, value, error }` envelope:
96
-
97
- ```tsx
98
- const UserCard = ilha<{ userId: string }>(({ userId }) => {
99
- const user = derived(async ({ signal }) => {
100
- const res = await fetch(`/api/users/${userId}`, { signal });
101
- return res.json();
102
- });
103
-
104
- if (user.loading) return <p>Loading…</p>;
105
- if (user.error) return <p>Error: {user.error.message}</p>;
106
- return <p>{user()?.name}</p>;
107
- });
57
+ const items = atom([{ n: 1 }, { n: 2 }]);
58
+ const total = atom(Atom.map(items.atom, (list) => list.reduce((sum, item) => sum + item.n, 0)));
108
59
  ```
109
60
 
110
- Async generators stream: each yielded value feeds the envelope. Stale runs abort via the passed `signal`.
61
+ Wrap multiple writes in `batch()`. Derived and mutation atoms use Effect's `Atom.map`, `Atom.transform`, and `Atom.fn` — pass `handle.atom`, then wrap in `atom()`. Use `watch(source, fn)` for side effects on atom changes.
111
62
 
112
- ### `action(fn)`
63
+ Use `atom.lazy(() => …)` for one-time initialization or to store a function value.
113
64
 
114
- Define a reusable operation with reactive execution state. Use it when you render `pending`, `data`, or `error`, or when you need unmount cancellation:
65
+ Atoms hold data. Do not store JSX in an atom.
115
66
 
116
- ```tsx
117
- const save = action(async (form: FormData, { signal }) => {
118
- const response = await fetch("/save", { method: "POST", body: form, signal });
119
- return response.json();
120
- });
121
-
122
- save(payload);
123
- save.pending;
124
- save.data;
125
- save.error;
126
- ```
127
-
128
- Direct action references work as native event handlers (`onclick={save}`). Use plain functions for ordinary operations.
129
-
130
- ### `effect(fn)` and `effect.once(fn)`
131
-
132
- `effect()` runs a reactive side effect that reruns when its dependencies change, with cleanup before rerun and on unmount:
133
-
134
- ```tsx
135
- const App = ilha(() => {
136
- effect(() => {
137
- document.title = count();
138
- return () => {
139
- /* cleanup */
140
- };
141
- });
142
- return <p>{count()}</p>;
143
- });
144
- ```
145
-
146
- `effect.once()` runs once after mount for one-time setup. It receives `{ host, signal, hydrated }` and supports cleanup. `effect()` and `effect.once()` are client-only; SSR never invokes them.
147
-
148
- ### `onError(fn)`
149
-
150
- Register a per-island error handler. Context: `{ error, source, host }` with sources `"effect"`, `"once"`, `"event"`, and `"action"`. Fall back to the global [`onUncaughtError()`](#onuncaughterrorfn) for app-wide sinks.
67
+ ---
151
68
 
152
- ## Typed props and validation
69
+ ## Streams and when
153
70
 
154
- Pass a [Standard Schema](https://standardschema.dev)-compatible validator as the first `ilha()` argument to validate, coerce, and default props at runtime:
71
+ Paint [Effect `Stream`](https://www.effect.website/docs/v4/api/effect/Stream) values. Yield a stream of views from a generator, or return one from an async component:
155
72
 
156
73
  ```tsx
157
- import { z } from "zod";
158
-
159
- const Greeting = ilha(z.object({ name: z.string().default("World") }), ({ name }) => (
160
- <p>Hello, {name}!</p>
161
- ));
74
+ import * as Stream from "effect/Stream";
75
+ import * as Atom from "effect/unstable/reactivity/Atom";
76
+ import { atom } from "ilha";
77
+
78
+ function* List() {
79
+ const items = atom(["a", "b"]);
80
+ yield Stream.map(Atom.toStream(items.atom), (list) => (
81
+ <ul>
82
+ {list.map((item) => (
83
+ <li key={item}>{item}</li>
84
+ ))}
85
+ </ul>
86
+ ));
87
+ }
162
88
  ```
163
89
 
164
- Validation runs during SSR, hydration, mount, and prop updates.
165
-
166
- ## Composing Islands
90
+ SSR takes the first emission (`take(1)`); the client keeps listening after `mount`.
167
91
 
168
- Nest islands as JSX components. Child islands render inline during SSR and mount independently on the client — a state change in a child does not re-render the parent:
92
+ Ilha also ships `when` for per-emission generator bodies (stale work interrupted). Side effects and pauses use Effect directly — see the [Streams guide](https://ilha.build/guide/ui/streams).
169
93
 
170
- ```tsx
171
- const Badge = ilha<{ label: string }>(({ label }) => <strong>{label}</strong>);
172
-
173
- const Page = ilha(() => <Badge label="New" />);
174
- ```
94
+ ---
175
95
 
176
- Each nested island is wrapped in a slot element (default `div`). Choose the wrapper tag with the child's `{ as }` constructor option:
96
+ ## SSR
177
97
 
178
98
  ```tsx
179
- const Row = ilha(({ label }) => <li>{label}</li>, { as: "li" });
180
- ```
181
-
182
- For keyed child islands in lists, create a keyed component with `Island.key()` before rendering it so identity — state, DOM, and focus — survives reorders:
99
+ import { atom, renderToString, mount } from "ilha";
183
100
 
184
- ```tsx
185
- const Item = ilha<{ label: string }>(({ label }) => <li>{label}</li>);
186
-
187
- const List = ilha(() => (
188
- <ul>
189
- {items.map((item) => {
190
- const Keyed = Item.key(item.id);
191
- return <Keyed label={item.label} />;
192
- })}
193
- </ul>
194
- ));
195
- ```
101
+ const Counter = () => {
102
+ const count = atom(0);
103
+ return <p>{count}</p>;
104
+ };
196
105
 
197
- Keys must be unique within a parent render and cannot contain `:`.
106
+ const html = await renderToString(Counter);
107
+ // <div data-ilha data-ilha-state="…"><p>0</p></div>
198
108
 
199
- ## Island Interface
200
-
201
- ### `island.toString(props?)`
202
-
203
- Synchronous SSR:
204
-
205
- ```ts
206
- Counter.toString(); // → string
207
- ```
208
-
209
- If the island declares async `derived()` values, `toString()` renders their loading state — use `await toStringAsync()` instead (a dev warning tells you when this happens).
210
-
211
- ### `await island.toStringAsync(props?)`
212
-
213
- Async SSR — awaits async derived values and pulls the first value from async-generator derived values:
214
-
215
- ```ts
216
- const html = await Counter.toStringAsync();
217
- ```
218
-
219
- ### `island.mount(host, props?)`
220
-
221
- Mount into a DOM element. Returns an unmount function that stops listeners, effects, and other active behavior:
222
-
223
- ```ts
224
- const unmount = Counter.mount(document.getElementById("app"));
109
+ const host = document.querySelector("[data-ilha]");
110
+ if (host) mount(host, Counter, { hydrate: true });
225
111
  ```
226
112
 
227
- ### `await island.hydratable(props, options)`
113
+ | Option | Default | Meaning |
114
+ | ---------------- | ------- | ------------------------------ |
115
+ | `snapshot` | `true` | Embed atom values |
116
+ | `markers` | `true` | Wrap in `<div data-ilha>` |
117
+ | `timeout` | none | Serialize after this many ms |
118
+ | `captureActions` | `false` | Probe handlers for server RPCs |
228
119
 
229
- Emit hydration markup with serialized props and an optional state snapshot. `name` must match the client registry key:
120
+ In Node, `renderToString` registers happy-dom when `document` is missing.
230
121
 
231
- ```ts
232
- // Server
233
- const html = await Counter.hydratable({}, { name: "Counter", snapshot: true });
234
- ```
235
-
236
- ```ts
237
- // Client
238
- mount({ Counter });
239
- ```
240
-
241
- Snapshots are positional: state and derived values restore by primitive order. Malformed or incompatible snapshots are ignored safely.
242
-
243
- ### `island.key(key)`
244
-
245
- Create a keyed child invocation for stable slot identity in lists (see [Composing Islands](#composing-islands)).
246
-
247
- ### `island.define(tagName, options?)`
248
-
249
- Register the island as a custom element, usable from plain HTML or any framework:
250
-
251
- ```ts
252
- Counter.define("x-counter", { observe: ["start"] });
253
- ```
254
-
255
- ## Top-level Helpers
256
-
257
- ### `mount(registry, options?)`
258
-
259
- Auto-discover and mount `[data-ilha="Name"]` hosts:
260
-
261
- ```ts
262
- mount({ Counter, Badge });
263
- ```
264
-
265
- Pass `{ root, lazy }` to scope discovery to a root element or defer mounting until hosts scroll into view.
266
-
267
- ### `effect(fn)`
122
+ ---
268
123
 
269
- Run a standalone reactive effect outside any island; returns a stop function:
124
+ ## JSX
270
125
 
271
- ```ts
272
- const stop = effect(() => {
273
- document.title = `${count()} items`;
274
- });
126
+ ```json
127
+ {
128
+ "compilerOptions": {
129
+ "jsx": "react-jsx",
130
+ "jsxImportSource": "ilha"
131
+ }
132
+ }
275
133
  ```
276
134
 
277
- Inside an island render, `effect()` registers an island effect slot instead.
278
-
279
- ### `context(key, initial)`
280
-
281
- Get or create a keyed, app-wide shared signal:
282
-
283
- ```ts
284
- const theme = context("app.theme", "light");
285
- ```
135
+ Event props are lowercase (`onclick`, `onchange`). Use `class`, not `className`. Use `h()` when you cannot use JSX.
286
136
 
287
- ### `batch(fn)` / `untrack(fn)`
137
+ ---
288
138
 
289
- `batch()` groups multiple writes into one propagation pass. `untrack()` reads signals without subscribing the surrounding scope:
139
+ ## Custom elements
290
140
 
291
141
  ```ts
292
- batch(() => {
293
- a.set(1);
294
- b.set(2);
142
+ import { atom } from "ilha";
143
+ import { define } from "ilha/define";
144
+
145
+ define("ilha-counter", () => {
146
+ const count = atom(0);
147
+ return (
148
+ <button type="button" onclick={() => count.update((n: number) => n + 1)}>
149
+ {count}
150
+ </button>
151
+ );
295
152
  });
296
-
297
- const value = untrack(() => secret());
298
153
  ```
299
154
 
300
- ### `persist(accessor, key)`
301
-
302
- Keep a standalone signal in sync with `localStorage`:
303
-
304
- ```ts
305
- persist(cart, "cart");
155
+ ```html
156
+ <ilha-counter data-ilha></ilha-counter>
306
157
  ```
307
158
 
308
- ### `onUncaughtError(fn)`
309
-
310
- Register an app-wide error sink for islands with no local `onError()`:
311
-
312
- ```ts
313
- const stop = onUncaughtError((error, source) => telemetry.capture(error, { source }));
314
- ```
315
-
316
- ### `html` / `raw`
317
-
318
- `html\`…\``is an XSS-safe tagged template that accepts signals, arrays, and nested templates.`raw(str)` opts into trusted markup:
319
-
320
- ```ts
321
- import { html, raw } from "ilha";
322
-
323
- html`<p>${count()}</p>`;
324
- html`<button>${raw(icon)}</button>`;
325
- ```
326
-
327
- ## Bindings
328
-
329
- Use `bind:*` inside JSX or `html`` for two-way form synchronization:
330
-
331
- ```tsx
332
- <input bind:value={name} />
333
- <input type="checkbox" bind:checked={done} />
334
- ```
335
-
336
- Supported kinds: `bind:value`, `bind:checked`, `bind:group`, `bind:open`, `bind:files`, and `bind:this`. Native event props keep modifiers `:once`, `:capture`, `:passive`, and `:abortable`.
159
+ ---
337
160
 
338
- ## Security
161
+ ## Routing and Astro
339
162
 
340
- JSX children and attributes are escaped by default. Use `raw()` only for trusted markup you control. `srcdoc` is always dropped, disallowed URL schemes are stripped, and unsafe inline styles are rejected.
163
+ - [`@ilha/router`](https://github.com/ilhajs/ilha/tree/main/packages/router) — file-system SPA routes and Oxide server islands.
164
+ - [`@ilha/astro`](https://github.com/ilhajs/ilha/tree/main/packages/astro) — Astro renderer (`renderToString` + `mount`).
341
165
 
342
- ## TypeScript
166
+ Docs: [ilha.build](https://ilha.build)
343
167
 
344
- Configure JSX with the automatic runtime:
168
+ ---
345
169
 
346
- ```json
347
- {
348
- "compilerOptions": {
349
- "jsx": "react-jsx",
350
- "jsxImportSource": "ilha"
351
- }
352
- }
353
- ```
170
+ ## License
354
171
 
355
- Build tools resolve `ilha/jsx-runtime` in production and `ilha/jsx-dev-runtime` in development.
172
+ MIT
@@ -0,0 +1,5 @@
1
+ import { n as Component } from "./types-Cie0Ihl6.js";
2
+ //#region src/define.d.ts
3
+ declare function define(name: string, component: Component): void;
4
+ //#endregion
5
+ export { define };
package/dist/define.js ADDED
@@ -0,0 +1,18 @@
1
+ import { t as mount } from "./mount-CzFZBgZG.js";
2
+
3
+ //#region src/define.ts
4
+ function define(name, component) {
5
+ customElements.define(name, class extends HTMLElement {
6
+ #unmount;
7
+ connectedCallback() {
8
+ this.#unmount = mount(this, component, { hydrate: this.hasAttribute("data-ilha") });
9
+ }
10
+ disconnectedCallback() {
11
+ this.#unmount?.();
12
+ this.#unmount = void 0;
13
+ }
14
+ });
15
+ }
16
+
17
+ //#endregion
18
+ export { define };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,37 @@
1
- import { A as action, B as isSafeUrlAttrValue, C as PersistOptions, D as SignalWriter, E as SignalAccessor, F as each, G as onError, H as json, I as effect, J as raw, K as onUncaughtError, L as html, M as context, N as css, O as StateAccessor, P as derived, R as ilha, S as NativeEventModifier, T as RawHtml, U as morph, V as isUrlAttributeName, W as mount, X as state, Y as serializeStyle, Z as untrack, _ as KeyedIsland, a as EachKeyedBuilder, b as NativeEventContext, c as EffectFn, d as ErrorContext, f as ErrorSource, g as IslandComponent, h as Island, i as EachBuilder, j as batch, k as TemplateNode, l as EffectOnceContext, m as HydratableOptions, n as DerivedAccessor, o as EachResult, p as ExternalSignal, q as persist, r as DerivedValue, s as EffectContext, t as ActionAccessor, u as EffectOnceFn, v as MountOptions, w as PersistStorage, x as NativeEventHandler, y as MountResult, z as isSafeUrl } from "./index-CVOjC94p.js";
2
- export { ActionAccessor, DerivedAccessor, DerivedValue, EachBuilder, EachKeyedBuilder, EachResult, EffectContext, EffectFn, EffectOnceContext, EffectOnceFn, ErrorContext, ErrorSource, ExternalSignal, HydratableOptions, Island, IslandComponent, KeyedIsland, MountOptions, MountResult, NativeEventContext, NativeEventHandler, NativeEventModifier, PersistOptions, PersistStorage, RawHtml, SignalAccessor, SignalWriter, StateAccessor, TemplateNode, action, batch, context, css, derived, each, effect, html, ilha, isSafeUrl, isSafeUrlAttrValue, isUrlAttributeName, json, morph, mount, onError, onUncaughtError, persist, raw, serializeStyle, state, untrack };
1
+ import { i as Instruction, n as Component, o as View, r as Fragment, s as Yielded, t as AtomHandle } from "./types-Cie0Ihl6.js";
2
+ import { t as h } from "./vnode-DyFwtMVw.js";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Stream from "effect/Stream";
5
+ import * as Atom from "effect/unstable/reactivity/Atom";
6
+ import "effect/Scope";
7
+ import { AtomRegistry } from "effect/unstable/reactivity/AtomRegistry";
8
+ //#region src/watch.d.ts
9
+ /** Run `fn` when `source` changes — and once on mount. Sync, async, and generator components. */
10
+ declare function watch<A>(source: AtomHandle<A> | Atom.Atom<A> | Stream.Stream<A, any, any>, fn: (value: A) => void): Instruction<void>;
11
+ //#endregion
12
+ //#region src/atom.d.ts
13
+ interface AtomFn {
14
+ <A>(init: A | Atom.Atom<A> | Effect.Effect<A, any, any> | Stream.Stream<A, any, any>): AtomHandle<A>;
15
+ lazy<A>(init: () => A): AtomHandle<A>;
16
+ }
17
+ declare const atom: AtomFn;
18
+ declare const batch: (f: () => void) => void;
19
+ //#endregion
20
+ //#region src/when.d.ts
21
+ declare function when<A, E = never, R = never>(stream: Stream.Stream<A, E, R>, body: (value: A) => Generator<Yielded, View | void, unknown>): Instruction<void, E>;
22
+ //#endregion
23
+ //#region src/mount.d.ts
24
+ type RenderToStringOptions = {
25
+ snapshot?: boolean;
26
+ markers?: boolean;
27
+ timeout?: number;
28
+ captureActions?: boolean;
29
+ };
30
+ type MountOptions = {
31
+ hydrate?: boolean;
32
+ onError?: (error: unknown) => void;
33
+ };
34
+ declare function mount(el: Element, fn: Component, opts?: MountOptions): () => void;
35
+ declare function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>;
36
+ //#endregion
37
+ export { type AtomHandle, type Component, Fragment, type MountOptions, type RenderToStringOptions, type View, atom, batch, h, mount, renderToString, watch, when };