ilha 0.13.0 → 0.14.0

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,167 @@
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. A function initializer is a **computed** atom:
85
52
 
86
53
  ```tsx
87
- const Counter = ilha<{ start: number }>(({ start }) => {
88
- const count = state(start);
89
- return <p>{count()}</p>;
90
- });
54
+ const items = atom([{ n: 1 }, { n: 2 }]);
55
+ const total = atom(() => items().reduce((sum, item) => sum + item.n, 0));
91
56
  ```
92
57
 
93
- ### `derived(fn)`
58
+ Atoms hold data. Do not store JSX in an atom.
94
59
 
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
- });
108
- ```
109
-
110
- Async generators stream: each yielded value feeds the envelope. Stale runs abort via the passed `signal`.
111
-
112
- ### `action(fn)`
113
-
114
- Define a reusable operation with reactive execution state. Use it when you render `pending`, `data`, or `error`, or when you need unmount cancellation:
115
-
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.
60
+ ---
129
61
 
130
- ### `effect(fn)` and `effect.once(fn)`
62
+ ## Streams, when, watch, wait
131
63
 
132
- `effect()` runs a reactive side effect that reruns when its dependencies change, with cleanup before rerun and on unmount:
64
+ Use a generator when you `yield*` instructions.
133
65
 
134
66
  ```tsx
135
- const App = ilha(() => {
136
- effect(() => {
137
- document.title = count();
138
- return () => {
139
- /* cleanup */
140
- };
67
+ import * as Stream from "effect/Stream";
68
+ import * as Atom from "effect/unstable/reactivity/Atom";
69
+ import { atom, when } from "ilha";
70
+
71
+ const Search = function* () {
72
+ const q = atom("");
73
+ yield (
74
+ <input value={q} oninput={(e: Event) => q.set((e.currentTarget as HTMLInputElement).value)} />
75
+ );
76
+ yield* when(Atom.toStream(q.atom).pipe(Stream.debounce("200 millis")), function* (query) {
77
+ if (!query) return;
78
+ yield <p>{query}</p>;
141
79
  });
142
- return <p>{count()}</p>;
143
- });
80
+ };
144
81
  ```
145
82
 
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.
151
-
152
- ## Typed props and validation
153
-
154
- Pass a [Standard Schema](https://standardschema.dev)-compatible validator as the first `ilha()` argument to validate, coerce, and default props at runtime:
155
-
156
- ```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
- ));
162
- ```
163
-
164
- Validation runs during SSR, hydration, mount, and prop updates.
165
-
166
- ## Composing Islands
167
-
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:
169
-
170
- ```tsx
171
- const Badge = ilha<{ label: string }>(({ label }) => <strong>{label}</strong>);
172
-
173
- const Page = ilha(() => <Badge label="New" />);
174
- ```
83
+ - `when(stream, body)` — render `body` for each value (SSR takes the first).
84
+ - `watch(source, fn)` — side effect on an atom or Stream.
85
+ - `wait(body)` — paint until `done(value)`, then continue the generator.
175
86
 
176
- Each nested island is wrapped in a slot element (default `div`). Choose the wrapper tag with the child's `{ as }` constructor option:
87
+ Map a Stream of arrays to JSX for lists (`Stream.map`, `Atom.toStream`).
177
88
 
178
- ```tsx
179
- const Row = ilha(({ label }) => <li>{label}</li>, { as: "li" });
180
- ```
89
+ ---
181
90
 
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:
91
+ ## SSR
183
92
 
184
93
  ```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
- ```
196
-
197
- Keys must be unique within a parent render and cannot contain `:`.
198
-
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"));
225
- ```
226
-
227
- ### `await island.hydratable(props, options)`
228
-
229
- Emit hydration markup with serialized props and an optional state snapshot. `name` must match the client registry key:
230
-
231
- ```ts
232
- // Server
233
- const html = await Counter.hydratable({}, { name: "Counter", snapshot: true });
234
- ```
235
-
236
- ```ts
237
- // Client
238
- mount({ Counter });
239
- ```
94
+ import { atom, renderToString, mount } from "ilha";
240
95
 
241
- Snapshots are positional: state and derived values restore by primitive order. Malformed or incompatible snapshots are ignored safely.
96
+ const Counter = () => {
97
+ const count = atom(0);
98
+ return <p>{count}</p>;
99
+ };
242
100
 
243
- ### `island.key(key)`
101
+ const html = await renderToString(Counter);
102
+ // <div data-ilha data-ilha-state="…"><p>0</p></div>
244
103
 
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"] });
104
+ const host = document.querySelector("[data-ilha]");
105
+ if (host) mount(host, Counter, { hydrate: true });
253
106
  ```
254
107
 
255
- ## Top-level Helpers
108
+ | Option | Default | Meaning |
109
+ | ---------------- | ------- | ------------------------------ |
110
+ | `snapshot` | `true` | Embed atom values |
111
+ | `markers` | `true` | Wrap in `<div data-ilha>` |
112
+ | `timeout` | none | Serialize after this many ms |
113
+ | `captureActions` | `false` | Probe handlers for server RPCs |
256
114
 
257
- ### `mount(registry, options?)`
115
+ In Node, `renderToString` registers happy-dom when `document` is missing.
258
116
 
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)`
117
+ ---
268
118
 
269
- Run a standalone reactive effect outside any island; returns a stop function:
119
+ ## JSX
270
120
 
271
- ```ts
272
- const stop = effect(() => {
273
- document.title = `${count()} items`;
274
- });
121
+ ```json
122
+ {
123
+ "compilerOptions": {
124
+ "jsx": "react-jsx",
125
+ "jsxImportSource": "ilha"
126
+ }
127
+ }
275
128
  ```
276
129
 
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:
130
+ Event props are lowercase (`onclick`, `onchange`). Use `class`, not `className`. Use `h()` when you cannot use JSX.
282
131
 
283
- ```ts
284
- const theme = context("app.theme", "light");
285
- ```
286
-
287
- ### `batch(fn)` / `untrack(fn)`
132
+ ---
288
133
 
289
- `batch()` groups multiple writes into one propagation pass. `untrack()` reads signals without subscribing the surrounding scope:
134
+ ## Custom elements
290
135
 
291
136
  ```ts
292
- batch(() => {
293
- a.set(1);
294
- b.set(2);
137
+ import { atom } from "ilha";
138
+ import { define } from "ilha/define";
139
+
140
+ define("ilha-counter", () => {
141
+ const count = atom(0);
142
+ return (
143
+ <button type="button" onclick={() => count.update((n: number) => n + 1)}>
144
+ {count}
145
+ </button>
146
+ );
295
147
  });
296
-
297
- const value = untrack(() => secret());
298
- ```
299
-
300
- ### `persist(accessor, key)`
301
-
302
- Keep a standalone signal in sync with `localStorage`:
303
-
304
- ```ts
305
- persist(cart, "cart");
306
- ```
307
-
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
148
  ```
326
149
 
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} />
150
+ ```html
151
+ <ilha-counter data-ilha></ilha-counter>
334
152
  ```
335
153
 
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`.
154
+ ---
337
155
 
338
- ## Security
156
+ ## Routing and Astro
339
157
 
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.
158
+ - [`@ilha/router`](https://github.com/ilhajs/ilha/tree/main/packages/router) — file-system SPA routes and Oxide server islands.
159
+ - [`@ilha/astro`](https://github.com/ilhajs/ilha/tree/main/packages/astro) — Astro renderer (`renderToString` + `mount`).
341
160
 
342
- ## TypeScript
161
+ Docs: [ilha.build](https://ilha.build)
343
162
 
344
- Configure JSX with the automatic runtime:
163
+ ---
345
164
 
346
- ```json
347
- {
348
- "compilerOptions": {
349
- "jsx": "react-jsx",
350
- "jsxImportSource": "ilha"
351
- }
352
- }
353
- ```
165
+ ## License
354
166
 
355
- Build tools resolve `ilha/jsx-runtime` in production and `ilha/jsx-dev-runtime` in development.
167
+ MIT
@@ -0,0 +1,5 @@
1
+ import { n as Component } from "./types-DU46exHq.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-Dcy4teaf.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,35 @@
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 { a as Instruction, c as Yielded, i as Fragment, n as Component, r as Done, s as View, t as AtomHandle } from "./types-DU46exHq.js";
2
+ import { t as h } from "./vnode-BZLcG8lC.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/atom.d.ts
9
+ declare function atom<A>(init: () => A): AtomHandle<A>;
10
+ declare function atom<A>(init: A | Atom.Atom<A> | Effect.Effect<A, any, any> | Stream.Stream<A, any, any>): AtomHandle<A>;
11
+ //#endregion
12
+ //#region src/when.d.ts
13
+ 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>;
14
+ //#endregion
15
+ //#region src/watch.d.ts
16
+ declare function watch<A>(source: AtomHandle<A> | Atom.Atom<A> | Stream.Stream<A, any, any>, fn: (value: A) => void): Instruction<void>;
17
+ //#endregion
18
+ //#region src/wait.d.ts
19
+ declare function wait<A>(body: (done: Done<A>) => Generator<Yielded, View | void, unknown>): Instruction<A>;
20
+ //#endregion
21
+ //#region src/mount.d.ts
22
+ type RenderToStringOptions = {
23
+ snapshot?: boolean;
24
+ markers?: boolean;
25
+ timeout?: number;
26
+ captureActions?: boolean;
27
+ };
28
+ type MountOptions = {
29
+ hydrate?: boolean;
30
+ onError?: (error: unknown) => void;
31
+ };
32
+ declare function mount(el: Element, fn: Component, opts?: MountOptions): () => void;
33
+ declare function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>;
34
+ //#endregion
35
+ export { type AtomHandle, type Component, type Done, Fragment, type MountOptions, type RenderToStringOptions, type View, atom, h, mount, renderToString, wait, watch, when };