ilha 0.10.3 → 0.11.1

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
@@ -331,7 +331,7 @@ Both the user-supplied cleanup function (if any) and the signal abort fire when
331
331
 
332
332
  ### `.onMount(fn)`
333
333
 
334
- Runs once after the island is mounted into the DOM. Receives `{ state, derived, input, host, hydrated }` where `hydrated` is `true` when the island was mounted over existing SSR content. Optionally returns a cleanup function called on unmount.
334
+ Runs once after the island is mounted into the DOM. **Client-only** — SSR never invokes it (matching `.on()` and `.effect()`), so server-rendered markup must not depend on onMount side effects. Receives `{ state, derived, input, host, hydrated }` where `hydrated` is `true` when the island was mounted over existing SSR content. Optionally returns a cleanup function called on unmount.
335
335
 
336
336
  ```ts
337
337
  ilha
@@ -538,7 +538,8 @@ Use `await island(props)` instead when asynchronous derived values must settle b
538
538
  ```ts
539
539
  MyIsland.toString(); // always sync
540
540
  MyIsland.toString({ name: "Ilha" }); // with props
541
- await MyIsland({ name: "Ilha" }); // async — awaits derived
541
+ await MyIsland.toStringAsync({ name: "Ilha" }); // async SSR — awaits async derived values
542
+ await MyIsland({ name: "Ilha" }); // callable async form (also used for JSX/html slot composition)
542
543
  ```
543
544
 
544
545
  ---
@@ -666,6 +667,8 @@ theme("dark"); // → sets to "dark"
666
667
  Safe to call in both SSR and browser environments.
667
668
 
668
669
  > **`signal()` vs `context()`** — both return the same accessor shape and can be used with `bind:` template syntax. Use `signal()` for one-off shared state where you'd hold the reference yourself; use `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key.
670
+ >
671
+ > **SSR caveat** — `context()` is process-global, not request-scoped. It is safe to read in SSR **only for app-wide singletons** (theme, locale, feature flags). Per-request state (current user, session) must not live in `context()`: concurrent SSR requests would leak it across requests. Pass per-request data through island props / loaders or use `@ilha/router`'s request-scope instead.
669
672
 
670
673
  ---
671
674
 
@@ -178,6 +178,12 @@ interface HydratableOptions {
178
178
  interface Island<TInput = Record<string, unknown>, _TStateMap extends Record<string, unknown> = Record<string, unknown>> {
179
179
  (props?: Partial<TInput>): string | Promise<string>;
180
180
  toString(props?: Partial<TInput>): string;
181
+ /**
182
+ * Async SSR: renders the island and awaits async `.derived()` values before
183
+ * returning the HTML string. Always returns a Promise — prefer this over
184
+ * `await island(props)` when you need the async SSR form by name.
185
+ */
186
+ toStringAsync(props?: Partial<TInput>): Promise<string>;
181
187
  mount(host: Element, props?: Partial<TInput>): () => void;
182
188
  hydratable(props: Partial<TInput>, options: HydratableOptions): Promise<string>;
183
189
  key(key: string): KeyedIsland<TInput>;
@@ -285,6 +291,20 @@ interface StateEntry<TInput> {
285
291
  key: string;
286
292
  init: StateInit<TInput, unknown>;
287
293
  }
294
+ /** A state key fed by a generator (`.stream()`). The runtime iterates the
295
+ * generator and writes each yielded value into the state signal. During SSR
296
+ * only the first value is pulled so it can render inline; on the client the
297
+ * generator is consumed continuously. */
298
+ interface StreamEntry<TInput> {
299
+ key: string;
300
+ fn: StreamFn<TInput>;
301
+ }
302
+ type StreamFnContext<TInput> = {
303
+ input: TInput;
304
+ /** Aborts when the island unmounts. Pass through to fetch/SSE layers. */
305
+ signal: AbortSignal;
306
+ };
307
+ type StreamFn<TInput, V = unknown> = (ctx: StreamFnContext<TInput>) => AsyncGenerator<V> | Generator<V>;
288
308
  interface ActionEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
289
309
  key: string;
290
310
  fn: (props: unknown, ctx: ActionContext<TInput, TStateMap, TDerivedMap>) => unknown;
@@ -302,7 +322,7 @@ interface EffectEntry<TInput, TStateMap extends Record<string, unknown>, TDerive
302
322
  /** Where the error originated. `"on"` covers sync throws and async rejections
303
323
  * from `.on()` handlers; `"effect"` covers sync throws from `.effect()` runs
304
324
  * (async work spawned inside an effect is not awaited by the runtime). */
305
- type ErrorSource = "on" | "effect" | "mount" | "transition" | "action";
325
+ type ErrorSource = "on" | "effect" | "mount" | "transition" | "action" | "stream";
306
326
  /**
307
327
  * Register a global error handler invoked when any island reports an error
308
328
  * (from .on, .effect, .onMount, or transitions) and has no local .onError()
@@ -341,6 +361,7 @@ interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDeri
341
361
  /** Shallow defaults merged before props (POJO `.input({ ... })` only). */
342
362
  defaultInput: Record<string, unknown> | null;
343
363
  states: StateEntry<TInput>[];
364
+ streams: StreamEntry<TInput>[];
344
365
  deriveds: DerivedEntry<TInput, TStateMap>[];
345
366
  actions: ActionEntry<TInput, TStateMap, TDerivedMap>[];
346
367
  ons: OnEntry<TInput, TStateMap, TDerivedMap, TActionMap>[];
@@ -360,6 +381,7 @@ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap exte
360
381
  input<T extends Record<string, unknown>>(defaults: T): IlhaBuilder<T, Record<never, never>, Record<never, never>, Record<never, never>>;
361
382
  as<Tag extends string>(tag: Tag): IlhaBuilder<TInput, TStateMap, TDerivedMap, TActionMap>;
362
383
  state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap, TActionMap>;
384
+ stream<K extends string, V>(key: K, fn: StreamFn<TInput, V>): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap, TActionMap>;
363
385
  derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>, TActionMap>;
364
386
  action<K extends string, P = undefined, R = void>(key: K, fn: (props: P, ctx: ActionContext<TInput, TStateMap, TDerivedMap>) => R): IlhaBuilder<TInput, TStateMap, TDerivedMap, TActionMap & Record<K, typeof fn>>;
365
387
  on<S extends string>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap, TActionMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap, TActionMap> : HandlerContext<TInput, TStateMap, TDerivedMap, TActionMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap, TActionMap>;
@@ -392,6 +414,7 @@ declare const ilha: RootBuilder & DirectIslandFactory & {
392
414
  };
393
415
  signal: typeof ilhaSignal;
394
416
  computed: typeof ilhaComputed;
417
+ morph: typeof morph;
395
418
  batch: typeof batch;
396
419
  untrack: typeof untrack;
397
420
  onUncaughtError: typeof onUncaughtError;
@@ -408,6 +431,12 @@ declare function __ilhaJsxSlot({ island, props, key }: {
408
431
  props: Record<string, unknown> | undefined;
409
432
  key: string | undefined;
410
433
  }): RawHtml;
434
+ /**
435
+ * Morph an element's children toward `html`, patching in place: surviving
436
+ * elements keep their identity, listeners, and focus (see the morph engine).
437
+ * Falls back to an innerHTML write when the root tags don't match.
438
+ */
439
+ declare function morph(host: Element, html: string): void;
411
440
  declare const html: typeof ilhaHtml;
412
441
  declare const raw: typeof ilhaRaw;
413
442
  declare const css: typeof ilhaCss;
@@ -420,4 +449,4 @@ declare const context: typeof ilhaContextFn & {
420
449
  clear(): void;
421
450
  };
422
451
  //#endregion
423
- export { __ilhaJsxSlot as A, mount as B, NativeEventModifier as C, SignalSetter as D, SignalAccessor as E, html as F, raw as H, ilha as I, ilhaComputed as L, context as M, css as N, SignalWriter as O, from as P, ilhaEffect as R, NativeEventHandler as S, RawHtml as T, untrack as U, onUncaughtError as V, IslandState as _, EffectContext as a, MountResult as b, ExternalSignal as c, HydratableOptions as d, ISLAND_MOUNT_HANDLES as f, IslandDerived as g, IslandActions as h, DerivedValue as i, batch as j, __ilhaJsxEvent as k, HandlerContext as l, Island as m, ActionContext as n, ErrorContext as o, ISLAND_MOUNT_INTERNAL as p, DerivedAccessor as r, ErrorSource as s, ActionAccessor as t, HandlerContextFor as u, KeyedIsland as v, OnMountContext as w, NativeEventContext as x, MountOptions as y, ilhaSignal as z };
452
+ export { StreamFnContext as A, ilhaEffect as B, NativeEventModifier as C, SignalSetter as D, SignalAccessor as E, css as F, raw as G, morph as H, from as I, untrack as K, html as L, __ilhaJsxSlot as M, batch as N, SignalWriter as O, context as P, ilha as R, NativeEventHandler as S, RawHtml as T, mount as U, ilhaSignal as V, onUncaughtError as W, IslandState as _, EffectContext as a, MountResult as b, ExternalSignal as c, HydratableOptions as d, ISLAND_MOUNT_HANDLES as f, IslandDerived as g, IslandActions as h, DerivedValue as i, __ilhaJsxEvent as j, StreamFn as k, HandlerContext as l, Island as m, ActionContext as n, ErrorContext as o, ISLAND_MOUNT_INTERNAL as p, DerivedAccessor as r, ErrorSource as s, ActionAccessor as t, HandlerContextFor as u, KeyedIsland as v, OnMountContext as w, NativeEventContext as x, MountOptions as y, ilhaComputed as z };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as __ilhaJsxSlot, B as mount, C as NativeEventModifier, D as SignalSetter, E as SignalAccessor, F as html, H as raw, I as ilha, L as ilhaComputed, M as context, N as css, O as SignalWriter, P as from, R as ilhaEffect, S as NativeEventHandler, T as RawHtml, U as untrack, V as onUncaughtError, _ as IslandState, a as EffectContext, b as MountResult, c as ExternalSignal, d as HydratableOptions, f as ISLAND_MOUNT_HANDLES, g as IslandDerived, h as IslandActions, i as DerivedValue, j as batch, k as __ilhaJsxEvent, l as HandlerContext, m as Island, n as ActionContext, o as ErrorContext, p as ISLAND_MOUNT_INTERNAL, r as DerivedAccessor, s as ErrorSource, t as ActionAccessor, u as HandlerContextFor, v as KeyedIsland, w as OnMountContext, x as NativeEventContext, y as MountOptions, z as ilhaSignal } from "./index-BDM797j2.js";
2
- export { ActionAccessor, ActionContext, DerivedAccessor, DerivedValue, EffectContext, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, Island, IslandActions, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, NativeEventContext, NativeEventHandler, NativeEventModifier, OnMountContext, RawHtml, SignalAccessor, SignalSetter, SignalWriter, __ilhaJsxEvent, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilha as default, ilhaEffect as effect, from, html, ilhaSignal, ilhaSignal as signal, mount, onUncaughtError, raw, untrack };
1
+ import { A as StreamFnContext, B as ilhaEffect, C as NativeEventModifier, D as SignalSetter, E as SignalAccessor, F as css, G as raw, H as morph, I as from, K as untrack, L as html, M as __ilhaJsxSlot, N as batch, O as SignalWriter, P as context, R as ilha, S as NativeEventHandler, T as RawHtml, U as mount, V as ilhaSignal, W as onUncaughtError, _ as IslandState, a as EffectContext, b as MountResult, c as ExternalSignal, d as HydratableOptions, f as ISLAND_MOUNT_HANDLES, g as IslandDerived, h as IslandActions, i as DerivedValue, j as __ilhaJsxEvent, k as StreamFn, l as HandlerContext, m as Island, n as ActionContext, o as ErrorContext, p as ISLAND_MOUNT_INTERNAL, r as DerivedAccessor, s as ErrorSource, t as ActionAccessor, u as HandlerContextFor, v as KeyedIsland, w as OnMountContext, x as NativeEventContext, y as MountOptions, z as ilhaComputed } from "./index-DnoM0M8S.js";
2
+ export { ActionAccessor, ActionContext, DerivedAccessor, DerivedValue, EffectContext, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, Island, IslandActions, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, NativeEventContext, NativeEventHandler, NativeEventModifier, OnMountContext, RawHtml, SignalAccessor, SignalSetter, SignalWriter, StreamFn, StreamFnContext, __ilhaJsxEvent, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilhaEffect as effect, from, html, ilha, ilhaSignal, ilhaSignal as signal, morph, mount, onUncaughtError, raw, untrack };
package/dist/index.js CHANGED
@@ -127,13 +127,21 @@ function isStableInlineSlotMount({ initialHtml, renderedHtml, slotIds }) {
127
127
  * map (client) and/or the inlined SSR subtree — never depend on the attr for them.
128
128
  * RawHtml is tagged so a rare attr-only mount can revive `Symbol.for("ilha.raw")`.
129
129
  */
130
- function slotPropsForAttr(props) {
130
+ function slotPropsForAttr(props, captureActions = false) {
131
131
  if (props === void 0) return void 0;
132
132
  let out;
133
133
  for (const key of Object.keys(props)) {
134
134
  if (key === "children") continue;
135
135
  const value = props[key];
136
- if (typeof value === "function" || typeof value === "symbol") continue;
136
+ if (typeof value === "function") {
137
+ const action = captureActions ? captureHandlerActions(value) : void 0;
138
+ if (action) (out ??= {})[key] = {
139
+ __ilha: "action",
140
+ ...action
141
+ };
142
+ continue;
143
+ }
144
+ if (typeof value === "symbol") continue;
137
145
  const encoded = encodeSlotPropValue(value);
138
146
  if (encoded === void 0 && value !== void 0 && value !== null) continue;
139
147
  (out ??= {})[key] = encoded;
@@ -471,11 +479,25 @@ const ASTRO_RENDERER_GLOBAL = Symbol.for("ilha.astroRenderer");
471
479
  * re-renders. Not part of the public surface. */
472
480
  const ISLAND_MOUNT_INTERNAL = Symbol.for("ilha.islandMountInternal");
473
481
  const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
482
+ const ISLAND_CLIENT_REF = Symbol.for("ilha.clientRef");
474
483
  /** @internal Live mount handles keyed by host element. Lets @ilha/router adopt
475
484
  * islands hydrated by `ilha.mount()` (whose handles it never saw) and push new
476
485
  * loader props into them in place instead of remounting. Entries are removed
477
486
  * on unmount. Not part of the public surface. */
478
487
  const ISLAND_MOUNT_HANDLES = /* @__PURE__ */ new WeakMap();
488
+ /** @internal Sync re-render of an island from a state snapshot. Attached by
489
+ * render(); used by server-frame endpoints to produce HTML for streamed
490
+ * updates without a live mount. Not part of the public surface. */
491
+ const ISLAND_RENDER_STATE = Symbol.for("ilha.renderState");
492
+ /** @internal Async SSR render that records the event→action manifest into the
493
+ * provided map. Used by emitIslandSlot so nested server islands ship their
494
+ * interactive surface alongside their inlined HTML. */
495
+ const ISLAND_SSR_MANIFEST = Symbol.for("ilha.ssrManifest");
496
+ function manifestTemplatePrefix(manifest) {
497
+ if (manifest.size === 0) return "";
498
+ const json = escapeHtml(JSON.stringify(Object.fromEntries(manifest)));
499
+ return `<template ${ACTIONS_ATTR}='${json}'></template>`;
500
+ }
479
501
  const SLOT_ATTR = "data-ilha-slot";
480
502
  /** Marks a slot host whose child island is mid leave-transition: it has been
481
503
  * unmounted (and its slot id stripped) but stays connected so the leave
@@ -483,25 +505,63 @@ const SLOT_ATTR = "data-ilha-slot";
483
505
  * or mutating them. */
484
506
  const LEAVING_ATTR = "data-ilha-leaving";
485
507
  const PROPS_ATTR = "data-ilha-props";
508
+ const CLIENT_REF_ATTR = "data-ilha-client-ref";
486
509
  const STATE_ATTR = "data-ilha-state";
487
510
  const CSS_ATTR = "data-ilha-css";
511
+ /** Hydration manifest mapping `${type}:${index}` event-sentinel pairs to the
512
+ * action keys that own them. Emitted by hydratable(); lets server-owned
513
+ * islands (whose render fn never ships to the client) reconnect their
514
+ * interactive surface. */
515
+ const ACTIONS_ATTR = "data-ilha-actions";
488
516
  function buildScopedStyle(css) {
489
517
  const safeCss = css.replace(/<\/style/gi, "<\\/style");
490
518
  return `<style ${CSS_ATTR}>@scope (:scope) to ([data-ilha]){${safeCss}}</style>`;
491
519
  }
520
+ /** Marks SSR action stubs so event registration never capture-invokes them
521
+ * (invoking the real action body during render would run its side effects). */
522
+ const SSR_ACTION_STUB = Symbol.for("ilha.ssrActionStub");
523
+ const captureStack = [];
524
+ /**
525
+ * Invoke a forwarding closure once against sentinel arguments so any
526
+ * `action.x(...)` call inside it is RECORDED, not executed. Returns the first
527
+ * recorded call, or undefined when the closure throws or calls no action.
528
+ *
529
+ * ponytail: the closure body really executes once per manifest render (SSR
530
+ * and every frame) — only the action call is intercepted. Handlers must be
531
+ * pure "call one action" thunks; side effects before/around the action call
532
+ * run server-side on every frame. Upgrade path: static-scan the closure
533
+ * instead of invoking it.
534
+ */
535
+ function captureHandlerActions(handler) {
536
+ const frame = [];
537
+ captureStack.push(frame);
538
+ try {
539
+ const result = handler({
540
+ preventDefault: () => {},
541
+ stopPropagation: () => {},
542
+ stopImmediatePropagation: () => {},
543
+ persist: () => {}
544
+ }, { signal: new AbortController().signal });
545
+ Promise.resolve(result).catch(() => {});
546
+ } catch {} finally {
547
+ captureStack.pop();
548
+ }
549
+ return frame[0];
550
+ }
492
551
  const RENDER_CTX_STACK = Symbol.for("ilha.renderCtxStack");
493
552
  function renderCtxStack() {
494
553
  const g = globalThis;
495
554
  return g[RENDER_CTX_STACK] ??= [];
496
555
  }
497
- function pushRenderCtx(liveHost, asyncChildren) {
556
+ function pushRenderCtx(liveHost, asyncChildren, manifest) {
498
557
  const ctx = {
499
558
  slots: /* @__PURE__ */ new Map(),
500
559
  positional: 0,
501
560
  liveHost,
502
561
  pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0,
503
562
  binds: [],
504
- events: []
563
+ events: [],
564
+ manifest
505
565
  };
506
566
  renderCtxStack().push(ctx);
507
567
  return ctx;
@@ -531,8 +591,9 @@ function emitIslandSlot({ island, props, key }) {
531
591
  props
532
592
  });
533
593
  const slotTag = getIslandSlotTag(island);
534
- const attrProps = slotPropsForAttr(props);
535
- const propsAttr = attrProps ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(attrProps))}'` : "";
594
+ const clientRef = island[ISLAND_CLIENT_REF];
595
+ const attrProps = slotPropsForAttr(props, typeof clientRef === "string" && ctx?.manifest === true);
596
+ const propsAttr = (attrProps ? ` ${PROPS_ATTR}='${escapeHtml(JSON.stringify(attrProps))}'` : "") + (typeof clientRef === "string" ? ` ${CLIENT_REF_ATTR}="${escapeHtml(clientRef)}"` : "");
536
597
  if (ctx?.liveHost) return wrapIslandSlotHtml({
537
598
  tag: slotTag,
538
599
  id,
@@ -542,9 +603,12 @@ function emitIslandSlot({ island, props, key }) {
542
603
  if (ctx?.pending) {
543
604
  popRenderCtx();
544
605
  try {
545
- const result = island(props);
606
+ const manifest = /* @__PURE__ */ new Map();
607
+ const ssrWithManifest = island[ISLAND_SSR_MANIFEST];
608
+ const result = ssrWithManifest ? ssrWithManifest(props, manifest) : island(props);
609
+ const prefix = () => manifestTemplatePrefix(manifest);
546
610
  if (result instanceof Promise) {
547
- ctx.pending.set(id, result.then(String));
611
+ ctx.pending.set(id, result.then(String).then((html) => prefix() + html));
548
612
  return wrapIslandSlotHtml({
549
613
  tag: slotTag,
550
614
  id,
@@ -556,7 +620,7 @@ function emitIslandSlot({ island, props, key }) {
556
620
  tag: slotTag,
557
621
  id,
558
622
  propsAttr,
559
- inner: String(result)
623
+ inner: prefix() + String(result)
560
624
  });
561
625
  } finally {
562
626
  renderCtxStack().push(ctx);
@@ -853,6 +917,8 @@ function ilhaHtml(strings, ...values) {
853
917
  const modifier = NATIVE_EVENT_MODIFIERS.has(rawModifier) ? rawModifier : void 0;
854
918
  const openQuote = eventMatch[3] ?? null;
855
919
  const cleanPrefix = chunk.slice(0, eventMatchStart).replace(/\s+$/, "");
920
+ const isActionStub = typeof value === "function" && SSR_ACTION_STUB in value;
921
+ const capture = ctx?.manifest && typeof value === "function" && !isActionStub ? captureHandlerActions(value) : void 0;
856
922
  result += cleanPrefix;
857
923
  if (ctx && typeof value === "function") {
858
924
  const index = ctx.events.length;
@@ -860,7 +926,8 @@ function ilhaHtml(strings, ...values) {
860
926
  ctx.events.push({
861
927
  type: eventName,
862
928
  handler: value,
863
- modifier
929
+ modifier,
930
+ capture
864
931
  });
865
932
  pendingEventSpecs += (pendingEventSpecs ? "," : "") + `${eventName}:${index}`;
866
933
  }
@@ -1623,12 +1690,15 @@ var IlhaBuilder = class IlhaBuilder {
1623
1690
  input(initialOrSchema) {
1624
1691
  let schema = null;
1625
1692
  let defaultInput = null;
1626
- if (initialOrSchema !== void 0) if (isStandardSchema(initialOrSchema)) schema = initialOrSchema;
1627
- else defaultInput = initialOrSchema;
1693
+ if (initialOrSchema !== void 0) {
1694
+ if (isStandardSchema(initialOrSchema)) schema = initialOrSchema;
1695
+ else defaultInput = initialOrSchema;
1696
+ }
1628
1697
  return new IlhaBuilder({
1629
1698
  schema,
1630
1699
  defaultInput,
1631
1700
  states: [],
1701
+ streams: [],
1632
1702
  deriveds: [],
1633
1703
  actions: [],
1634
1704
  ons: [],
@@ -1656,6 +1726,21 @@ var IlhaBuilder = class IlhaBuilder {
1656
1726
  }]
1657
1727
  });
1658
1728
  }
1729
+ stream(key, fn) {
1730
+ const cfg = this._cfg;
1731
+ const states = cfg.states.some((entry) => entry.key === key) ? cfg.states : [...cfg.states, {
1732
+ key,
1733
+ init: void 0
1734
+ }];
1735
+ return new IlhaBuilder({
1736
+ ...cfg,
1737
+ states,
1738
+ streams: [...cfg.streams.filter((entry) => entry.key !== key), {
1739
+ key,
1740
+ fn
1741
+ }]
1742
+ });
1743
+ }
1659
1744
  derived(key, fn) {
1660
1745
  const cfg = this._cfg;
1661
1746
  return new IlhaBuilder({
@@ -1729,7 +1814,7 @@ var IlhaBuilder = class IlhaBuilder {
1729
1814
  });
1730
1815
  }
1731
1816
  render(fn) {
1732
- const { schema, defaultInput, states, deriveds, actions, ons, effects, onMounts, onErrors, transition, css: cssSource, as: slotAs } = this._cfg;
1817
+ const { schema, defaultInput, states, streams, deriveds, actions, ons, effects, onMounts, onErrors, transition, css: cssSource, as: slotAs } = this._cfg;
1733
1818
  const stylePrefix = cssSource == null ? "" : buildScopedStyle(cssSource);
1734
1819
  const configuredSlotTag = slotAs ?? "div";
1735
1820
  function resolveInput(props) {
@@ -1740,8 +1825,8 @@ var IlhaBuilder = class IlhaBuilder {
1740
1825
  if (!schema) return merged;
1741
1826
  return validateSchema(schema, merged);
1742
1827
  }
1743
- function renderWithCtx({ render, liveHost, asyncChildren }) {
1744
- const ctx = pushRenderCtx(liveHost, asyncChildren);
1828
+ function renderWithCtx({ render, liveHost, asyncChildren, manifest }) {
1829
+ const ctx = pushRenderCtx(liveHost, asyncChildren, manifest);
1745
1830
  try {
1746
1831
  const html = unwrapHtml(render());
1747
1832
  const slots = ctx.slots;
@@ -1794,8 +1879,19 @@ var IlhaBuilder = class IlhaBuilder {
1794
1879
  }
1795
1880
  function buildSsrActions() {
1796
1881
  const out = {};
1882
+ const lookup = /* @__PURE__ */ new Map();
1797
1883
  for (const entry of actions) {
1798
- const invoke = () => {};
1884
+ const invoke = (...callArgs) => {
1885
+ const frame = captureStack[captureStack.length - 1];
1886
+ if (frame) {
1887
+ if (!frame.some((r) => r.k === entry.key)) frame.push({
1888
+ k: entry.key,
1889
+ a: callArgs
1890
+ });
1891
+ return;
1892
+ }
1893
+ };
1894
+ invoke[SSR_ACTION_STUB] = true;
1799
1895
  Object.defineProperties(invoke, {
1800
1896
  pending: {
1801
1897
  get: () => false,
@@ -1811,80 +1907,52 @@ var IlhaBuilder = class IlhaBuilder {
1811
1907
  }
1812
1908
  });
1813
1909
  out[entry.key] = invoke;
1910
+ lookup.set(invoke, entry.key);
1814
1911
  }
1815
- return out;
1816
- }
1817
- /** Detached host for SSR setup hooks; must not be `{}` (Areia/components call host.matches). */
1818
- function createSsrOnMountHost() {
1819
- if (typeof document !== "undefined") return document.createElement("div");
1820
- const stub = {
1821
- matches: () => false,
1822
- querySelector: () => null,
1823
- querySelectorAll: () => [],
1824
- setAttribute: () => {},
1825
- getAttribute: () => null,
1826
- removeAttribute: () => {},
1827
- appendChild: () => stub
1912
+ return {
1913
+ action: out,
1914
+ lookup
1828
1915
  };
1829
- return stub;
1830
1916
  }
1831
- /** Run .onMount() before top-level SSR only (seed module/external state from `input`). */
1832
- function runOnMountForRender({ input, state, derived, action }) {
1833
- if (onMounts.length === 0) return;
1834
- if (currentRenderCtx()) return;
1835
- const host = createSsrOnMountHost();
1836
- const controller = new AbortController();
1837
- const ssrCleanups = [];
1838
- for (const entry of onMounts) {
1839
- const prevSub = setActiveSub(void 0);
1840
- let userCleanup;
1841
- try {
1842
- userCleanup = entry.fn({
1843
- state,
1844
- derived,
1845
- action,
1846
- input,
1847
- host,
1848
- hydrated: false,
1849
- signal: controller.signal
1850
- });
1851
- } catch (err) {
1852
- const error = err instanceof Error ? err : new Error(String(err));
1853
- if (onErrors.length === 0) {
1854
- if (!reportToGlobal(error, "mount")) console.error(error);
1855
- } else for (const oe of onErrors) try {
1856
- oe.fn({
1857
- error,
1858
- source: "mount",
1859
- state,
1860
- derived,
1861
- action,
1917
+ function renderToString(props, sync = false, streamOut, eventsOut, stateOverride, forceAsyncChildren = false) {
1918
+ const input = resolveInput(props);
1919
+ const state = buildSignalState(input, stateOverride);
1920
+ const { action, lookup: actionLookup } = buildSsrActions();
1921
+ const recordEventsManifest = (events) => {
1922
+ if (!eventsOut || events.length === 0) return;
1923
+ events.forEach((record, index) => {
1924
+ if (record.capture) {
1925
+ eventsOut.set(`${record.type}:${index}`, record.capture);
1926
+ return;
1927
+ }
1928
+ const key = actionLookup.get(record.handler);
1929
+ if (key) eventsOut.set(`${record.type}:${index}`, key);
1930
+ });
1931
+ };
1932
+ const pullStreamInitials = async (streamOut) => {
1933
+ for (const entry of streams) {
1934
+ const controller = new AbortController();
1935
+ try {
1936
+ const gen = await entry.fn({
1862
1937
  input,
1863
- host
1938
+ signal: controller.signal
1864
1939
  });
1865
- } catch (handlerErr) {
1866
- console.error(handlerErr);
1867
- }
1868
- } finally {
1869
- setActiveSub(prevSub);
1870
- }
1871
- if (typeof userCleanup === "function") ssrCleanups.push(userCleanup);
1872
- }
1873
- controller.abort();
1874
- for (const teardown of ssrCleanups.reverse()) try {
1875
- teardown();
1876
- } catch (err) {
1877
- const error = err instanceof Error ? err : new Error(String(err));
1878
- if (onErrors.length === 0) {
1879
- if (!reportToGlobal(error, "mount")) console.error(error);
1940
+ const first = await gen.next();
1941
+ controller.abort();
1942
+ await gen.return?.(void 0);
1943
+ if (first.done) continue;
1944
+ if (streamOut) streamOut[entry.key] = first.value;
1945
+ const prevSub = setActiveSub(void 0);
1946
+ try {
1947
+ const setter = state[entry.key];
1948
+ setter?.(first.value);
1949
+ } finally {
1950
+ setActiveSub(prevSub);
1951
+ }
1952
+ } catch {}
1880
1953
  }
1881
- }
1882
- }
1883
- function renderToString(props, sync = false) {
1884
- const input = resolveInput(props);
1885
- const state = buildSignalState(input);
1886
- const action = buildSsrActions();
1887
- const results = deriveds.map((entry) => {
1954
+ };
1955
+ const computeDerivedResults = () => deriveds.map((entry) => {
1888
1956
  const prevSub = setActiveSub(void 0);
1889
1957
  try {
1890
1958
  return {
@@ -1904,84 +1972,83 @@ var IlhaBuilder = class IlhaBuilder {
1904
1972
  setActiveSub(prevSub);
1905
1973
  }
1906
1974
  });
1907
- if (!results.some((r) => r.result instanceof Promise) || sync) {
1908
- const envelopes = {};
1909
- for (const r of results) if (r.result instanceof Promise) envelopes[r.key] = {
1910
- loading: true,
1911
- value: void 0,
1912
- error: void 0
1913
- };
1914
- else envelopes[r.key] = {
1915
- loading: false,
1916
- value: r.result,
1917
- error: void 0
1918
- };
1919
- const derived = buildDerivedAccessors(envelopes);
1920
- if (!currentRenderCtx()) runOnMountForRender({
1921
- input,
1922
- state,
1923
- derived,
1924
- action
1925
- });
1926
- const prevSub = setActiveSub(void 0);
1927
- try {
1928
- const { html } = renderWithCtx({ render: () => fn({
1929
- state,
1930
- derived,
1931
- action,
1932
- input
1933
- }) });
1934
- return stylePrefix + html;
1935
- } finally {
1936
- setActiveSub(prevSub);
1937
- }
1938
- }
1939
- return Promise.all(results.map(async (r) => {
1940
- try {
1941
- return {
1942
- key: r.key,
1943
- envelope: {
1944
- loading: false,
1945
- value: await Promise.resolve(r.result),
1946
- error: void 0
1947
- }
1975
+ if (!sync && (streams.length > 0 || forceAsyncChildren)) return pullStreamInitials(streamOut).then(() => renderToStringTail());
1976
+ return renderToStringTail();
1977
+ function renderToStringTail() {
1978
+ const results = computeDerivedResults();
1979
+ if (!(forceAsyncChildren || results.some((r) => r.result instanceof Promise)) || sync) {
1980
+ const envelopes = {};
1981
+ for (const r of results) if (r.result instanceof Promise) envelopes[r.key] = {
1982
+ loading: true,
1983
+ value: void 0,
1984
+ error: void 0
1948
1985
  };
1949
- } catch (err) {
1950
- return {
1951
- key: r.key,
1952
- envelope: {
1953
- loading: false,
1954
- value: void 0,
1955
- error: err instanceof Error ? err : new Error(String(err))
1956
- }
1986
+ else envelopes[r.key] = {
1987
+ loading: false,
1988
+ value: r.result,
1989
+ error: void 0
1957
1990
  };
1991
+ const derived = buildDerivedAccessors(envelopes);
1992
+ const prevSub = setActiveSub(void 0);
1993
+ try {
1994
+ const { html, events } = renderWithCtx({
1995
+ render: () => fn({
1996
+ state,
1997
+ derived,
1998
+ action,
1999
+ input
2000
+ }),
2001
+ manifest: eventsOut !== void 0
2002
+ });
2003
+ recordEventsManifest(events);
2004
+ return stylePrefix + html;
2005
+ } finally {
2006
+ setActiveSub(prevSub);
2007
+ }
1958
2008
  }
1959
- })).then(async (resolved) => {
1960
- const envelopes = {};
1961
- for (const r of resolved) envelopes[r.key] = r.envelope;
1962
- const derived = buildDerivedAccessors(envelopes);
1963
- if (!currentRenderCtx()) runOnMountForRender({
1964
- input,
1965
- state,
1966
- derived,
1967
- action
2009
+ return Promise.all(results.map(async (r) => {
2010
+ try {
2011
+ return {
2012
+ key: r.key,
2013
+ envelope: {
2014
+ loading: false,
2015
+ value: await Promise.resolve(r.result),
2016
+ error: void 0
2017
+ }
2018
+ };
2019
+ } catch (err) {
2020
+ return {
2021
+ key: r.key,
2022
+ envelope: {
2023
+ loading: false,
2024
+ value: void 0,
2025
+ error: err instanceof Error ? err : new Error(String(err))
2026
+ }
2027
+ };
2028
+ }
2029
+ })).then(async (resolved) => {
2030
+ const envelopes = {};
2031
+ for (const r of resolved) envelopes[r.key] = r.envelope;
2032
+ const derived = buildDerivedAccessors(envelopes);
2033
+ const prevSub = setActiveSub(void 0);
2034
+ try {
2035
+ const { html, events } = await renderWithCtx({
2036
+ render: () => fn({
2037
+ state,
2038
+ derived,
2039
+ action,
2040
+ input
2041
+ }),
2042
+ asyncChildren: true,
2043
+ manifest: eventsOut !== void 0
2044
+ });
2045
+ recordEventsManifest(events);
2046
+ return stylePrefix + html;
2047
+ } finally {
2048
+ setActiveSub(prevSub);
2049
+ }
1968
2050
  });
1969
- const prevSub = setActiveSub(void 0);
1970
- try {
1971
- const { html } = await renderWithCtx({
1972
- render: () => fn({
1973
- state,
1974
- derived,
1975
- action,
1976
- input
1977
- }),
1978
- asyncChildren: true
1979
- });
1980
- return stylePrefix + html;
1981
- } finally {
1982
- setActiveSub(prevSub);
1983
- }
1984
- });
2051
+ }
1985
2052
  }
1986
2053
  function mountIsland(host, props) {
1987
2054
  return mountIslandInternal(host, props).unmount;
@@ -2025,7 +2092,7 @@ var IlhaBuilder = class IlhaBuilder {
2025
2092
  const parsed = safeParseSnapshot(rawState, STATE_ATTR);
2026
2093
  if (parsed !== void 0) snapshotRaw = parsed;
2027
2094
  }
2028
- const stateSnapshot = snapshotRaw ? Object.fromEntries(Object.entries(snapshotRaw).filter(([k]) => k !== "_derived" && k !== "_skipOnMount")) : void 0;
2095
+ const stateSnapshot = snapshotRaw ? Object.fromEntries(Object.entries(snapshotRaw).filter(([k]) => k !== "_derived" && k !== "_skipOnMount" && k !== "_streams")) : void 0;
2029
2096
  const derivedSnapshotRaw = snapshotRaw?._derived;
2030
2097
  let derivedSnapshot;
2031
2098
  if (derivedSnapshotRaw) {
@@ -2454,6 +2521,36 @@ var IlhaBuilder = class IlhaBuilder {
2454
2521
  }
2455
2522
  stopDerived = setupDerived();
2456
2523
  cleanups.push(stopDerived);
2524
+ for (const entry of streams) {
2525
+ let streamAborted = false;
2526
+ const streamController = new AbortController();
2527
+ const runStream = async () => {
2528
+ try {
2529
+ const gen = await entry.fn({
2530
+ input,
2531
+ signal: streamController.signal
2532
+ });
2533
+ while (!streamAborted && !streamController.signal.aborted) {
2534
+ const { done, value } = await gen.next();
2535
+ if (streamAborted || streamController.signal.aborted || done) break;
2536
+ const prevSub = setActiveSub(void 0);
2537
+ try {
2538
+ const setter = state[entry.key];
2539
+ setter?.(value);
2540
+ } finally {
2541
+ setActiveSub(prevSub);
2542
+ }
2543
+ }
2544
+ } catch (err) {
2545
+ if (!streamAborted && !streamController.signal.aborted && err?.name !== "AbortError") reportError(err, "stream");
2546
+ }
2547
+ };
2548
+ runStream();
2549
+ cleanups.push(() => {
2550
+ streamAborted = true;
2551
+ streamController.abort();
2552
+ });
2553
+ }
2457
2554
  let initialized = false;
2458
2555
  const initialRenderedHtml = initial.html;
2459
2556
  let lastRendered = null;
@@ -2630,9 +2727,16 @@ var IlhaBuilder = class IlhaBuilder {
2630
2727
  return renderToString(props);
2631
2728
  });
2632
2729
  island.toString = (props) => renderToString(props, true);
2730
+ island.toStringAsync = (props) => Promise.resolve(renderToString(props, false));
2633
2731
  island.mount = (host, props) => mountIsland(host, props);
2634
2732
  island[ISLAND_MOUNT_INTERNAL] = (host, props) => mountIslandInternal(host, props);
2635
2733
  island[ISLAND_SLOT_TAG] = configuredSlotTag;
2734
+ island[ISLAND_RENDER_STATE] = async (props) => {
2735
+ const manifest = /* @__PURE__ */ new Map();
2736
+ const html = await renderToString(props ?? void 0, false, void 0, manifest, void 0, true);
2737
+ return manifestTemplatePrefix(manifest) + html;
2738
+ };
2739
+ island[ISLAND_SSR_MANIFEST] = (props, eventsOut) => renderToString(props, false, void 0, eventsOut, void 0, true);
2636
2740
  island.key = (key) => {
2637
2741
  if (typeof key !== "string" || key.trim().length === 0) throw new Error("island.key() requires a non-empty string.");
2638
2742
  if (key.includes(":")) throw new Error(`island.key() key cannot contain the slot separator ":" (got "${key}").`);
@@ -2732,7 +2836,9 @@ var IlhaBuilder = class IlhaBuilder {
2732
2836
  const { name, as: rawTag = "div", snapshot = false, skipOnMount: explicitSkipOnMount } = opts;
2733
2837
  const tag = assertValidSlotTagName(rawTag);
2734
2838
  const resolvedProps = props ?? {};
2735
- const inner = await renderToString(resolvedProps);
2839
+ const streamValues = {};
2840
+ const eventsManifest = /* @__PURE__ */ new Map();
2841
+ const inner = await renderToString(resolvedProps, false, streamValues, eventsManifest, void 0, true);
2736
2842
  const encodedProps = escapeHtml(JSON.stringify(resolvedProps));
2737
2843
  let stateAttr = "";
2738
2844
  if (snapshot !== false) {
@@ -2742,7 +2848,9 @@ var IlhaBuilder = class IlhaBuilder {
2742
2848
  const snapshotData = {};
2743
2849
  const input = resolveInput(resolvedProps);
2744
2850
  const plainState = buildPlainState(input);
2745
- if (doState) for (const entry of states) snapshotData[entry.key] = plainState[entry.key]();
2851
+ const stateForDerived = { ...plainState };
2852
+ for (const [k, v] of Object.entries(streamValues)) stateForDerived[k] = () => v;
2853
+ if (doState) for (const entry of states) snapshotData[entry.key] = entry.key in streamValues ? streamValues[entry.key] : plainState[entry.key]();
2746
2854
  if (doDerived) {
2747
2855
  const derivedResults = {};
2748
2856
  for (const entry of deriveds) {
@@ -2752,7 +2860,7 @@ var IlhaBuilder = class IlhaBuilder {
2752
2860
  let threw = false;
2753
2861
  try {
2754
2862
  resultPromise = entry.fn({
2755
- state: plainState,
2863
+ state: stateForDerived,
2756
2864
  input,
2757
2865
  signal: new AbortController().signal
2758
2866
  });
@@ -2788,9 +2896,12 @@ var IlhaBuilder = class IlhaBuilder {
2788
2896
  snapshotData["_derived"] = derivedResults;
2789
2897
  }
2790
2898
  if (doSkipOnMount) snapshotData["_skipOnMount"] = true;
2899
+ if (streams.length > 0) snapshotData["_streams"] = Object.fromEntries(streams.map((entry) => [entry.key, true]));
2791
2900
  stateAttr = ` ${STATE_ATTR}='${escapeHtml(JSON.stringify(snapshotData))}'`;
2792
2901
  }
2793
- return `<${tag} data-ilha="${escapeHtml(name)}" ${PROPS_ATTR}='${encodedProps}'${stateAttr}>${inner}</${tag}>`;
2902
+ let actionsAttr = "";
2903
+ if (eventsManifest.size > 0) actionsAttr = ` ${ACTIONS_ATTR}='${escapeHtml(JSON.stringify(Object.fromEntries(eventsManifest)))}'`;
2904
+ return `<${tag} data-ilha="${escapeHtml(name)}" ${PROPS_ATTR}='${encodedProps}'${stateAttr}${actionsAttr}>${inner}</${tag}>`;
2794
2905
  };
2795
2906
  return island;
2796
2907
  }
@@ -2852,6 +2963,7 @@ const rootBuilder = new IlhaBuilder({
2852
2963
  schema: null,
2853
2964
  defaultInput: null,
2854
2965
  states: [],
2966
+ streams: [],
2855
2967
  deriveds: [],
2856
2968
  actions: [],
2857
2969
  ons: [],
@@ -2880,6 +2992,7 @@ const ilha = Object.assign(callableRoot, {
2880
2992
  context: ilhaContext,
2881
2993
  signal: ilhaSignal,
2882
2994
  computed: ilhaComputed,
2995
+ morph,
2883
2996
  batch,
2884
2997
  untrack,
2885
2998
  onUncaughtError
@@ -2888,11 +3001,14 @@ const ilha = Object.assign(callableRoot, {
2888
3001
  function __ilhaJsxEvent({ type, handler, modifier }) {
2889
3002
  const ctx = currentRenderCtx();
2890
3003
  if (!ctx) return void 0;
3004
+ const isActionStub = SSR_ACTION_STUB in handler;
3005
+ const capture = ctx.manifest && !isActionStub ? captureHandlerActions(handler) : void 0;
2891
3006
  const index = ctx.events.length;
2892
3007
  ctx.events.push({
2893
3008
  type,
2894
3009
  handler,
2895
- modifier
3010
+ modifier,
3011
+ capture
2896
3012
  });
2897
3013
  return index;
2898
3014
  }
@@ -2904,6 +3020,27 @@ function __ilhaJsxSlot({ island, props, key }) {
2904
3020
  key
2905
3021
  }));
2906
3022
  }
3023
+ /**
3024
+ * Morph an element's children toward `html`, patching in place: surviving
3025
+ * elements keep their identity, listeners, and focus (see the morph engine).
3026
+ * Falls back to an innerHTML write when the root tags don't match.
3027
+ */
3028
+ function morph(host, html) {
3029
+ if (typeof document === "undefined") return;
3030
+ const openTag = host.localName ?? "div";
3031
+ const tpl = document.createElement("template");
3032
+ tpl.innerHTML = `<${openTag}>${html}</${openTag}>`;
3033
+ const next = tpl.content.firstElementChild;
3034
+ if (!next || next.localName !== host.localName) {
3035
+ host.innerHTML = html;
3036
+ return;
3037
+ }
3038
+ try {
3039
+ morphInner(host, next);
3040
+ } catch {
3041
+ host.innerHTML = html;
3042
+ }
3043
+ }
2907
3044
  const html = ilhaHtml;
2908
3045
  const raw = ilhaRaw;
2909
3046
  const css = ilhaCss;
@@ -2912,4 +3049,4 @@ const from = ilhaFrom;
2912
3049
  const context = ilhaContext;
2913
3050
 
2914
3051
  //#endregion
2915
- export { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, __ilhaJsxEvent, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilha as default, ilhaEffect as effect, from, html, ilhaSignal, ilhaSignal as signal, mount, onUncaughtError, raw, untrack };
3052
+ export { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, __ilhaJsxEvent, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilhaEffect as effect, from, html, ilha, ilhaSignal, ilhaSignal as signal, morph, mount, onUncaughtError, raw, untrack };
@@ -1,2 +1,2 @@
1
- import { a as JSX, r as jsxDEV, t as Fragment } from "./jsx-runtime-C6R6IiOD.js";
1
+ import { a as JSX, r as jsxDEV, t as Fragment } from "./jsx-runtime-DMkplizR.js";
2
2
  export { Fragment, type JSX, jsxDEV };
@@ -1,4 +1,4 @@
1
- import { C as NativeEventModifier, E as SignalAccessor, O as SignalWriter, S as NativeEventHandler, T as RawHtml } from "./index-BDM797j2.js";
1
+ import { C as NativeEventModifier, E as SignalAccessor, O as SignalWriter, S as NativeEventHandler, T as RawHtml } from "./index-DnoM0M8S.js";
2
2
  //#region src/jsx-types.d.ts
3
3
  declare namespace JSX {
4
4
  export type Child = unknown;
@@ -1,2 +1,2 @@
1
- import { a as JSX, i as jsxs, n as jsx, r as jsxDEV, t as Fragment } from "./jsx-runtime-C6R6IiOD.js";
1
+ import { a as JSX, i as jsxs, n as jsx, r as jsxDEV, t as Fragment } from "./jsx-runtime-DMkplizR.js";
2
2
  export { Fragment, type JSX, jsx, jsxDEV, jsxs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "keywords": [
6
6
  "framework-free",