ilha 0.3.0 → 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 +181 -24
- 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 = [];
|
|
@@ -225,10 +275,6 @@ function buildDerivedSignals(entries, state, input, derivedSnapshot) {
|
|
|
225
275
|
let ac = new AbortController();
|
|
226
276
|
let skipFirst = derivedSnapshot != null && entry.key in derivedSnapshot;
|
|
227
277
|
const stopEffect = effect(() => {
|
|
228
|
-
if (skipFirst) {
|
|
229
|
-
skipFirst = false;
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
278
|
ac.abort();
|
|
233
279
|
ac = new AbortController();
|
|
234
280
|
const currentAc = ac;
|
|
@@ -237,6 +283,11 @@ function buildDerivedSignals(entries, state, input, derivedSnapshot) {
|
|
|
237
283
|
input,
|
|
238
284
|
signal: currentAc.signal
|
|
239
285
|
});
|
|
286
|
+
if (skipFirst) {
|
|
287
|
+
skipFirst = false;
|
|
288
|
+
if (result instanceof Promise) result.catch(() => {});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
240
291
|
if (!(result instanceof Promise)) {
|
|
241
292
|
const prevSub = setActiveSub(void 0);
|
|
242
293
|
env({
|
|
@@ -362,9 +413,34 @@ function parseOnArgs(selectorOrCombined) {
|
|
|
362
413
|
once: modifiers.has("once"),
|
|
363
414
|
capture: modifiers.has("capture"),
|
|
364
415
|
passive: modifiers.has("passive")
|
|
365
|
-
}
|
|
416
|
+
},
|
|
417
|
+
abortable: modifiers.has("abortable")
|
|
366
418
|
};
|
|
367
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
|
+
}
|
|
368
444
|
const _mountedHosts = __DEV__ ? /* @__PURE__ */ new WeakSet() : null;
|
|
369
445
|
var IlhaBuilder = class IlhaBuilder {
|
|
370
446
|
_cfg;
|
|
@@ -379,6 +455,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
379
455
|
ons: [],
|
|
380
456
|
effects: [],
|
|
381
457
|
onMounts: [],
|
|
458
|
+
onErrors: [],
|
|
382
459
|
transition: null,
|
|
383
460
|
binds: [],
|
|
384
461
|
css: null
|
|
@@ -421,6 +498,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
421
498
|
selector: parsed.selector,
|
|
422
499
|
event: parsed.eventType,
|
|
423
500
|
options: parsed.options,
|
|
501
|
+
abortable: parsed.abortable,
|
|
424
502
|
handler
|
|
425
503
|
}]
|
|
426
504
|
});
|
|
@@ -437,6 +515,12 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
437
515
|
onMounts: [...this._cfg.onMounts, { fn }]
|
|
438
516
|
});
|
|
439
517
|
}
|
|
518
|
+
onError(fn) {
|
|
519
|
+
return new IlhaBuilder({
|
|
520
|
+
...this._cfg,
|
|
521
|
+
onErrors: [...this._cfg.onErrors, { fn }]
|
|
522
|
+
});
|
|
523
|
+
}
|
|
440
524
|
transition(opts) {
|
|
441
525
|
return new IlhaBuilder({
|
|
442
526
|
...this._cfg,
|
|
@@ -461,7 +545,7 @@ var IlhaBuilder = class IlhaBuilder {
|
|
|
461
545
|
});
|
|
462
546
|
}
|
|
463
547
|
render(fn) {
|
|
464
|
-
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;
|
|
465
549
|
const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
|
|
466
550
|
function resolveInput(props) {
|
|
467
551
|
const value = props ?? {};
|
|
@@ -627,12 +711,33 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
627
711
|
const shouldSkipOnMount = hydrated && snapshotRaw?.["_skipOnMount"] === true;
|
|
628
712
|
const state = buildSignalState(input, stateSnapshot);
|
|
629
713
|
const cleanups = [];
|
|
714
|
+
const unmountController = new AbortController();
|
|
715
|
+
cleanups.push(() => unmountController.abort());
|
|
630
716
|
if (transition?.enter) {
|
|
631
717
|
const result = transition.enter(host);
|
|
632
718
|
if (result instanceof Promise) result.catch(console.error);
|
|
633
719
|
}
|
|
634
720
|
const { proxy: derived, stop: stopDerived } = buildDerivedSignals(deriveds, state, input, derivedSnapshot);
|
|
635
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
|
+
}
|
|
636
741
|
const mountedSlots = /* @__PURE__ */ new Map();
|
|
637
742
|
function findSlot(id) {
|
|
638
743
|
const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
|
|
@@ -680,6 +785,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
680
785
|
}
|
|
681
786
|
const listeners = [];
|
|
682
787
|
const firedOnce = /* @__PURE__ */ new Set();
|
|
788
|
+
const invocationControllers = /* @__PURE__ */ new WeakMap();
|
|
683
789
|
function attachListeners() {
|
|
684
790
|
for (const entry of ons) {
|
|
685
791
|
if (entry.options.once && firedOnce.has(entry)) continue;
|
|
@@ -693,15 +799,41 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
693
799
|
listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
|
|
694
800
|
}
|
|
695
801
|
const eventTarget = event.target instanceof Element ? event.target : listenerTarget;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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");
|
|
703
836
|
});
|
|
704
|
-
if (result instanceof Promise) result.catch(console.error);
|
|
705
837
|
};
|
|
706
838
|
const opts = {
|
|
707
839
|
...entry.options,
|
|
@@ -793,20 +925,41 @@ Element: ${host.outerHTML.slice(0, 120)}`);
|
|
|
793
925
|
cleanups.push(detachListeners);
|
|
794
926
|
for (const entry of effects) {
|
|
795
927
|
let userCleanup;
|
|
928
|
+
let runController = null;
|
|
796
929
|
const stopEffect = effect(() => {
|
|
797
930
|
if (userCleanup) {
|
|
798
|
-
|
|
931
|
+
try {
|
|
932
|
+
userCleanup();
|
|
933
|
+
} catch (err) {
|
|
934
|
+
reportError(err, "effect");
|
|
935
|
+
}
|
|
799
936
|
userCleanup = void 0;
|
|
800
937
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
+
}
|
|
806
954
|
});
|
|
807
955
|
cleanups.push(() => {
|
|
808
956
|
stopEffect();
|
|
809
|
-
if (userCleanup)
|
|
957
|
+
if (userCleanup) try {
|
|
958
|
+
userCleanup();
|
|
959
|
+
} catch (err) {
|
|
960
|
+
reportError(err, "effect");
|
|
961
|
+
}
|
|
962
|
+
if (runController) runController.abort();
|
|
810
963
|
});
|
|
811
964
|
}
|
|
812
965
|
let tornDown = false;
|
|
@@ -957,6 +1110,7 @@ const rootBuilder = new IlhaBuilder({
|
|
|
957
1110
|
ons: [],
|
|
958
1111
|
effects: [],
|
|
959
1112
|
onMounts: [],
|
|
1113
|
+
onErrors: [],
|
|
960
1114
|
transition: null,
|
|
961
1115
|
binds: [],
|
|
962
1116
|
css: null
|
|
@@ -966,7 +1120,10 @@ const ilha = Object.assign(rootBuilder, {
|
|
|
966
1120
|
raw: ilhaRaw,
|
|
967
1121
|
mount: mountAll,
|
|
968
1122
|
from: ilhaFrom,
|
|
969
|
-
context: ilhaContext
|
|
1123
|
+
context: ilhaContext,
|
|
1124
|
+
signal: ilhaSignal,
|
|
1125
|
+
batch,
|
|
1126
|
+
untrack
|
|
970
1127
|
});
|
|
971
1128
|
const html = ilhaHtml;
|
|
972
1129
|
const raw = ilhaRaw;
|
|
@@ -975,4 +1132,4 @@ const mount = mountAll;
|
|
|
975
1132
|
const from = ilhaFrom;
|
|
976
1133
|
const context = ilhaContext;
|
|
977
1134
|
//#endregion
|
|
978
|
-
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
|
}
|