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 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:debounce", ({ state, event }) => {
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 | Description |
150
- | --------- | ------------------------ |
151
- | `once` | Listener fires only once |
152
- | `capture` | Capture phase |
153
- | `passive` | `{ passive: true }` |
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
- .bind("input", myContextSignal)
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
- const result = entry.handler({
698
- state,
699
- derived,
700
- input,
701
- host,
702
- target: eventTarget,
703
- event
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
- userCleanup();
931
+ try {
932
+ userCleanup();
933
+ } catch (err) {
934
+ reportError(err, "effect");
935
+ }
800
936
  userCleanup = void 0;
801
937
  }
802
- userCleanup = entry.fn({
803
- state,
804
- input,
805
- host
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) 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.1",
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.3.6"
29
+ "zod": "^4.4.1"
30
30
  }
31
31
  }