ilha 0.12.1 → 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,373 +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((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()`.
19
+ ## Quick start
49
20
 
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.
51
-
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.
64
-
65
- ## Primitives
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.
66
37
 
67
- ### `state(init?)`
38
+ ---
68
39
 
69
- Island-local reactive state. Returns a signal accessor — a function that reads or writes depending on how you call it:
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(5); // write
79
- count((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, return it from an updater wrapper: `callback(() => 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
- });
91
- ```
92
-
93
- ### `derived(fn)`
94
-
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
- });
54
+ const items = atom([{ n: 1 }, { n: 2 }]);
55
+ const total = atom(() => items().reduce((sum, item) => sum + item.n, 0));
108
56
  ```
109
57
 
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:
58
+ Atoms hold data. Do not store JSX in an atom.
115
59
 
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
- });
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.
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
- ));
80
+ };
162
81
  ```
163
82
 
164
- Validation runs during SSR, hydration, mount, and prop updates.
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.
165
86
 
166
- ## Composing Islands
87
+ Map a Stream of arrays to JSX for lists (`Stream.map`, `Atom.toStream`).
167
88
 
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
- ```
175
-
176
- Each nested island is wrapped in a slot element (default `div`). Choose the wrapper tag with the child's `{ as }` constructor option:
177
-
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
- ```
240
-
241
- Snapshots are positional: state and derived values restore by primitive order. Malformed or incompatible snapshots are ignored safely.
94
+ import { atom, renderToString, mount } from "ilha";
242
95
 
243
- ### `island.key(key)`
96
+ const Counter = () => {
97
+ const count = atom(0);
98
+ return <p>{count}</p>;
99
+ };
244
100
 
245
- Create a keyed child invocation for stable slot identity in lists (see [Composing Islands](#composing-islands)).
101
+ const html = await renderToString(Counter);
102
+ // <div data-ilha data-ilha-state="…"><p>0</p></div>
246
103
 
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 });
104
+ const host = document.querySelector("[data-ilha]");
105
+ if (host) mount(host, Counter, { hydrate: true });
263
106
  ```
264
107
 
265
- Pass `{ root, lazy }` to scope discovery to a root element or defer mounting until hosts scroll into view.
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 |
266
114
 
267
- ### `signal(initial)`
115
+ In Node, `renderToString` registers happy-dom when `document` is missing.
268
116
 
269
- Create a free-standing signal for shared state. Same accessor shape as `state()` but lives outside any island:
270
-
271
- ```ts
272
- const count = signal(0);
273
- count(); // read
274
- count(5); // write
275
- ```
276
-
277
- ### `computed(fn)`
278
-
279
- Create a lazy, cached, read-only value derived from signals:
280
-
281
- ```ts
282
- const total = computed(() => price() * qty());
283
- ```
284
-
285
- ### `effect(fn)`
117
+ ---
286
118
 
287
- Run a standalone reactive effect outside any island; returns a stop function:
119
+ ## JSX
288
120
 
289
- ```ts
290
- const stop = effect(() => {
291
- document.title = `${count()} items`;
292
- });
121
+ ```json
122
+ {
123
+ "compilerOptions": {
124
+ "jsx": "react-jsx",
125
+ "jsxImportSource": "ilha"
126
+ }
127
+ }
293
128
  ```
294
129
 
295
- Inside an island render, `effect()` registers an island effect slot instead.
130
+ Event props are lowercase (`onclick`, `onchange`). Use `class`, not `className`. Use `h()` when you cannot use JSX.
296
131
 
297
- ### `context(key, initial)`
132
+ ---
298
133
 
299
- Get or create a keyed, app-wide shared signal:
134
+ ## Custom elements
300
135
 
301
136
  ```ts
302
- const theme = context("app.theme", "light");
303
- ```
304
-
305
- ### `batch(fn)` / `untrack(fn)`
137
+ import { atom } from "ilha";
138
+ import { define } from "ilha/define";
306
139
 
307
- `batch()` groups multiple writes into one propagation pass. `untrack()` reads signals without subscribing the surrounding scope:
308
-
309
- ```ts
310
- batch(() => {
311
- a(1);
312
- b(2);
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
+ );
313
147
  });
314
-
315
- const value = untrack(() => secret());
316
148
  ```
317
149
 
318
- ### `persist(accessor, key)`
319
-
320
- Keep a standalone signal in sync with `localStorage`:
321
-
322
- ```ts
323
- persist(cart, "cart");
150
+ ```html
151
+ <ilha-counter data-ilha></ilha-counter>
324
152
  ```
325
153
 
326
- ### `onUncaughtError(fn)`
327
-
328
- Register an app-wide error sink for islands with no local `onError()`:
329
-
330
- ```ts
331
- const stop = onUncaughtError((error, source) => telemetry.capture(error, { source }));
332
- ```
333
-
334
- ### `html` / `raw`
335
-
336
- `html\`…\``is an XSS-safe tagged template that accepts signals, arrays, and nested templates.`raw(str)` opts into trusted markup:
337
-
338
- ```ts
339
- import { html, raw } from "ilha";
340
-
341
- html`<p>${count()}</p>`;
342
- html`<button>${raw(icon)}</button>`;
343
- ```
344
-
345
- ## Bindings
346
-
347
- Use `bind:*` inside JSX or `html`` for two-way form synchronization:
348
-
349
- ```tsx
350
- <input bind:value={name} />
351
- <input type="checkbox" bind:checked={done} />
352
- ```
353
-
354
- 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
+ ---
355
155
 
356
- ## Security
156
+ ## Routing and Astro
357
157
 
358
- 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`).
359
160
 
360
- ## TypeScript
161
+ Docs: [ilha.build](https://ilha.build)
361
162
 
362
- Configure JSX with the automatic runtime:
163
+ ---
363
164
 
364
- ```json
365
- {
366
- "compilerOptions": {
367
- "jsx": "react-jsx",
368
- "jsxImportSource": "ilha"
369
- }
370
- }
371
- ```
165
+ ## License
372
166
 
373
- 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 isSafeUrl, C as PersistOptions, D as SignalSetter, E as SignalAccessor, F as each, G as mount, H as isUrlAttributeName, I as effect, J as persist, K as onError, L as html, M as context, N as css, O as SignalWriter, P as derived, Q as untrack, R as ilha, S as NativeEventModifier, T as RawHtml, U as json, V as isSafeUrlAttrValue, W as morph, X as serializeStyle, Y as raw, Z as state, _ 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 StateAccessor, l as EffectOnceContext, m as HydratableOptions, n as DerivedAccessor, o as EachResult, p as ExternalSignal, q as onUncaughtError, 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 ilhaSignal } from "./index-BETRqz-0.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, SignalSetter, SignalWriter, StateAccessor, action, batch, context, css, derived, each, effect, html, ilha, ilhaSignal, 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 };