ilha 0.11.0 → 0.12.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
@@ -17,21 +17,22 @@ bun add ilha
17
17
  ## Quick Start
18
18
 
19
19
  ```ts
20
- import ilha, { html } from "ilha";
21
-
22
- const Counter = ilha
23
- .state("count", 0)
24
- .action("increment", (_, { state }) => {
25
- state.count((count) => count + 1);
26
- })
27
- .render(
28
- ({ state, action }) => html`
29
- <div>
30
- <p>Count: ${state.count()}</p>
31
- <button onclick=${action.increment}>Increment</button>
32
- </div>
33
- `,
34
- );
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
+ });
35
36
 
36
37
  // SSR
37
38
  Counter.toString(); // → '<div><p>Count: 0</p><button>Increment</button></div>'
@@ -44,933 +45,329 @@ Counter.mount(document.getElementById("app"));
44
45
 
45
46
  ## Core Concepts
46
47
 
47
- Islands are **self-contained reactive components** that know how to render themselves to an HTML string (SSR) and mount themselves into the DOM (client). Create one directly with `ilha(renderFn)`, or use the fluent builder when it needs local capabilities.
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()`.
48
49
 
49
- State is managed with signals — when a signal changes, only the affected island re-renders using a minimal DOM morph. No virtual DOM diffing, no framework overhead.
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.
50
51
 
51
52
  ### Choose the smallest component form
52
53
 
53
- Ilha has two runtime modes with three authoring forms:
54
-
55
- | Form | Use it when |
56
- | ----------------------------------------- | -------------------------------------------------------------------------------- |
57
- | `const View = () => JSX` | You need reusable markup inside another island |
58
- | `const View = ilha(() => JSX)` | The component needs its own reactive scope, lifecycle, mount, or hydration |
59
- | `ilha.state(...).action(...).render(...)` | The island needs local state, derived values, actions, input, or lifecycle hooks |
60
-
61
- Start with a plain component:
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 |
62
58
 
63
59
  ```tsx
64
- const Status = () => <p>Ready</p>;
60
+ const Label = ilha<{ label: string }>(({ label }) => <span>{label}</span>);
65
61
  ```
66
62
 
67
- Promote it without changing its markup when it needs an island boundary:
68
-
69
- ```tsx
70
- const Status = ilha(() => <p>Ready</p>);
71
- ```
72
-
73
- Expand it into the builder when it needs local capabilities:
74
-
75
- ```tsx
76
- const Status = ilha.state("message", "Ready").render(({ state }) => <p>{state.message()}</p>);
77
- ```
78
-
79
- Both `ilha(() => JSX)` and the builder return a complete `Island`. A plain function remains transparent: its rendering, events, and cleanup belong to the containing island.
80
-
81
- ---
82
-
83
- ## Builder API
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.
84
64
 
85
- Use the `ilha` builder when an island needs capabilities such as typed input, state, derived values, actions, effects, or lifecycle hooks.
65
+ ## Primitives
86
66
 
87
- ### `ilha.input<T>()` / `ilha.input(schema)`
67
+ ### `state(init?)`
88
68
 
89
- Declares the island's external input type. Two forms:
69
+ Island-local reactive state. Returns a signal accessor — a function that reads or writes depending on how you call it:
90
70
 
91
- **1. Type-only (no runtime validation):**
92
-
93
- ```ts
94
- const MyIsland = ilha
95
- .input<{ name: string }>()
96
- .render(({ input }) => `<p>Hello, ${input.name}!</p>`);
97
- ```
98
-
99
- **2. With a [Standard Schema](https://standardschema.dev/) validator** (Zod, Valibot, ArkType, etc.) — runs validation at render time and uses the schema's inferred output type:
100
-
101
- ```ts
102
- import { z } from "zod";
103
-
104
- const MyIsland = ilha
105
- .input(z.object({ name: z.string().default("World") }))
106
- .render(({ input }) => `<p>Hello, ${input.name}!</p>`);
107
-
108
- MyIsland.toString({ name: "Ilha" }); // → '<p>Hello, Ilha!</p>'
109
- ```
110
-
111
- Async schemas are not supported.
112
-
113
- ---
114
-
115
- ### `.state(key, init?)`
116
-
117
- Declares a reactive state signal. The initial value can be a static value or a function receiving the resolved `input`.
71
+ ```tsx
72
+ const Counter = ilha(() => {
73
+ const count = state(0);
74
+ return <p>{count()}</p>;
75
+ });
118
76
 
119
- ```ts
120
- ilha
121
- .state("count", 0)
122
- .state("name", "anonymous")
123
- .state("double", ({ count }) => count * 2) // init from input
124
- .render(({ state }) => `<p>${state.count()}</p>`);
77
+ count(); // read
78
+ count(5); // write
79
+ count((previous) => previous + 1); // update from the latest value
125
80
  ```
126
81
 
127
- State accessors are **getters and setters** — call without arguments to read, call with a value to write:
128
-
129
- ```ts
130
- state.count(); // → 0 (read)
131
- state.count(5); // → sets to 5 (write)
132
- ```
82
+ A function argument is a lazy initializer. To store a function value, return it from an updater wrapper: `callback(() => nextCallback)`.
133
83
 
134
- Inside `html\`\``, you can interpolate signal accessors directly **without calling them** —`ilha` detects signal accessors and calls them for you, also applying HTML escaping:
84
+ A state initializer applies only when the instance is created. Later prop changes rerender the component but do not reset state:
135
85
 
136
- ```ts
137
- html`<p>${state.count}</p>`; // same as html`<p>${state.count()}</p>`
86
+ ```tsx
87
+ const Counter = ilha<{ start: number }>(({ start }) => {
88
+ const count = state(start);
89
+ return <p>{count()}</p>;
90
+ });
138
91
  ```
139
92
 
140
- ---
141
-
142
- ### `.derived(key, fn)`
93
+ ### `derived(fn)`
143
94
 
144
- Declares an async (or sync) derived value. The function receives `{ state, input, signal }` where `signal` is an `AbortSignal` that aborts on re-run. Re-runs automatically when any reactive dependency changes.
95
+ Compute a value from state or props. Supports synchronous values, promises, and async generators, with a built-in `{ loading, value, error }` envelope:
145
96
 
146
- ```ts
147
- ilha
148
- .state("userId", 1)
149
- .derived("user", async ({ state, signal }) => {
150
- const res = await fetch(`/api/users/${state.userId()}`, { signal });
97
+ ```tsx
98
+ const UserCard = ilha<{ userId: string }>(({ userId }) => {
99
+ const user = derived(async ({ signal }) => {
100
+ const res = await fetch(`/api/users/${userId}`, { signal });
151
101
  return res.json();
152
- })
153
- .render(({ derived }) => {
154
- if (derived.user.loading) return `<p>Loading…</p>`;
155
- if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
156
- return `<p>${derived.user().name}</p>`;
157
102
  });
158
- ```
159
-
160
- Read with `derived.name()` like state. Each accessor also exposes `loading`, `value`, and `error` for async work. Write `derived.name(value)` for optimistic UI.
161
103
 
162
- ---
163
-
164
- ### `.action(key, fn)`
165
-
166
- Declares a reusable synchronous or asynchronous operation. Define actions before consumers, then call them from lowercase native event handlers:
167
-
168
- ```tsx
169
- const Counter = ilha
170
- .state("count", 0)
171
- .action("increment", (amount: number, { state }) => {
172
- state.count((count) => count + amount);
173
- return state.count();
174
- })
175
- .render(({ state, action }) => (
176
- <button onclick={() => action.increment(1)}>{state.count()}</button>
177
- ));
178
- ```
179
-
180
- Async actions expose reactive `pending`, `data`, and `error` properties. Action callbacks receive state, derived values, input, host, and an abort signal.
181
-
182
- ---
183
-
184
- ### `.on(selector, handler)`
185
-
186
- Prefer lowercase native event props for handlers owned by one rendered element. Use `.on()` when you need a CSS selector, an island-host listener, the full handler context, or combined modifiers.
187
-
188
- The selector string uses the format `"cssSelector@eventName"`. Omit the selector part to target the island host itself.
189
-
190
- ```ts
191
- ilha
192
- .state("count", 0)
193
- .on("@click", ({ state }) => state.count((count) => count + 1)) // host click
194
- .on("button.inc@click", ({ state }) => state.count((count) => count + 1)) // child click
195
- .on("input@input", ({ state, event }) => {
196
- state.query((event.target as HTMLInputElement).value);
197
- })
198
- .render(({ state }) => html`<div><button class="inc">+</button></div>`);
199
- ```
200
-
201
- **Event modifiers** — append after a `:` separator:
202
-
203
- | Modifier | Description |
204
- | ----------- | ------------------------------------------------------------------------- |
205
- | `once` | Listener fires only once |
206
- | `capture` | Capture phase |
207
- | `passive` | `{ passive: true }` |
208
- | `abortable` | `ctx.signal` aborts when the same listener fires again on the same target |
209
-
210
- Multiple modifiers can be combined: `@click:once:capture`.
211
-
212
- The handler receives a `HandlerContext`:
213
-
214
- ```ts
215
- {
216
- state: IslandState; // reactive state signals
217
- derived: IslandDerived; // derived values
218
- input: TInput; // resolved input props
219
- host: Element; // island root element
220
- target: Element; // element that fired the event (typed per event name)
221
- event: Event; // the native event (typed per event name)
222
- signal: AbortSignal; // aborts on unmount, and on next fire if `:abortable`
223
- }
224
- ```
225
-
226
- **Cancelling async work with `ctx.signal`** — pass it to `fetch` or any abort-aware API to cancel stale requests when the island unmounts:
227
-
228
- ```ts
229
- ilha
230
- .state("results", [])
231
- .on("button@click", async ({ state, signal }) => {
232
- const res = await fetch("/api/data", { signal });
233
- state.results(await res.json());
234
- })
235
- .render(
236
- () =>
237
- html`<button>Load</button>
238
- <ul></ul>`,
239
- );
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
+ });
240
108
  ```
241
109
 
242
- **Race-cancellation with `:abortable`** — when the same listener fires again on the same target, the previous invocation's signal aborts. Useful for search-as-you-type and other patterns where only the latest invocation should win:
110
+ Async generators stream: each yielded value feeds the envelope. Stale runs abort via the passed `signal`.
243
111
 
244
- ```ts
245
- ilha
246
- .on("input@input:abortable", async ({ state, event, signal }) => {
247
- const q = (event.target as HTMLInputElement).value;
248
- const res = await fetch(`/search?q=${q}`, { signal }); // earlier requests cancelled
249
- if (signal.aborted) return;
250
- state.results(await res.json());
251
- })
252
- .render(
253
- () =>
254
- html`<input />
255
- <ul></ul>`,
256
- );
257
- ```
112
+ ### `action(fn)`
258
113
 
259
- Race-cancellation is scoped per-target — clicking button A doesn't cancel an in-flight handler on button B.
114
+ Define a reusable operation with reactive execution state. Use it when you render `pending`, `data`, or `error`, or when you need unmount cancellation:
260
115
 
261
- **Implicit batching** — multiple synchronous state writes in a single handler produce one re-render, not one per write:
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
+ });
262
121
 
263
- ```ts
264
- .on("@click", ({ state }) => {
265
- state.a(1);
266
- state.b(2);
267
- state.c(3); // → one render, not three
268
- })
122
+ save(payload);
123
+ save.pending;
124
+ save.data;
125
+ save.error;
269
126
  ```
270
127
 
271
- `AbortError` rejections from cancelled async work are filtered out automatically — they do not reach `.onError()` or `console.error`.
272
-
273
- ---
128
+ Direct action references work as native event handlers (`onclick={save}`). Use plain functions for ordinary operations.
274
129
 
275
- ### `.effect(fn)`
130
+ ### `effect(fn)` and `effect.once(fn)`
276
131
 
277
- Registers a reactive effect that runs after mount and re-runs when any signal it reads changes. Optionally returns a cleanup function.
132
+ `effect()` runs a reactive side effect that reruns when its dependencies change, with cleanup before rerun and on unmount:
278
133
 
279
- ```ts
280
- ilha
281
- .state("title", "Hello")
282
- .effect(({ state }) => {
283
- document.title = state.title();
134
+ ```tsx
135
+ const App = ilha(() => {
136
+ effect(() => {
137
+ document.title = count();
284
138
  return () => {
285
- document.title = "";
286
- }; // cleanup on unmount or re-run
287
- })
288
- .render(({ state }) => `<p>${state.title()}</p>`);
289
- ```
290
-
291
- The handler receives an `EffectContext`:
292
-
293
- ```ts
294
- {
295
- state: IslandState;
296
- derived: IslandDerived;
297
- input: TInput;
298
- host: Element;
299
- signal: AbortSignal; // aborts when the effect re-runs OR the island unmounts
300
- }
301
- ```
302
-
303
- Reading `derived.name()` subscribes the effect. Writing `derived.name(value)` does not — use writes for optimistic UI without pinning the effect to every derived update.
304
-
305
- **Cancelling async work with `ctx.signal`** — unlike `.on()`, race-cancellation is the **default** behaviour for effects (no opt-in modifier needed) because dependency changes invariably make the previous run stale. Pass `signal` to async work to bail out of stale invocations without needing a manual cleanup function:
306
-
307
- ```ts
308
- ilha
309
- .state("userId", 1)
310
- .state("user", null)
311
- .effect(({ state, signal }) => {
312
- (async () => {
313
- try {
314
- const res = await fetch(`/api/users/${state.userId()}`, { signal });
315
- if (signal.aborted) return;
316
- state.user(await res.json());
317
- } catch (err) {
318
- if (err && (err as Error).name === "AbortError") return;
319
- throw err;
320
- }
321
- })();
322
- })
323
- .render(({ state }) => html`<p>${state.user?.name ?? "Loading…"}</p>`);
324
- ```
325
-
326
- Both the user-supplied cleanup function (if any) and the signal abort fire when the effect re-runs, so you can mix patterns.
327
-
328
- **Implicit batching** — multiple synchronous state writes inside an effect run produce a single propagation pass.
329
-
330
- ---
331
-
332
- ### `.onMount(fn)`
333
-
334
- Runs once after the island is mounted into the DOM. Receives `{ state, derived, input, host, hydrated }` where `hydrated` is `true` when the island was mounted over existing SSR content. Optionally returns a cleanup function called on unmount.
335
-
336
- ```ts
337
- ilha
338
- .onMount(({ host, hydrated }) => {
339
- console.log("mounted", hydrated ? "(hydrated)" : "(fresh)");
340
- return () => console.log("unmounted");
341
- })
342
- .render(() => `<div>hello</div>`);
343
- ```
344
-
345
- `.onMount()` is skipped when `snapshot.skipOnMount` is set via `.hydratable()`.
346
-
347
- ---
348
-
349
- ### `.onError(fn)`
350
-
351
- Registers an error handler that catches errors thrown by `.on()` handlers (sync throws and async rejections) and `.effect()` runs (sync throws). Multiple `.onError()` calls compose — all run in declaration order. If no `.onError()` is registered, errors fall back to `console.error` so they are never silently swallowed.
352
-
353
- ```ts
354
- ilha
355
- .state("count", 0)
356
- .on("@click", ({ state }) => {
357
- if (state.count() > 5) throw new Error("too many clicks");
358
- state.count((count) => count + 1);
359
- })
360
- .onError(({ error, source }) => {
361
- console.error(`[${source}] ${error.message}`);
362
- Sentry.captureException(error);
363
- })
364
- .render(({ state }) => `<button>${state.count()}</button>`);
365
- ```
366
-
367
- The handler receives an `ErrorContext`:
368
-
369
- ```ts
370
- {
371
- error: Error; // always wrapped to Error if a non-Error was thrown
372
- source: "on" | "effect";
373
- state: IslandState;
374
- derived: IslandDerived;
375
- input: TInput;
376
- host: Element;
377
- }
139
+ /* cleanup */
140
+ };
141
+ });
142
+ return <p>{count()}</p>;
143
+ });
378
144
  ```
379
145
 
380
- `AbortError` rejections from `.on()` handlers are **not** routed to `.onError()` — they are the expected outcome of cancellation (via `:abortable` race-cancel or unmount) and would otherwise pollute error tracking.
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.
381
147
 
382
- An error thrown inside an `.onError()` handler does not break other registered handlers; it is logged to `console.error` and execution continues with the next handler.
148
+ ### `onError(fn)`
383
149
 
384
- ---
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.
385
151
 
386
- ### `.css(strings, ...values)`
152
+ ## Typed props and validation
387
153
 
388
- Attaches scoped styles to the island. Accepts a tagged template literal or a plain string. The CSS is automatically wrapped in a `@scope` rule bounded to the island host, so styles are contained within the island and do not leak into child islands.
154
+ Pass a [Standard Schema](https://standardschema.dev)-compatible validator as the first `ilha()` argument to validate, coerce, and default props at runtime:
389
155
 
390
- ```ts
391
- import { css } from "ilha";
156
+ ```tsx
157
+ import { z } from "zod";
392
158
 
393
- const Card = ilha.state("active", false).css`
394
- .title { font-weight: 700; }
395
- button { background: teal; color: white; }
396
- `.render(
397
- ({ state }) => html`
398
- <div>
399
- <p class="title">Hello</p>
400
- <button>Toggle</button>
401
- </div>
402
- `,
403
- );
159
+ const Greeting = ilha(z.object({ name: z.string().default("World") }), ({ name }) => (
160
+ <p>Hello, {name}!</p>
161
+ ));
404
162
  ```
405
163
 
406
- Interpolations are supported:
164
+ Validation runs during SSR, hydration, mount, and prop updates.
407
165
 
408
- ```ts
409
- const accent = "teal";
166
+ ## Composing Islands
410
167
 
411
- ilha.css`button { background: ${accent}; }`.render(() => `<button>Go</button>`);
412
- ```
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:
413
169
 
414
- You can also pass a plain string (e.g. from an external `.css` file):
415
-
416
- ```ts
417
- import styles from "./card.css?raw";
418
-
419
- ilha.css(styles).render(() => `<div class="card">…</div>`);
420
- ```
421
-
422
- **SSR output** — a `<style data-ilha-css>` tag is prepended as the first child of the island's rendered HTML:
170
+ ```tsx
171
+ const Badge = ilha<{ label: string }>(({ label }) => <strong>{label}</strong>);
423
172
 
424
- ```html
425
- <style data-ilha-css>
426
- @scope (:scope) to ([data-ilha]) {
427
- .title {
428
- font-weight: 700;
429
- }
430
- }
431
- </style>
432
- <div>…</div>
173
+ const Page = ilha(() => <Badge label="New" />);
433
174
  ```
434
175
 
435
- **Client mount** — the style element is injected once as the first child of the host and preserved across re-renders (morph never replaces it). During hydration, the SSR-emitted `<style>` node is reused and not duplicated.
436
-
437
- **`.hydratable()` integration** — the style tag is included inside the `data-ilha` wrapper regardless of the `snapshot` option.
438
-
439
- > **Note:** Calling `.css()` more than once on the same builder chain is not supported. In dev mode a warning is logged and only the last stylesheet is used. Compose all your styles into a single `.css()` call.
440
-
441
- ---
442
-
443
- ### Composing Islands
444
-
445
- Child islands are interpolated directly inside a parent's html template. During SSR the child's HTML is rendered inline; during client mount the child is activated independently inside its own host element.
176
+ Each nested island is wrapped in a slot element (default `div`). Choose the wrapper tag with the child's `{ as }` constructor option:
446
177
 
447
- ```ts
448
- const Icon = ilha(() => `<svg>…</svg>`);
449
-
450
- const Card = ilha(
451
- () => html`
452
- <div class="card">
453
- ${Icon}
454
- <p>Card content</p>
455
- </div>
456
- `,
457
- );
178
+ ```tsx
179
+ const Row = ilha(({ label }) => <li>{label}</li>, { as: "li" });
458
180
  ```
459
181
 
460
- **Passing props** — use JSX props when composing a child island:
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:
461
183
 
462
184
  ```tsx
463
- const Badge = ilha
464
- .input(z.object({ label: z.string(), color: z.string().default("teal") }))
465
- .render(({ input }) => <span style={{ background: input.color }}>{input.label}</span>);
466
-
467
- const Card = ilha(() => (
468
- <div>
469
- <Badge label="New" color="coral" />
470
- <p>Content</p>
471
- </div>
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>
472
194
  ));
473
195
  ```
474
196
 
475
- Props (including JSX `children` and function callbacks) stay on the **live slot map** during parent render/mount. `data-ilha-props` only carries JSON-safe scalars for hydration hints — never rely on it for children or handlers. Nested islands under layout shells (`defineLayout` / `wrapLayout`) use the same path as page-level composition.
476
-
477
- **Keyed children** — use `.key()` when a child may reorder or appear conditionally. Keys must be unique within a parent render:
197
+ Keys must be unique within a parent render and cannot contain `:`.
478
198
 
479
- ```ts
480
- const List = ilha(
481
- () =>
482
- html`<ul>
483
- ${items.map((item) => html`<li>${Item.key(item.id)({ name: item.name })}</li>`)}
484
- </ul>`,
485
- );
486
- ```
487
-
488
- ---
199
+ ## Island Interface
489
200
 
490
- ### `.transition(opts)`
201
+ ### `island.toString(props?)`
491
202
 
492
- Attaches enter/leave transition callbacks called on mount and unmount respectively.
203
+ Synchronous SSR:
493
204
 
494
205
  ```ts
495
- ilha
496
- .transition({
497
- enter: async (host) => {
498
- host.animate([{ opacity: 0 }, { opacity: 1 }], 300).finished;
499
- },
500
- leave: async (host) => {
501
- await host.animate([{ opacity: 1 }, { opacity: 0 }], 300).finished;
502
- },
503
- })
504
- .render(() => `<div>content</div>`);
206
+ Counter.toString(); // → string
505
207
  ```
506
208
 
507
- The `leave` transition is awaited before cleanup runs.
508
-
509
- ---
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).
510
210
 
511
- ### `.render(fn)` and `ilha(fn)`
211
+ ### `await island.toStringAsync(props?)`
512
212
 
513
- `.render()` finalises a configured builder and returns an `Island`. The render function receives `{ state, derived, action, input }` and returns a string or `RawHtml`.
213
+ Async SSR — awaits async derived values and pulls the first value from async-generator derived values:
514
214
 
515
215
  ```ts
516
- const MyIsland = ilha.state("x", 1).render(({ state }) => html`<p>${state.x()}</p>`);
216
+ const html = await Counter.toStringAsync();
517
217
  ```
518
218
 
519
- For an island without builder configuration, `ilha(fn)` is shorthand for `ilha.render(fn)`:
520
-
521
- ```tsx
522
- const StaticIsland = ilha(() => <p>Hello</p>);
523
- const Label = ilha<{ label: string }>(({ input }) => <p>{input.label}</p>);
524
- ```
525
-
526
- ---
527
-
528
- ## Island Interface
529
-
530
- Every island produced by `.render()` or `ilha(fn)` exposes:
531
-
532
- ### `island.toString(props?)`
533
-
534
- Render the island to an HTML string synchronously. If `.derived()` entries have async functions, they render in `loading: true` state.
219
+ ### `island.mount(host, props?)`
535
220
 
536
- Use `await island(props)` instead when asynchronous derived values must settle before rendering. Always include `await` when using the callable form so a possible `Promise<string>` is never mistaken for a string.
221
+ Mount into a DOM element. Returns an unmount function that stops listeners, effects, and other active behavior:
537
222
 
538
223
  ```ts
539
- MyIsland.toString(); // always sync
540
- MyIsland.toString({ name: "Ilha" }); // with props
541
- await MyIsland({ name: "Ilha" }); // async — awaits derived
224
+ const unmount = Counter.mount(document.getElementById("app"));
542
225
  ```
543
226
 
544
- ---
545
-
546
- ### `island.mount(host, props?)`
547
-
548
- Mounts the island into a DOM element. Reads `data-ilha-props` and `data-ilha-state` from the host element automatically — no need to pass props when hydrating SSR output.
227
+ ### `await island.hydratable(props, options)`
549
228
 
550
- Returns an `unmount` function.
229
+ Emit hydration markup with serialized props and an optional state snapshot. `name` must match the client registry key:
551
230
 
552
231
  ```ts
553
- const unmount = MyIsland.mount(document.getElementById("app"));
554
- unmount(); // → stops effects, removes listeners, runs leave transition
232
+ // Server
233
+ const html = await Counter.hydratable({}, { name: "Counter", snapshot: true });
555
234
  ```
556
235
 
557
- In dev mode, double-mounting the same element logs a warning and returns a no-op.
558
-
559
- ---
560
-
561
- ### `island.hydratable(props, options)`
562
-
563
- Async method that renders the island wrapped in a `data-ilha` hydration container. Used for SSR+hydration pipelines.
564
-
565
236
  ```ts
566
- const html = await MyIsland.hydratable(
567
- { name: "Ilha" },
568
- {
569
- name: "MyIsland", // registry key for client-side activation
570
- as: "div", // wrapper tag (default: "div")
571
- snapshot: true, // embed state + derived as data-ilha-state
572
- skipOnMount: false, // skip onMount on hydration (default: true when snapshot)
573
- },
574
- );
575
- // → '<div data-ilha="MyIsland" data-ilha-props="…" data-ilha-state="…">…</div>'
237
+ // Client
238
+ mount({ Counter });
576
239
  ```
577
240
 
578
- **`snapshot` option:**
241
+ Snapshots are positional: state and derived values restore by primitive order. Malformed or incompatible snapshots are ignored safely.
579
242
 
580
- | Value | Behaviour |
581
- | --------------------------------- | --------------------------------------------- |
582
- | `false` | No snapshot — onMount always runs |
583
- | `true` | Snapshots both state and derived values |
584
- | `{ state: true, derived: false }` | Fine-grained control over what is snapshotted |
243
+ ### `island.key(key)`
585
244
 
586
- ---
587
-
588
- ## Top-level Helpers
245
+ Create a keyed child invocation for stable slot identity in lists (see [Composing Islands](#composing-islands)).
589
246
 
590
- ### `ilha.mount(registry, options?)` / `mount(registry, options?)`
247
+ ### `island.define(tagName, options?)`
591
248
 
592
- Auto-discovers all `[data-ilha]` elements in the DOM and mounts the corresponding island from the registry.
249
+ Register the island as a custom element, usable from plain HTML or any framework:
593
250
 
594
251
  ```ts
595
- import { mount } from "ilha";
596
-
597
- const { unmount } = mount(
598
- { counter: Counter, card: Card },
599
- {
600
- root: document.getElementById("app"), // default: document.body
601
- lazy: true, // use IntersectionObserver (mount on visibility)
602
- },
603
- );
604
-
605
- unmount(); // → unmounts all discovered islands
252
+ Counter.define("x-counter", { observe: ["start"] });
606
253
  ```
607
254
 
608
- ---
255
+ ## Top-level Helpers
609
256
 
610
- ### `ilha.from(selector, island, props?)` / `from(selector, island, props?)`
257
+ ### `mount(registry, options?)`
611
258
 
612
- Mounts a single island into the first element matching `selector`. Returns the `unmount` function, or `null` if the element is not found.
259
+ Auto-discover and mount `[data-ilha="Name"]` hosts:
613
260
 
614
261
  ```ts
615
- import { from } from "ilha";
616
-
617
- const unmount = from("#hero", HeroIsland, { title: "Welcome" });
262
+ mount({ Counter, Badge });
618
263
  ```
619
264
 
620
- ---
265
+ Pass `{ root, lazy }` to scope discovery to a root element or defer mounting until hosts scroll into view.
621
266
 
622
267
  ### `signal(initial)`
623
268
 
624
- Creates a free-standing reactive signal that lives outside any island. Useful for sharing state across multiple islands without prop drilling, or for binding form inputs to module-level state.
269
+ Create a free-standing signal for shared state. Same accessor shape as `state()` but lives outside any island:
625
270
 
626
271
  ```ts
627
- import { signal } from "ilha";
628
-
629
272
  const count = signal(0);
630
-
631
- count(); // → 0 (read)
632
- count(5); // → sets to 5 (write)
273
+ count(); // read
274
+ count(5); // write
633
275
  ```
634
276
 
635
- Reading the signal inside any reactive scope — `.render()`, `.derived()`, `.effect()` — automatically subscribes that scope, so when the signal changes, dependents re-run as if it were local state.
277
+ ### `computed(fn)`
278
+
279
+ Create a lazy, cached, read-only value derived from signals:
636
280
 
637
281
  ```ts
638
- import ilha, { signal, html } from "ilha";
282
+ const total = computed(() => price() * qty());
283
+ ```
639
284
 
640
- const username = signal("anonymous");
285
+ ### `effect(fn)`
641
286
 
642
- const Header = ilha(() => html`<header>Hi, ${username()}!</header>`);
643
- const Footer = ilha(() => html`<footer>Logged in as ${username()}</footer>`);
287
+ Run a standalone reactive effect outside any island; returns a stop function:
644
288
 
645
- // Both islands re-render when `username` changes from anywhere.
646
- username("alice");
289
+ ```ts
290
+ const stop = effect(() => {
291
+ document.title = `${count()} items`;
292
+ });
647
293
  ```
648
294
 
649
- Works naturally with `bind:` template syntax for two-way form bindings against module-level state.
650
-
651
- ---
295
+ Inside an island render, `effect()` registers an island effect slot instead.
652
296
 
653
297
  ### `context(key, initial)`
654
298
 
655
- Creates a **global context signal** — a named reactive signal shared across all islands. Identical keys always return the same signal instance, which makes it useful for app-wide singletons (theme, locale, current user) where you want the registry semantics.
299
+ Get or create a keyed, app-wide shared signal:
656
300
 
657
301
  ```ts
658
- import { context } from "ilha";
659
-
660
302
  const theme = context("app.theme", "light");
661
-
662
- theme(); // → "light"
663
- theme("dark"); // → sets to "dark"
664
303
  ```
665
304
 
666
- Safe to call in both SSR and browser environments.
667
-
668
- > **`signal()` vs `context()`** — both return the same accessor shape and can be used with `bind:` template syntax. Use `signal()` for one-off shared state where you'd hold the reference yourself; use `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key.
669
-
670
- ---
671
-
672
- ### `batch(fn)`
305
+ ### `batch(fn)` / `untrack(fn)`
673
306
 
674
- Runs `fn` as an atomic batch — multiple signal writes inside the callback produce a single propagation pass, so dependents see the final state and run once instead of once per write. Returns whatever `fn` returns.
307
+ `batch()` groups multiple writes into one propagation pass. `untrack()` reads signals without subscribing the surrounding scope:
675
308
 
676
309
  ```ts
677
- import { signal, batch } from "ilha";
678
-
679
- const a = signal(0);
680
- const b = signal(0);
681
-
682
- // Without batch: each write triggers a propagation pass.
683
- a(1); // → effects re-run
684
- b(2); // → effects re-run
685
-
686
- // With batch: both writes flush together.
687
310
  batch(() => {
688
- a(10);
689
- b(20);
690
- }); // → effects re-run once
691
- ```
311
+ a(1);
312
+ b(2);
313
+ });
692
314
 
693
- `.on()` handlers and `.effect()` runs are batched implicitly, so you only need `batch()` when triggering multiple writes from outside an island — e.g. from a top-level event listener, a `setTimeout` callback, or a WebSocket message handler. Nested `batch()` calls are safe and only flush when the outermost batch ends.
694
-
695
- ---
696
-
697
- ### `untrack(fn)`
698
-
699
- Runs `fn` with reactive tracking suspended. Reading signals inside `fn` returns their current value without subscribing the surrounding scope. Use this in effects or deriveds when you want to peek at state without causing a re-run on its changes.
700
-
701
- ```ts
702
- import ilha, { signal, untrack } from "ilha";
703
-
704
- const tracked = signal(0);
705
- const peeked = signal("hello");
706
-
707
- ilha
708
- .effect(() => {
709
- // Re-runs when `tracked` changes, but NOT when `peeked` changes.
710
- console.log(
711
- tracked(),
712
- untrack(() => peeked()),
713
- );
714
- })
715
- .render(() => `<p>x</p>`);
315
+ const value = untrack(() => secret());
716
316
  ```
717
317
 
718
- Returns whatever `fn` returns.
719
-
720
- ---
721
-
722
- ### `html\`\`` tagged template
318
+ ### `persist(accessor, key)`
723
319
 
724
- XSS-safe HTML template tag. Interpolated values are HTML-escaped by default. Pass `raw()` to opt out of escaping.
320
+ Keep a standalone signal in sync with `localStorage`:
725
321
 
726
322
  ```ts
727
- import { html, raw } from "ilha";
728
-
729
- const name = "<script>alert(1)</script>";
730
- html`<p>${name}</p>`; // → <p>&lt;script&gt;…</p> (escaped)
731
- html`<p>${raw("<b>hi</b>")}</p>`; // → <p><b>hi</b></p> (raw)
323
+ persist(cart, "cart");
732
324
  ```
733
325
 
734
- Interpolation rules:
735
-
736
- | Value type | Behaviour |
737
- | -------------------- | ------------------------------------------- |
738
- | `string` / `number` | HTML-escaped |
739
- | `null` / `undefined` | Omitted (empty string) |
740
- | `raw(str)` | Inserted as-is (no escaping) |
741
- | `html\`…\`` | Inserted as-is (already safe) |
742
- | Signal accessor | Called and escaped |
743
- | Island / Island call | Emitted as `data-ilha-slot` host element |
744
- | Array | Each item processed recursively (no commas) |
326
+ ### `onUncaughtError(fn)`
745
327
 
746
- **Template bindings** — use `bind:property=${signal}` inside `html\`\`` to create two-way bindings between form elements and signals:
328
+ Register an app-wide error sink for islands with no local `onError()`:
747
329
 
748
330
  ```ts
749
- ilha.state("name", "").render(
750
- ({ state }) => html`
751
- <input bind:value=${state.name} />
752
- <p>Hello, ${state.name()}!</p>
753
- `,
754
- );
331
+ const stop = onUncaughtError((error, source) => telemetry.capture(error, { source }));
755
332
  ```
756
333
 
757
- Supported bindings:
758
-
759
- | Binding | Element | Bound property | Trigger event |
760
- | -------------------- | ------------------------------------------------- | ------------------- | ------------- |
761
- | `bind:value` | `<input>`, `<textarea>`, `<select>` | `value` | `input` |
762
- | `bind:valueAsNumber` | `<input type="number">` | `valueAsNumber` | `input` |
763
- | `bind:valueAsDate` | `<input type="date">` | `valueAsDate` | `input` |
764
- | `bind:checked` | `<input type="checkbox">` | `checked` | `change` |
765
- | `bind:group` | `<input type="radio">`, `<input type="checkbox">` | `checked` / `value` | `change` |
766
- | `bind:open` | `<details>` | `open` | `toggle` |
767
- | `bind:files` | `<input type="file">` | `files` | `change` |
768
- | `bind:this` | Any element | element reference | — |
769
-
770
- `bind:group` connects multiple inputs to a single signal — radio buttons hold the selected `value`, checkboxes hold an array of checked values. `bind:this` writes the DOM element into a signal on mount and `null` on unmount. External signals from `signal()` or `context()` work as binding targets too, enabling shared state across islands.
334
+ ### `html` / `raw`
771
335
 
772
- **List rendering pattern:**
336
+ `html\`…\``is an XSS-safe tagged template that accepts signals, arrays, and nested templates.`raw(str)` opts into trusted markup:
773
337
 
774
338
  ```ts
775
- const items = ["apple", "banana", "cherry"];
776
- html`<ul>
777
- ${items.map((item) => html`<li>${item}</li>`)}
778
- </ul>`;
779
- ```
780
-
781
- ---
782
-
783
- ### `raw(value)`
784
-
785
- Marks a string as trusted raw HTML, bypassing escaping when used inside `html\`\``.
786
-
787
- ```ts
788
- import { raw } from "ilha";
339
+ import { html, raw } from "ilha";
789
340
 
790
- raw("<strong>bold</strong>"); // → passes through unescaped
341
+ html`<p>${count()}</p>`;
342
+ html`<button>${raw(icon)}</button>`;
791
343
  ```
792
344
 
793
- ---
794
-
795
- ### `css\`\`` tagged template
796
-
797
- A passthrough tagged template for CSS strings. Functionally identical to a plain template literal — no runtime transformation occurs. Its purpose is purely to enable editor tooling (LSP syntax highlighting, Prettier formatting) to recognise the contents as CSS.
345
+ ## Bindings
798
346
 
799
- ```ts
800
- import { css } from "ilha";
347
+ Use `bind:*` inside JSX or `html`` for two-way form synchronization:
801
348
 
802
- const styles = css`
803
- button {
804
- background: teal;
805
- color: white;
806
- }
807
- .label {
808
- font-weight: 700;
809
- }
810
- `;
811
-
812
- ilha.css(styles).render(() => `<button class="label">Go</button>`);
813
- ```
814
-
815
- Interpolations work as normal string concatenation:
816
-
817
- ```ts
818
- const accent = "coral";
819
- const styles = css`
820
- button {
821
- background: ${accent};
822
- }
823
- `;
349
+ ```tsx
350
+ <input bind:value={name} />
351
+ <input type="checkbox" bind:checked={done} />
824
352
  ```
825
353
 
826
- > **Note:** `css` (the named export) is the plain passthrough tag for tooling. `ilha.css` is the builder chain method that attaches styles to an island. They are intentionally separate.
827
-
828
- ---
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`.
829
355
 
830
- ## JSX Runtime
356
+ ## Security
831
357
 
832
- Prefer JSX over the `html` tag? `ilha` ships a JSX runtime (`ilha/jsx-runtime`) that produces the same XSS-safe output — JSX expressions evaluate to the same `RawHtml` values the `html` tag returns, so the two syntaxes are interchangeable and can be mixed freely.
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.
833
359
 
834
- ### Setup
360
+ ## TypeScript
835
361
 
836
- Enable the automatic JSX transform in `tsconfig.json` (works with TypeScript, Bun, Vite, esbuild, etc.):
362
+ Configure JSX with the automatic runtime:
837
363
 
838
- ```jsonc
364
+ ```json
839
365
  {
840
366
  "compilerOptions": {
841
367
  "jsx": "react-jsx",
842
- "jsxImportSource": "ilha",
843
- },
368
+ "jsxImportSource": "ilha"
369
+ }
844
370
  }
845
371
  ```
846
372
 
847
- ### Usage
848
-
849
- ```tsx
850
- import ilha from "ilha";
851
-
852
- const Counter = ilha
853
- .state("count", 0)
854
- .action("increment", (_, { state }) => {
855
- state.count((count) => count + 1);
856
- })
857
- .render(({ state, action }) => (
858
- <div>
859
- <p>Count: {state.count}</p>
860
- <button onclick={action.increment}>Increment</button>
861
- </div>
862
- ));
863
- ```
864
-
865
- Interpolated children follow the same rules as the `html` tag — strings are escaped, signal accessors are auto-called, islands become hydration slots, arrays are flattened. Use `raw()` to opt out of escaping, and `<></>` (Fragment) to group siblings without a wrapper element.
866
-
867
- ### Attributes
868
-
869
- | Feature | Behaviour |
870
- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
871
- | `class` / `className` | Accepts a string, an array (`["a", cond && "b"]`), or an object (`{ active: isActive }`) |
872
- | `htmlFor` | Alias for `for` |
873
- | `style` | Accepts a string or an object (`{ backgroundColor: "teal" }` → `background-color:teal`) |
874
- | Boolean attributes | `true` renders the bare attribute, `false`/`null`/`undefined` omit it |
875
- | `bind:*` | Two-way bindings, same as in `html` templates — pass a signal accessor: `<input bind:value={state.name} />` |
876
- | `key` | Keys a child island for reorder-safe rendering (same as `.key()`). Keys must be non-empty and must not contain `:` |
877
-
878
- Lowercase native event props such as `onclick={handler}` attach with `addEventListener` during island mount; executable handlers never become inline SSR attributes. `srcdoc` is stripped, and URL attributes (`href`, `src`, `action`, …) with unsafe schemes such as `javascript:` are dropped.
879
-
880
- ### Islands as components
881
-
882
- Islands are callable JSX components, so props and keys work as expected. Unlike transparent plain components, each island keeps its own reactive scope and lifecycle:
883
-
884
- ```tsx
885
- const Badge = ilha
886
- .input<{ label: string }>()
887
- .render(({ input }) => <span class="badge">{input.label}</span>);
888
-
889
- const Card = ilha(() => (
890
- <div class="card">
891
- <Badge label="New" />
892
- <p>Card content</p>
893
- </div>
894
- ));
895
-
896
- const List = ilha(() => (
897
- <ul>
898
- {items.map((item) => (
899
- <li>
900
- <Item key={item.id} name={item.name} />
901
- </li>
902
- ))}
903
- </ul>
904
- ));
905
- ```
906
-
907
- ---
908
-
909
- ## SSR + Hydration
910
-
911
- The recommended SSR + hydration pattern uses `.hydratable()` on the server and `ilha.mount()` on the client.
912
-
913
- ### Server
914
-
915
- ```ts
916
- import { MyIsland } from "./islands";
917
-
918
- const html = await MyIsland.hydratable({ count: 42 }, { name: "my-island", snapshot: true });
919
-
920
- return `<!doctype html><html><body>${html}</body></html>`;
921
- ```
922
-
923
- ### Client
924
-
925
- ```ts
926
- import { mount } from "ilha";
927
- import { MyIsland } from "./islands";
928
-
929
- mount({ MyIsland });
930
- ```
931
-
932
- The client reads `data-ilha-state` to restore signal values from the snapshot, skipping a needless re-render and calling `.onMount()` only if `skipOnMount` is not set.
933
-
934
- ### State snapshot flow
935
-
936
- ```
937
- server client
938
- ────────────────────────────────────── ──────────────────────────────────────────
939
- .hydratable({ count: 42 }, { mount({ MyIsland })
940
- name: "my-island", → reads data-ilha-state
941
- snapshot: true → restores signals from snapshot
942
- }) → skips onMount (skipOnMount: true)
943
- → data-ilha-state='{"count":42}' → attaches event listeners
944
- → starts effects + derived watchers
945
- ```
946
-
947
- ---
948
-
949
- ## TypeScript
950
-
951
- Key exported types:
952
-
953
- ```ts
954
- import type {
955
- Island,
956
- IslandState,
957
- IslandDerived,
958
- DerivedValue,
959
- KeyedIsland,
960
- HydratableOptions,
961
- OnMountContext,
962
- HandlerContext,
963
- HandlerContextFor,
964
- ErrorContext,
965
- ErrorSource,
966
- ExternalSignal,
967
- MountOptions,
968
- MountResult,
969
- } from "ilha";
970
- ```
971
-
972
- ---
973
-
974
- ## License
975
-
976
- MIT
373
+ Build tools resolve `ilha/jsx-runtime` in production and `ilha/jsx-dev-runtime` in development.