ilha 0.3.1 → 0.4.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 +223 -9
- package/dist/index.d.ts +74 -2
- package/dist/index.js +176 -20
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ ilha
|
|
|
138
138
|
.state("count", 0)
|
|
139
139
|
.on("@click", ({ state }) => state.count(state.count() + 1)) // host click
|
|
140
140
|
.on("button.inc@click", ({ state }) => state.count(state.count() + 1)) // child click
|
|
141
|
-
.on("input@input
|
|
141
|
+
.on("input@input", ({ state, event }) => {
|
|
142
142
|
state.query((event.target as HTMLInputElement).value);
|
|
143
143
|
})
|
|
144
144
|
.render(({ state }) => html`<div><button class="inc">+</button></div>`);
|
|
@@ -146,11 +146,12 @@ ilha
|
|
|
146
146
|
|
|
147
147
|
**Event modifiers** — append after a `:` separator:
|
|
148
148
|
|
|
149
|
-
| Modifier
|
|
150
|
-
|
|
|
151
|
-
| `once`
|
|
152
|
-
| `capture`
|
|
153
|
-
| `passive`
|
|
149
|
+
| Modifier | Description |
|
|
150
|
+
| ----------- | ------------------------------------------------------------------------- |
|
|
151
|
+
| `once` | Listener fires only once |
|
|
152
|
+
| `capture` | Capture phase |
|
|
153
|
+
| `passive` | `{ passive: true }` |
|
|
154
|
+
| `abortable` | `ctx.signal` aborts when the same listener fires again on the same target |
|
|
154
155
|
|
|
155
156
|
Multiple modifiers can be combined: `@click:once:capture`.
|
|
156
157
|
|
|
@@ -159,13 +160,62 @@ The handler receives a `HandlerContext`:
|
|
|
159
160
|
```ts
|
|
160
161
|
{
|
|
161
162
|
state: IslandState; // reactive state signals
|
|
163
|
+
derived: IslandDerived; // derived values
|
|
162
164
|
input: TInput; // resolved input props
|
|
163
165
|
host: Element; // island root element
|
|
164
166
|
target: Element; // element that fired the event (typed per event name)
|
|
165
167
|
event: Event; // the native event (typed per event name)
|
|
168
|
+
signal: AbortSignal; // aborts on unmount, and on next fire if `:abortable`
|
|
166
169
|
}
|
|
167
170
|
```
|
|
168
171
|
|
|
172
|
+
**Cancelling async work with `ctx.signal`** — pass it to `fetch` or any abort-aware API to cancel stale requests when the island unmounts:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
ilha
|
|
176
|
+
.state("results", [])
|
|
177
|
+
.on("button@click", async ({ state, signal }) => {
|
|
178
|
+
const res = await fetch("/api/data", { signal });
|
|
179
|
+
state.results(await res.json());
|
|
180
|
+
})
|
|
181
|
+
.render(
|
|
182
|
+
() =>
|
|
183
|
+
html`<button>Load</button>
|
|
184
|
+
<ul></ul>`,
|
|
185
|
+
);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**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:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
ilha
|
|
192
|
+
.on("input@input:abortable", async ({ state, event, signal }) => {
|
|
193
|
+
const q = (event.target as HTMLInputElement).value;
|
|
194
|
+
const res = await fetch(`/search?q=${q}`, { signal }); // earlier requests cancelled
|
|
195
|
+
if (signal.aborted) return;
|
|
196
|
+
state.results(await res.json());
|
|
197
|
+
})
|
|
198
|
+
.render(
|
|
199
|
+
() =>
|
|
200
|
+
html`<input />
|
|
201
|
+
<ul></ul>`,
|
|
202
|
+
);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Race-cancellation is scoped per-target — clicking button A doesn't cancel an in-flight handler on button B.
|
|
206
|
+
|
|
207
|
+
**Implicit batching** — multiple synchronous state writes in a single handler produce one re-render, not one per write:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
.on("@click", ({ state }) => {
|
|
211
|
+
state.a(1);
|
|
212
|
+
state.b(2);
|
|
213
|
+
state.c(3); // → one render, not three
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`AbortError` rejections from cancelled async work are filtered out automatically — they do not reach `.onError()` or `console.error`.
|
|
218
|
+
|
|
169
219
|
---
|
|
170
220
|
|
|
171
221
|
### `.effect(fn)`
|
|
@@ -184,6 +234,42 @@ ilha
|
|
|
184
234
|
.render(({ state }) => `<p>${state.title()}</p>`);
|
|
185
235
|
```
|
|
186
236
|
|
|
237
|
+
The handler receives an `EffectContext`:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
{
|
|
241
|
+
state: IslandState;
|
|
242
|
+
input: TInput;
|
|
243
|
+
host: Element;
|
|
244
|
+
signal: AbortSignal; // aborts when the effect re-runs OR the island unmounts
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**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:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
ilha
|
|
252
|
+
.state("userId", 1)
|
|
253
|
+
.state("user", null)
|
|
254
|
+
.effect(({ state, signal }) => {
|
|
255
|
+
(async () => {
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(`/api/users/${state.userId()}`, { signal });
|
|
258
|
+
if (signal.aborted) return;
|
|
259
|
+
state.user(await res.json());
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (err && (err as Error).name === "AbortError") return;
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
})
|
|
266
|
+
.render(({ state }) => html`<p>${state.user?.name ?? "Loading…"}</p>`);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Both the user-supplied cleanup function (if any) and the signal abort fire when the effect re-runs, so you can mix patterns.
|
|
270
|
+
|
|
271
|
+
**Implicit batching** — multiple synchronous state writes inside an effect run produce a single propagation pass.
|
|
272
|
+
|
|
187
273
|
---
|
|
188
274
|
|
|
189
275
|
### `.onMount(fn)`
|
|
@@ -203,6 +289,43 @@ ilha
|
|
|
203
289
|
|
|
204
290
|
---
|
|
205
291
|
|
|
292
|
+
### `.onError(fn)`
|
|
293
|
+
|
|
294
|
+
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.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
ilha
|
|
298
|
+
.state("count", 0)
|
|
299
|
+
.on("@click", ({ state }) => {
|
|
300
|
+
if (state.count() > 5) throw new Error("too many clicks");
|
|
301
|
+
state.count(state.count() + 1);
|
|
302
|
+
})
|
|
303
|
+
.onError(({ error, source }) => {
|
|
304
|
+
console.error(`[${source}] ${error.message}`);
|
|
305
|
+
Sentry.captureException(error);
|
|
306
|
+
})
|
|
307
|
+
.render(({ state }) => `<button>${state.count()}</button>`);
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The handler receives an `ErrorContext`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
{
|
|
314
|
+
error: Error; // always wrapped to Error if a non-Error was thrown
|
|
315
|
+
source: "on" | "effect";
|
|
316
|
+
state: IslandState;
|
|
317
|
+
derived: IslandDerived;
|
|
318
|
+
input: TInput;
|
|
319
|
+
host: Element;
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`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.
|
|
324
|
+
|
|
325
|
+
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.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
206
329
|
### `.bind(selector, stateKey | externalSignal)`
|
|
207
330
|
|
|
208
331
|
Two-way binds a form element to a state key or an external signal. Handles `input`, `select`, `textarea`, `checkbox`, `radio`, and `number` inputs automatically.
|
|
@@ -224,10 +347,15 @@ ilha
|
|
|
224
347
|
);
|
|
225
348
|
```
|
|
226
349
|
|
|
227
|
-
You can also bind to an external signal created with `context()`:
|
|
350
|
+
You can also bind to an external signal — either a free-standing one created with `signal()` or a named global one created with `context()`:
|
|
228
351
|
|
|
229
352
|
```ts
|
|
230
|
-
|
|
353
|
+
import { signal, context } from "ilha";
|
|
354
|
+
|
|
355
|
+
const username = signal("");
|
|
356
|
+
const theme = context("app.theme", "light");
|
|
357
|
+
|
|
358
|
+
ilha.bind("input.name", username).bind("select.theme", theme).render(/* … */);
|
|
231
359
|
```
|
|
232
360
|
|
|
233
361
|
---
|
|
@@ -461,9 +589,40 @@ const unmount = from("#hero", HeroIsland, { title: "Welcome" });
|
|
|
461
589
|
|
|
462
590
|
---
|
|
463
591
|
|
|
592
|
+
### `signal(initial)`
|
|
593
|
+
|
|
594
|
+
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.
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { signal } from "ilha";
|
|
598
|
+
|
|
599
|
+
const count = signal(0);
|
|
600
|
+
|
|
601
|
+
count(); // → 0 (read)
|
|
602
|
+
count(5); // → sets to 5 (write)
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
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.
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
import ilha, { signal, html } from "ilha";
|
|
609
|
+
|
|
610
|
+
const username = signal("anonymous");
|
|
611
|
+
|
|
612
|
+
const Header = ilha.render(() => html`<header>Hi, ${username()}!</header>`);
|
|
613
|
+
const Footer = ilha.render(() => html`<footer>Logged in as ${username()}</footer>`);
|
|
614
|
+
|
|
615
|
+
// Both islands re-render when `username` changes from anywhere.
|
|
616
|
+
username("alice");
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Pairs naturally with `.bind()` for two-way form bindings against module-level state.
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
464
623
|
### `context(key, initial)`
|
|
465
624
|
|
|
466
|
-
Creates a **global context signal** — a named reactive signal shared across all islands. Identical keys always return the same signal instance.
|
|
625
|
+
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.
|
|
467
626
|
|
|
468
627
|
```ts
|
|
469
628
|
import { context } from "ilha";
|
|
@@ -476,6 +635,58 @@ theme("dark"); // → sets to "dark"
|
|
|
476
635
|
|
|
477
636
|
Safe to call in both SSR and browser environments.
|
|
478
637
|
|
|
638
|
+
> **`signal()` vs `context()`** — both return the same accessor shape and can be passed to `.bind()`. 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.
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
### `batch(fn)`
|
|
643
|
+
|
|
644
|
+
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.
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
import { signal, batch } from "ilha";
|
|
648
|
+
|
|
649
|
+
const a = signal(0);
|
|
650
|
+
const b = signal(0);
|
|
651
|
+
|
|
652
|
+
// Without batch: each write triggers a propagation pass.
|
|
653
|
+
a(1); // → effects re-run
|
|
654
|
+
b(2); // → effects re-run
|
|
655
|
+
|
|
656
|
+
// With batch: both writes flush together.
|
|
657
|
+
batch(() => {
|
|
658
|
+
a(10);
|
|
659
|
+
b(20);
|
|
660
|
+
}); // → effects re-run once
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
`.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.
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
### `untrack(fn)`
|
|
668
|
+
|
|
669
|
+
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.
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
import ilha, { signal, untrack } from "ilha";
|
|
673
|
+
|
|
674
|
+
const tracked = signal(0);
|
|
675
|
+
const peeked = signal("hello");
|
|
676
|
+
|
|
677
|
+
ilha
|
|
678
|
+
.effect(() => {
|
|
679
|
+
// Re-runs when `tracked` changes, but NOT when `peeked` changes.
|
|
680
|
+
console.log(
|
|
681
|
+
tracked(),
|
|
682
|
+
untrack(() => peeked()),
|
|
683
|
+
);
|
|
684
|
+
})
|
|
685
|
+
.render(() => `<p>x</p>`);
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
Returns whatever `fn` returns.
|
|
689
|
+
|
|
479
690
|
---
|
|
480
691
|
|
|
481
692
|
### `html\`\`` tagged template
|
|
@@ -615,6 +826,9 @@ import type {
|
|
|
615
826
|
OnMountContext,
|
|
616
827
|
HandlerContext,
|
|
617
828
|
HandlerContextFor,
|
|
829
|
+
ErrorContext,
|
|
830
|
+
ErrorSource,
|
|
831
|
+
ExternalSignal,
|
|
618
832
|
MountOptions,
|
|
619
833
|
MountResult,
|
|
620
834
|
} from "ilha";
|
package/dist/index.d.ts
CHANGED
|
@@ -57,6 +57,35 @@ type ContextSignal<T> = {
|
|
|
57
57
|
(value: T): void;
|
|
58
58
|
};
|
|
59
59
|
declare function ilhaContext<T>(key: string, initial: T): ContextSignal<T>;
|
|
60
|
+
/**
|
|
61
|
+
* Create a free-standing reactive signal that lives outside any island.
|
|
62
|
+
* Useful for sharing state across islands without prop drilling, or for
|
|
63
|
+
* binding form inputs to module-level state via `.bind(selector, signal)`.
|
|
64
|
+
*
|
|
65
|
+
* The returned accessor is a getter when called with no arguments and a
|
|
66
|
+
* setter when called with one. Reading it inside a `.derived()`, `.effect()`,
|
|
67
|
+
* or `.render()` automatically subscribes the surrounding reactive scope —
|
|
68
|
+
* so when the signal changes, dependents re-run as if it were local state.
|
|
69
|
+
*/
|
|
70
|
+
declare function ilhaSignal<T>(initial: T): ExternalSignal<T>;
|
|
71
|
+
/**
|
|
72
|
+
* Run `fn` with reactive tracking suspended. Reading signals inside `fn`
|
|
73
|
+
* returns their current value without subscribing the surrounding scope.
|
|
74
|
+
* Use this in effects/deriveds when you want to peek at state without
|
|
75
|
+
* causing a re-run on its changes.
|
|
76
|
+
*/
|
|
77
|
+
declare function untrack<T>(fn: () => T): T;
|
|
78
|
+
/**
|
|
79
|
+
* Run `fn` as an atomic batch — multiple signal writes inside the callback
|
|
80
|
+
* produce a single propagation pass, so dependents (effects, deriveds,
|
|
81
|
+
* island re-renders) see the final state and run once instead of once per
|
|
82
|
+
* write. Returns whatever `fn` returns.
|
|
83
|
+
*
|
|
84
|
+
* Note: `.on()` handlers and `.effect()` runs are batched implicitly, so
|
|
85
|
+
* you only need this when triggering multiple writes from outside an
|
|
86
|
+
* island (e.g. from a top-level event listener or async callback).
|
|
87
|
+
*/
|
|
88
|
+
declare function batch<T>(fn: () => T): T;
|
|
60
89
|
interface DerivedValue<T> {
|
|
61
90
|
loading: boolean;
|
|
62
91
|
value: T | undefined;
|
|
@@ -115,6 +144,13 @@ type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
|
|
|
115
144
|
state: IslandState<TStateMap>;
|
|
116
145
|
input: TInput;
|
|
117
146
|
host: Element;
|
|
147
|
+
/**
|
|
148
|
+
* AbortSignal that aborts when the effect re-runs (because a dependency
|
|
149
|
+
* changed) or when the island unmounts. Pass to `fetch` or check
|
|
150
|
+
* `signal.aborted` after `await` boundaries to bail out of stale work
|
|
151
|
+
* without needing a manual cleanup function.
|
|
152
|
+
*/
|
|
153
|
+
signal: AbortSignal;
|
|
118
154
|
};
|
|
119
155
|
type OnMountContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
|
|
120
156
|
state: IslandState<TStateMap>;
|
|
@@ -130,9 +166,18 @@ type HandlerContext<TInput, TStateMap extends Record<string, unknown>, TDerivedM
|
|
|
130
166
|
host: Element;
|
|
131
167
|
target: Element;
|
|
132
168
|
event: Event;
|
|
169
|
+
/**
|
|
170
|
+
* AbortSignal that fires when the island unmounts. If the handler's selector
|
|
171
|
+
* was registered with the `:abortable` modifier, the signal is also aborted
|
|
172
|
+
* when the same listener fires again on the same target (giving you free
|
|
173
|
+
* race-cancellation for things like search-as-you-type fetches). Pass this
|
|
174
|
+
* to `fetch`, `AbortController`-aware APIs, or check `signal.aborted`
|
|
175
|
+
* after `await` boundaries to bail out of stale work.
|
|
176
|
+
*/
|
|
177
|
+
signal: AbortSignal;
|
|
133
178
|
};
|
|
134
179
|
type HTMLEventName = keyof HTMLElementEventMap & string;
|
|
135
|
-
type Modifier = "once" | "capture" | "passive";
|
|
180
|
+
type Modifier = "once" | "capture" | "passive" | "abortable";
|
|
136
181
|
type WithModifiers<E extends string> = E | `${E}:${Modifier}` | `${E}:${Modifier}:${Modifier}` | `${E}:${Modifier}:${Modifier}:${Modifier}`;
|
|
137
182
|
type OnSelectorString = `@${WithModifiers<HTMLEventName>}` | `${string}@${WithModifiers<HTMLEventName>}`;
|
|
138
183
|
type HandlerContextFor<TInput, TStateMap extends Record<string, unknown>, TEventName extends string, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
|
|
@@ -142,6 +187,12 @@ type HandlerContextFor<TInput, TStateMap extends Record<string, unknown>, TEvent
|
|
|
142
187
|
host: Element;
|
|
143
188
|
target: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName]["target"] extends Element | null ? NonNullable<HTMLElementEventMap[TEventName]["target"]> : Element : Element;
|
|
144
189
|
event: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName] : Event;
|
|
190
|
+
/**
|
|
191
|
+
* AbortSignal that fires when the island unmounts. If the handler's selector
|
|
192
|
+
* was registered with the `:abortable` modifier, the signal is also aborted
|
|
193
|
+
* when the same listener fires again on the same target.
|
|
194
|
+
*/
|
|
195
|
+
signal: AbortSignal;
|
|
145
196
|
};
|
|
146
197
|
type StateInit<TInput, V> = V | ((input: TInput) => V);
|
|
147
198
|
interface StateEntry<TInput> {
|
|
@@ -152,11 +203,27 @@ interface OnEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap
|
|
|
152
203
|
selector: string;
|
|
153
204
|
event: string;
|
|
154
205
|
options: AddEventListenerOptions;
|
|
206
|
+
abortable: boolean;
|
|
155
207
|
handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>;
|
|
156
208
|
}
|
|
157
209
|
interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
|
|
158
210
|
fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void;
|
|
159
211
|
}
|
|
212
|
+
/** Where the error originated. `"on"` covers sync throws and async rejections
|
|
213
|
+
* from `.on()` handlers; `"effect"` covers sync throws from `.effect()` runs
|
|
214
|
+
* (async work spawned inside an effect is not awaited by the runtime). */
|
|
215
|
+
type ErrorSource = "on" | "effect";
|
|
216
|
+
type ErrorContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
|
|
217
|
+
error: Error;
|
|
218
|
+
source: ErrorSource;
|
|
219
|
+
state: IslandState<TStateMap>;
|
|
220
|
+
derived: IslandDerived<TDerivedMap>;
|
|
221
|
+
input: TInput;
|
|
222
|
+
host: Element;
|
|
223
|
+
};
|
|
224
|
+
interface OnErrorEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
|
|
225
|
+
fn: (ctx: ErrorContext<TInput, TStateMap, TDerivedMap>) => void;
|
|
226
|
+
}
|
|
160
227
|
interface OnMountEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
|
|
161
228
|
fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void;
|
|
162
229
|
}
|
|
@@ -178,6 +245,7 @@ interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDeri
|
|
|
178
245
|
ons: OnEntry<TInput, TStateMap, TDerivedMap>[];
|
|
179
246
|
effects: EffectEntry<TInput, TStateMap>[];
|
|
180
247
|
onMounts: OnMountEntry<TInput, TStateMap, TDerivedMap>[];
|
|
248
|
+
onErrors: OnErrorEntry<TInput, TStateMap, TDerivedMap>[];
|
|
181
249
|
transition: TransitionOptions | null;
|
|
182
250
|
binds: BindEntry<TStateMap>[];
|
|
183
251
|
css: string | null;
|
|
@@ -195,6 +263,7 @@ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap exte
|
|
|
195
263
|
on(selectorOrCombined: string, handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
196
264
|
effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
197
265
|
onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
266
|
+
onError(fn: (ctx: ErrorContext<TInput, TStateMap, TDerivedMap>) => void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
198
267
|
transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
199
268
|
css(strings: TemplateStringsArray | string, ...values: (string | number)[]): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
|
|
200
269
|
render(fn: (ctx: RenderContext<TInput, TStateMap, TDerivedMap>) => string | RawHtml): Island<TInput, TStateMap>;
|
|
@@ -208,6 +277,9 @@ declare const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>,
|
|
|
208
277
|
mount: typeof mountAll;
|
|
209
278
|
from: typeof ilhaFrom;
|
|
210
279
|
context: typeof ilhaContext;
|
|
280
|
+
signal: typeof ilhaSignal;
|
|
281
|
+
batch: typeof batch;
|
|
282
|
+
untrack: typeof untrack;
|
|
211
283
|
};
|
|
212
284
|
declare const html: typeof ilhaHtml;
|
|
213
285
|
declare const raw: typeof ilhaRaw;
|
|
@@ -216,4 +288,4 @@ declare const mount: typeof mountAll;
|
|
|
216
288
|
declare const from: typeof ilhaFrom;
|
|
217
289
|
declare const context: typeof ilhaContext;
|
|
218
290
|
//#endregion
|
|
219
|
-
export { DerivedValue, HandlerContext, HandlerContextFor, HydratableOptions, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, context, css, ilha as default, from, html, mount, raw };
|
|
291
|
+
export { DerivedValue, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { effect, setActiveSub, signal } from "alien-signals";
|
|
1
|
+
import { effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals";
|
|
2
2
|
//#region src/index.ts
|
|
3
3
|
const __DEV__ = typeof process !== "undefined" ? process.env?.["NODE_ENV"] !== "production" : true;
|
|
4
4
|
function warn(msg) {
|
|
@@ -212,6 +212,56 @@ function ilhaContext(key, initial) {
|
|
|
212
212
|
contextRegistry.set(key, accessor);
|
|
213
213
|
return accessor;
|
|
214
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Create a free-standing reactive signal that lives outside any island.
|
|
217
|
+
* Useful for sharing state across islands without prop drilling, or for
|
|
218
|
+
* binding form inputs to module-level state via `.bind(selector, signal)`.
|
|
219
|
+
*
|
|
220
|
+
* The returned accessor is a getter when called with no arguments and a
|
|
221
|
+
* setter when called with one. Reading it inside a `.derived()`, `.effect()`,
|
|
222
|
+
* or `.render()` automatically subscribes the surrounding reactive scope —
|
|
223
|
+
* so when the signal changes, dependents re-run as if it were local state.
|
|
224
|
+
*/
|
|
225
|
+
function ilhaSignal(initial) {
|
|
226
|
+
const s = signal(initial);
|
|
227
|
+
const accessor = (...args) => {
|
|
228
|
+
if (args.length === 0) return s();
|
|
229
|
+
s(args[0]);
|
|
230
|
+
};
|
|
231
|
+
return accessor;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Run `fn` with reactive tracking suspended. Reading signals inside `fn`
|
|
235
|
+
* returns their current value without subscribing the surrounding scope.
|
|
236
|
+
* Use this in effects/deriveds when you want to peek at state without
|
|
237
|
+
* causing a re-run on its changes.
|
|
238
|
+
*/
|
|
239
|
+
function untrack(fn) {
|
|
240
|
+
const prev = setActiveSub(void 0);
|
|
241
|
+
try {
|
|
242
|
+
return fn();
|
|
243
|
+
} finally {
|
|
244
|
+
setActiveSub(prev);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Run `fn` as an atomic batch — multiple signal writes inside the callback
|
|
249
|
+
* produce a single propagation pass, so dependents (effects, deriveds,
|
|
250
|
+
* island re-renders) see the final state and run once instead of once per
|
|
251
|
+
* write. Returns whatever `fn` returns.
|
|
252
|
+
*
|
|
253
|
+
* Note: `.on()` handlers and `.effect()` runs are batched implicitly, so
|
|
254
|
+
* you only need this when triggering multiple writes from outside an
|
|
255
|
+
* island (e.g. from a top-level event listener or async callback).
|
|
256
|
+
*/
|
|
257
|
+
function batch(fn) {
|
|
258
|
+
startBatch();
|
|
259
|
+
try {
|
|
260
|
+
return fn();
|
|
261
|
+
} finally {
|
|
262
|
+
endBatch();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
215
265
|
function buildDerivedSignals(entries, state, input, derivedSnapshot) {
|
|
216
266
|
const envelopes = /* @__PURE__ */ new Map();
|
|
217
267
|
const stops = [];
|
|
@@ -363,9 +413,34 @@ function parseOnArgs(selectorOrCombined) {
|
|
|
363
413
|
once: modifiers.has("once"),
|
|
364
414
|
capture: modifiers.has("capture"),
|
|
365
415
|
passive: modifiers.has("passive")
|
|
366
|
-
}
|
|
416
|
+
},
|
|
417
|
+
abortable: modifiers.has("abortable")
|
|
367
418
|
};
|
|
368
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Combine multiple AbortSignals into one that aborts when any input aborts.
|
|
422
|
+
* Uses native AbortSignal.any when available, falls back to a manual chain
|
|
423
|
+
* otherwise (for older runtimes).
|
|
424
|
+
*/
|
|
425
|
+
function anySignal(signals) {
|
|
426
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
|
|
427
|
+
const controller = new AbortController();
|
|
428
|
+
const cleanups = [];
|
|
429
|
+
for (const s of signals) {
|
|
430
|
+
if (s.aborted) {
|
|
431
|
+
controller.abort(s.reason);
|
|
432
|
+
cleanups.forEach((c) => c());
|
|
433
|
+
return controller.signal;
|
|
434
|
+
}
|
|
435
|
+
const handler = () => controller.abort(s.reason);
|
|
436
|
+
s.addEventListener("abort", handler, { once: true });
|
|
437
|
+
cleanups.push(() => s.removeEventListener("abort", handler));
|
|
438
|
+
}
|
|
439
|
+
if (!controller.signal.aborted) controller.signal.addEventListener("abort", () => {
|
|
440
|
+
cleanups.forEach((c) => c());
|
|
441
|
+
});
|
|
442
|
+
return controller.signal;
|
|
443
|
+
}
|
|
369
444
|
const _mountedHosts = __DEV__ ? /* @__PURE__ */ new WeakSet() : null;
|
|
370
445
|
var IlhaBuilder = class IlhaBuilder {
|
|
371
446
|
_cfg;
|
|
@@ -380,6 +455,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
380
455
|
ons: [],
|
|
381
456
|
effects: [],
|
|
382
457
|
onMounts: [],
|
|
458
|
+
onErrors: [],
|
|
383
459
|
transition: null,
|
|
384
460
|
binds: [],
|
|
385
461
|
css: null
|
|
@@ -422,6 +498,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
422
498
|
selector: parsed.selector,
|
|
423
499
|
event: parsed.eventType,
|
|
424
500
|
options: parsed.options,
|
|
501
|
+
abortable: parsed.abortable,
|
|
425
502
|
handler
|
|
426
503
|
}]
|
|
427
504
|
});
|
|
@@ -438,6 +515,12 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
438
515
|
onMounts: [...this._cfg.onMounts, { fn }]
|
|
439
516
|
});
|
|
440
517
|
}
|
|
518
|
+
onError(fn) {
|
|
519
|
+
return new IlhaBuilder({
|
|
520
|
+
...this._cfg,
|
|
521
|
+
onErrors: [...this._cfg.onErrors, { fn }]
|
|
522
|
+
});
|
|
523
|
+
}
|
|
441
524
|
transition(opts) {
|
|
442
525
|
return new IlhaBuilder({
|
|
443
526
|
...this._cfg,
|
|
@@ -462,7 +545,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
462
545
|
});
|
|
463
546
|
}
|
|
464
547
|
render(fn) {
|
|
465
|
-
const { schema, states, deriveds, ons, effects, onMounts, transition, binds, css: cssSource } = this._cfg;
|
|
548
|
+
const { schema, states, deriveds, ons, effects, onMounts, onErrors, transition, binds, css: cssSource } = this._cfg;
|
|
466
549
|
const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
|
|
467
550
|
function resolveInput(props) {
|
|
468
551
|
const value = props ?? {};
|
|
@@ -628,12 +711,33 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
628
711
|
const shouldSkipOnMount = hydrated && snapshotRaw?.["_skipOnMount"] === true;
|
|
629
712
|
const state = buildSignalState(input, stateSnapshot);
|
|
630
713
|
const cleanups = [];
|
|
714
|
+
const unmountController = new AbortController();
|
|
715
|
+
cleanups.push(() => unmountController.abort());
|
|
631
716
|
if (transition?.enter) {
|
|
632
717
|
const result = transition.enter(host);
|
|
633
718
|
if (result instanceof Promise) result.catch(console.error);
|
|
634
719
|
}
|
|
635
720
|
const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input, derivedSnapshot);
|
|
636
721
|
cleanups.push(stopDerived);
|
|
722
|
+
function reportError(err, source) {
|
|
723
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
724
|
+
if (onErrors.length === 0) {
|
|
725
|
+
console.error(error);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
for (const entry of onErrors) try {
|
|
729
|
+
entry.fn({
|
|
730
|
+
error,
|
|
731
|
+
source,
|
|
732
|
+
state,
|
|
733
|
+
derived,
|
|
734
|
+
input,
|
|
735
|
+
host
|
|
736
|
+
});
|
|
737
|
+
} catch (handlerErr) {
|
|
738
|
+
console.error(handlerErr);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
637
741
|
const mountedSlots = /* @__PURE__ */ new Map();
|
|
638
742
|
function findSlot(id) {
|
|
639
743
|
const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
|
|
@@ -681,6 +785,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
681
785
|
}
|
|
682
786
|
const listeners = [];
|
|
683
787
|
const firedOnce = /* @__PURE__ */ new Set();
|
|
788
|
+
const invocationControllers = /* @__PURE__ */ new WeakMap();
|
|
684
789
|
function attachListeners() {
|
|
685
790
|
for (const entry of ons) {
|
|
686
791
|
if (entry.options.once && firedOnce.has(entry)) continue;
|
|
@@ -694,15 +799,41 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
694
799
|
listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
|
|
695
800
|
}
|
|
696
801
|
const eventTarget = event.target instanceof Element ? event.target : listenerTarget;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
802
|
+
let handlerSignal;
|
|
803
|
+
if (entry.abortable) {
|
|
804
|
+
let entryMap = invocationControllers.get(entry);
|
|
805
|
+
if (!entryMap) {
|
|
806
|
+
entryMap = /* @__PURE__ */ new WeakMap();
|
|
807
|
+
invocationControllers.set(entry, entryMap);
|
|
808
|
+
}
|
|
809
|
+
const prev = entryMap.get(listenerTarget);
|
|
810
|
+
if (prev) prev.abort();
|
|
811
|
+
const invocationController = new AbortController();
|
|
812
|
+
entryMap.set(listenerTarget, invocationController);
|
|
813
|
+
handlerSignal = anySignal([unmountController.signal, invocationController.signal]);
|
|
814
|
+
} else handlerSignal = unmountController.signal;
|
|
815
|
+
let result;
|
|
816
|
+
startBatch();
|
|
817
|
+
try {
|
|
818
|
+
result = entry.handler({
|
|
819
|
+
state,
|
|
820
|
+
derived,
|
|
821
|
+
input,
|
|
822
|
+
host,
|
|
823
|
+
target: eventTarget,
|
|
824
|
+
event,
|
|
825
|
+
signal: handlerSignal
|
|
826
|
+
});
|
|
827
|
+
} catch (err) {
|
|
828
|
+
reportError(err, "on");
|
|
829
|
+
endBatch();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
endBatch();
|
|
833
|
+
if (result instanceof Promise) result.catch((err) => {
|
|
834
|
+
if (err && err.name === "AbortError") return;
|
|
835
|
+
reportError(err, "on");
|
|
704
836
|
});
|
|
705
|
-
if (result instanceof Promise) result.catch(console.error);
|
|
706
837
|
};
|
|
707
838
|
const opts = {
|
|
708
839
|
...entry.options,
|
|
@@ -794,20 +925,41 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
794
925
|
cleanups.push(detachListeners);
|
|
795
926
|
for (const entry of effects) {
|
|
796
927
|
let userCleanup;
|
|
928
|
+
let runController = null;
|
|
797
929
|
const stopEffect = effect(() => {
|
|
798
930
|
if (userCleanup) {
|
|
799
|
-
|
|
931
|
+
try {
|
|
932
|
+
userCleanup();
|
|
933
|
+
} catch (err) {
|
|
934
|
+
reportError(err, "effect");
|
|
935
|
+
}
|
|
800
936
|
userCleanup = void 0;
|
|
801
937
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
938
|
+
if (runController) runController.abort();
|
|
939
|
+
runController = new AbortController();
|
|
940
|
+
const runSignal = anySignal([unmountController.signal, runController.signal]);
|
|
941
|
+
startBatch();
|
|
942
|
+
try {
|
|
943
|
+
userCleanup = entry.fn({
|
|
944
|
+
state,
|
|
945
|
+
input,
|
|
946
|
+
host,
|
|
947
|
+
signal: runSignal
|
|
948
|
+
});
|
|
949
|
+
} catch (err) {
|
|
950
|
+
reportError(err, "effect");
|
|
951
|
+
} finally {
|
|
952
|
+
endBatch();
|
|
953
|
+
}
|
|
807
954
|
});
|
|
808
955
|
cleanups.push(() => {
|
|
809
956
|
stopEffect();
|
|
810
|
-
if (userCleanup)
|
|
957
|
+
if (userCleanup) try {
|
|
958
|
+
userCleanup();
|
|
959
|
+
} catch (err) {
|
|
960
|
+
reportError(err, "effect");
|
|
961
|
+
}
|
|
962
|
+
if (runController) runController.abort();
|
|
811
963
|
});
|
|
812
964
|
}
|
|
813
965
|
let tornDown = false;
|
|
@@ -958,6 +1110,7 @@ const rootBuilder = new IlhaBuilder({
|
|
|
958
1110
|
ons: [],
|
|
959
1111
|
effects: [],
|
|
960
1112
|
onMounts: [],
|
|
1113
|
+
onErrors: [],
|
|
961
1114
|
transition: null,
|
|
962
1115
|
binds: [],
|
|
963
1116
|
css: null
|
|
@@ -967,7 +1120,10 @@ const ilha = Object.assign(rootBuilder, {
|
|
|
967
1120
|
raw: ilhaRaw,
|
|
968
1121
|
mount: mountAll,
|
|
969
1122
|
from: ilhaFrom,
|
|
970
|
-
context: ilhaContext
|
|
1123
|
+
context: ilhaContext,
|
|
1124
|
+
signal: ilhaSignal,
|
|
1125
|
+
batch,
|
|
1126
|
+
untrack
|
|
971
1127
|
});
|
|
972
1128
|
const html = ilhaHtml;
|
|
973
1129
|
const raw = ilhaRaw;
|
|
@@ -976,4 +1132,4 @@ const mount = mountAll;
|
|
|
976
1132
|
const from = ilhaFrom;
|
|
977
1133
|
const context = ilhaContext;
|
|
978
1134
|
//#endregion
|
|
979
|
-
export { context, css, ilha as default, from, html, mount, raw };
|
|
1135
|
+
export { batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ilha",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A tiny, framework-free island architecture library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ryuz <ryuzer@proton.me>",
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"alien-signals": "3.1.2"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"zod": "^4.
|
|
29
|
+
"zod": "^4.4.1"
|
|
30
30
|
}
|
|
31
31
|
}
|