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 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 = [];
@@ -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
- const result = entry.handler({
697
- state,
698
- derived,
699
- input,
700
- host,
701
- target: eventTarget,
702
- 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");
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
- userCleanup();
931
+ try {
932
+ userCleanup();
933
+ } catch (err) {
934
+ reportError(err, "effect");
935
+ }
799
936
  userCleanup = void 0;
800
937
  }
801
- userCleanup = entry.fn({
802
- state,
803
- input,
804
- host
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) 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.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.3.6"
29
+ "zod": "^4.4.1"
30
30
  }
31
31
  }