ilha 0.6.0 → 0.7.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
@@ -121,11 +121,11 @@ ilha
121
121
  .render(({ derived }) => {
122
122
  if (derived.user.loading) return `<p>Loading…</p>`;
123
123
  if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
124
- return `<p>${derived.user.value.name}</p>`;
124
+ return `<p>${derived.user().name}</p>`;
125
125
  });
126
126
  ```
127
127
 
128
- Each derived value exposes `{ loading, value, error }`.
128
+ Read with `derived.name()` like state. Each accessor also exposes `loading`, `value`, and `error` for async work. Write `derived.name(value)` for optimistic UI.
129
129
 
130
130
  ---
131
131
 
@@ -239,12 +239,15 @@ The handler receives an `EffectContext`:
239
239
  ```ts
240
240
  {
241
241
  state: IslandState;
242
+ derived: IslandDerived;
242
243
  input: TInput;
243
244
  host: Element;
244
245
  signal: AbortSignal; // aborts when the effect re-runs OR the island unmounts
245
246
  }
246
247
  ```
247
248
 
249
+ Reading `derived.name()` subscribes the effect. Writing `derived.name(value)` does not — use writes for optimistic UI without pinning the effect to every derived update.
250
+
248
251
  **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
252
 
250
253
  ```ts
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ interface IslandCall {
51
51
  interface MarkedSignalAccessor<T> {
52
52
  (): T;
53
53
  (...args: [value: T]): void;
54
+ select<S>(selector: (state: T) => S): MarkedSignalAccessor<S>;
54
55
  [SIGNAL_ACCESSOR]: true;
55
56
  }
56
57
  declare function ilhaRaw(value: string): RawHtml;
@@ -58,11 +59,11 @@ declare function ilhaCss(strings: TemplateStringsArray | string, ...values: (str
58
59
  type JsxChild = unknown;
59
60
  type JsxProps = Record<string, unknown> | null | undefined;
60
61
  type JsxType = string | ((props: Record<string, unknown>) => unknown);
61
- declare function ilhaJsx(type: JsxType, props: JsxProps, ...children: JsxChild[]): RawHtml;
62
+ declare function ilhaJsx(type: JsxType, props: JsxProps, maybeKey?: JsxChild | string | number, ...restChildren: JsxChild[]): RawHtml;
62
63
  declare function ilhaFragment(props: {
63
64
  children?: JsxChild;
64
65
  } | null, ...children: JsxChild[]): RawHtml;
65
- declare function ilhaJsxDEV(type: JsxType, props: JsxProps): RawHtml;
66
+ declare function ilhaJsxDEV(type: JsxType, props: JsxProps, maybeKey?: string | number, _source?: unknown, _self?: unknown): RawHtml;
66
67
  declare function ilhaHtml(strings: TemplateStringsArray, ...values: unknown[]): RawHtml;
67
68
  type ContextSignal<T> = {
68
69
  (): T;
@@ -80,7 +81,7 @@ declare function ilhaContext<T>(key: string, initial: T): ContextSignal<T>;
80
81
  * or `.render()` automatically subscribes the surrounding reactive scope —
81
82
  * so when the signal changes, dependents re-run as if it were local state.
82
83
  */
83
- declare function ilhaSignal<T>(initial: T): ExternalSignal<T>;
84
+ declare function ilhaSignal<T>(initial: T): SignalAccessor<T>;
84
85
  /**
85
86
  * Run `fn` with reactive tracking suspended. Reading signals inside `fn`
86
87
  * returns their current value without subscribing the surrounding scope.
@@ -104,6 +105,13 @@ interface DerivedValue<T> {
104
105
  value: T | undefined;
105
106
  error: Error | undefined;
106
107
  }
108
+ type DerivedAccessor<T> = {
109
+ readonly loading: boolean;
110
+ readonly value: T | undefined;
111
+ readonly error: Error | undefined;
112
+ (): T | undefined;
113
+ (value: T): void;
114
+ };
107
115
  type DerivedFnContext<TInput, TStateMap extends Record<string, unknown>> = {
108
116
  state: IslandState<TStateMap>;
109
117
  input: TInput;
@@ -114,11 +122,8 @@ interface DerivedEntry<TInput, TStateMap extends Record<string, unknown>> {
114
122
  key: string;
115
123
  fn: DerivedFn<TInput, TStateMap, unknown>;
116
124
  }
117
- type IslandDerived<TDerivedMap extends Record<string, unknown>> = { readonly [K in keyof TDerivedMap]: DerivedValue<TDerivedMap[K]> };
118
- interface ExternalSignal<T = unknown> {
119
- (): T;
120
- (value: T): void;
121
- }
125
+ type IslandDerived<TDerivedMap extends Record<string, unknown>> = { readonly [K in keyof TDerivedMap]: DerivedAccessor<TDerivedMap[K]> };
126
+ type ExternalSignal<T = unknown> = SignalAccessor<T>;
122
127
  type SignalAccessor<T> = MarkedSignalAccessor<T>;
123
128
  type MergeState<TStateMap extends Record<string, unknown>, K extends string, V> = Omit<TStateMap, K> & Record<K, V>;
124
129
  type IslandState<TStateMap extends Record<string, unknown>> = { readonly [K in keyof TStateMap]-?: SignalAccessor<TStateMap[K]> };
@@ -149,8 +154,9 @@ type RenderContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMa
149
154
  derived: IslandDerived<TDerivedMap>;
150
155
  input: TInput;
151
156
  };
152
- type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
157
+ type EffectContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> = {
153
158
  state: IslandState<TStateMap>;
159
+ derived: IslandDerived<TDerivedMap>;
154
160
  input: TInput;
155
161
  host: Element;
156
162
  /**
@@ -213,8 +219,8 @@ interface OnEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap
213
219
  abortable: boolean;
214
220
  handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>;
215
221
  }
216
- interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
217
- fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void;
222
+ interface EffectEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
223
+ fn: (ctx: EffectContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void;
218
224
  }
219
225
  /** Where the error originated. `"on"` covers sync throws and async rejections
220
226
  * from `.on()` handlers; `"effect"` covers sync throws from `.effect()` runs
@@ -243,14 +249,14 @@ interface MountOptions {
243
249
  lazy?: boolean;
244
250
  }
245
251
  interface MountResult {
246
- unmount: () => void;
252
+ unmount: () => void | Promise<void>;
247
253
  }
248
254
  interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown>> {
249
255
  schema: StandardSchemaV1 | null;
250
256
  states: StateEntry<TInput>[];
251
257
  deriveds: DerivedEntry<TInput, TStateMap>[];
252
258
  ons: OnEntry<TInput, TStateMap, TDerivedMap>[];
253
- effects: EffectEntry<TInput, TStateMap>[];
259
+ effects: EffectEntry<TInput, TStateMap, TDerivedMap>[];
254
260
  onMounts: OnMountEntry<TInput, TStateMap, TDerivedMap>[];
255
261
  onErrors: OnErrorEntry<TInput, TStateMap, TDerivedMap>[];
256
262
  transition: TransitionOptions | null;
@@ -264,7 +270,7 @@ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap exte
264
270
  state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap>;
265
271
  derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>>;
266
272
  on<S extends string>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
267
- effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
273
+ effect(fn: (ctx: EffectContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
268
274
  onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
269
275
  onError(fn: (ctx: ErrorContext<TInput, TStateMap, TDerivedMap>) => void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
270
276
  transition(opts: TransitionOptions): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
@@ -299,4 +305,4 @@ declare const mount: typeof mountAll;
299
305
  declare const from: typeof ilhaFrom;
300
306
  declare const context: typeof ilhaContext;
301
307
  //#endregion
302
- export { DerivedValue, ErrorContext, ErrorSource, ExternalSignal, Fragment, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_INTERNAL, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, jsx, jsxDEV, jsxs, mount, raw, untrack };
308
+ export { DerivedAccessor, DerivedValue, EffectContext, ErrorContext, ErrorSource, ExternalSignal, Fragment, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_INTERNAL, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, jsx, jsxDEV, jsxs, mount, raw, untrack };
package/dist/index.js CHANGED
@@ -16,9 +16,76 @@ function shallowEqualInput(a, b) {
16
16
  }
17
17
  return true;
18
18
  }
19
+ function extractDirectChildSlotIdsInOrder(html) {
20
+ if (typeof document === "undefined") return [];
21
+ const tpl = document.createElement("template");
22
+ tpl.innerHTML = html;
23
+ const root = tpl.content;
24
+ const ids = [];
25
+ for (const el of root.querySelectorAll("[data-ilha-slot]")) {
26
+ if (!(el instanceof Element)) continue;
27
+ let parent = el.parentElement ?? el.parentNode;
28
+ let nested = false;
29
+ while (parent && parent !== root) {
30
+ if (parent instanceof Element && parent.hasAttribute("data-ilha-slot")) {
31
+ nested = true;
32
+ break;
33
+ }
34
+ parent = parent instanceof Element ? parent.parentElement ?? parent.parentNode : parent.parentNode;
35
+ }
36
+ if (!nested) ids.push(el.getAttribute("data-ilha-slot"));
37
+ }
38
+ return ids;
39
+ }
40
+ function extractSlotPropsAttr(html, slotId) {
41
+ const escaped = slotId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ const withProps = new RegExp(`<div\\s[^>]*data-ilha-slot="${escaped}"[^>]*data-ilha-props='([^']*)'`);
43
+ const m = html.match(withProps);
44
+ if (m) return m[1];
45
+ const withoutProps = new RegExp(`<div\\s[^>]*data-ilha-slot="${escaped}"`);
46
+ return html.match(withoutProps) ? "" : null;
47
+ }
48
+ function isStableInlineSlotMount(initialHtml, renderedHtml, slotIds) {
49
+ const initialOrder = extractDirectChildSlotIdsInOrder(initialHtml);
50
+ const renderedOrder = extractDirectChildSlotIdsInOrder(renderedHtml);
51
+ if (initialOrder.length !== renderedOrder.length || initialOrder.some((id, i) => id !== renderedOrder[i])) return false;
52
+ for (const id of slotIds) {
53
+ const initialProps = extractSlotPropsAttr(initialHtml, id);
54
+ const renderedProps = extractSlotPropsAttr(renderedHtml, id);
55
+ if (initialProps === null || renderedProps === null) return false;
56
+ if (initialProps !== renderedProps) return false;
57
+ }
58
+ return true;
59
+ }
60
+ function serializeSlotProps(props) {
61
+ return props === void 0 ? "" : JSON.stringify(props);
62
+ }
63
+ const MORPH_CONTROLLER_SLOT_SELECTOR = "[data-slot]";
64
+ const MORPH_CONTROLLER_ATTRS = new Set([
65
+ "data-checked",
66
+ "data-unchecked",
67
+ "data-indeterminate",
68
+ "aria-checked",
69
+ "data-open",
70
+ "data-closed",
71
+ "data-state",
72
+ "aria-expanded",
73
+ "aria-hidden",
74
+ "data-selected",
75
+ "data-panel-open"
76
+ ]);
77
+ function shouldPreserveMorphAttr(el, name) {
78
+ return el.matches(MORPH_CONTROLLER_SLOT_SELECTOR) && MORPH_CONTROLLER_ATTRS.has(name);
79
+ }
19
80
  function syncAttributes(from, to) {
20
- for (const { name, value } of to.attributes) if (from.getAttribute(name) !== value) from.setAttribute(name, value);
21
- for (const { name } of Array.from(from.attributes)) if (!to.hasAttribute(name)) from.removeAttribute(name);
81
+ for (const { name, value } of to.attributes) {
82
+ if (shouldPreserveMorphAttr(from, name)) continue;
83
+ if (from.getAttribute(name) !== value) from.setAttribute(name, value);
84
+ }
85
+ for (const { name } of Array.from(from.attributes)) {
86
+ if (shouldPreserveMorphAttr(from, name)) continue;
87
+ if (!to.hasAttribute(name)) from.removeAttribute(name);
88
+ }
22
89
  }
23
90
  function morphChildren(fromParent, toParent) {
24
91
  const fromNodes = Array.from(fromParent.childNodes);
@@ -88,14 +155,14 @@ function dedentString(str) {
88
155
  const indent = Math.min(...lines.filter((l) => l.trim() !== "").map((l) => l.match(/^(\s*)/)[1].length));
89
156
  return lines.map((l) => l.slice(indent)).join("\n");
90
157
  }
91
- const RAW = Symbol("ilha.raw");
92
- const SIGNAL_ACCESSOR = Symbol("ilha.signalAccessor");
93
- const ISLAND = Symbol("ilha.island");
94
- const ISLAND_CALL = Symbol("ilha.islandCall");
158
+ const RAW = Symbol.for("ilha.raw");
159
+ const SIGNAL_ACCESSOR = Symbol.for("ilha.signalAccessor");
160
+ const ISLAND = Symbol.for("ilha.island");
161
+ const ISLAND_CALL = Symbol.for("ilha.islandCall");
95
162
  /** @internal Internal hook used by a parent's mountSlots to mount a child island and
96
163
  * retain a handle to push updated props into it on subsequent parent
97
164
  * re-renders. Not part of the public surface. */
98
- const ISLAND_MOUNT_INTERNAL = Symbol("ilha.islandMountInternal");
165
+ const ISLAND_MOUNT_INTERNAL = Symbol.for("ilha.islandMountInternal");
99
166
  const SLOT_ATTR = "data-ilha-slot";
100
167
  const PROPS_ATTR = "data-ilha-props";
101
168
  const STATE_ATTR = "data-ilha-state";
@@ -122,10 +189,19 @@ function currentRenderCtx() {
122
189
  return renderCtxStack[renderCtxStack.length - 1];
123
190
  }
124
191
  function isIsland(v) {
125
- return typeof v === "function" && ISLAND in v;
192
+ if (typeof v !== "function") return false;
193
+ if (ISLAND in v) return true;
194
+ return Object.getOwnPropertySymbols(v).some((s) => s.description === "ilha.island");
126
195
  }
127
196
  function isIslandCall(v) {
128
- return (typeof v === "object" || typeof v === "function") && v !== null && ISLAND_CALL in v;
197
+ if (v == null || typeof v !== "object" && typeof v !== "function") return false;
198
+ if (ISLAND_CALL in v) return true;
199
+ if (Object.getOwnPropertySymbols(v).some((s) => s.description === "ilha.islandCall")) return true;
200
+ if (typeof v === "object" && "island" in v) {
201
+ const island = v.island;
202
+ return typeof island === "function" && (ISLAND in island || Object.getOwnPropertySymbols(island).some((s) => s.description === "ilha.island"));
203
+ }
204
+ return false;
129
205
  }
130
206
  function emitIslandSlot(island, props, key) {
131
207
  const ctx = currentRenderCtx();
@@ -171,10 +247,86 @@ async function resolveAsyncChildren(html, ctx) {
171
247
  }
172
248
  function markSignalAccessor(fn) {
173
249
  fn[SIGNAL_ACCESSOR] = true;
174
- return fn;
250
+ const accessor = fn;
251
+ accessor.select = (selector) => createSelectAccessor(accessor, selector);
252
+ return accessor;
175
253
  }
176
254
  function isSignalAccessor(v) {
177
- return typeof v === "function" && SIGNAL_ACCESSOR in v;
255
+ if (typeof v !== "function") return false;
256
+ if (SIGNAL_ACCESSOR in v) return true;
257
+ return Object.getOwnPropertySymbols(v).some((s) => s.description === "ilha.signalAccessor");
258
+ }
259
+ function getAtPath(obj, path) {
260
+ let cur = obj;
261
+ for (const seg of path) {
262
+ if (cur == null) return void 0;
263
+ cur = cur[seg];
264
+ }
265
+ return cur;
266
+ }
267
+ function setAtPath(obj, path, value) {
268
+ if (path.length === 0) return value;
269
+ const [head, ...rest] = path;
270
+ if (Array.isArray(obj)) {
271
+ const idx = head;
272
+ if (idx < 0 || idx >= obj.length) return obj;
273
+ const next = obj[idx];
274
+ const updated = rest.length === 0 ? value : setAtPath(next, rest, value);
275
+ if (Object.is(next, updated)) return obj;
276
+ const copy = obj.slice();
277
+ copy[idx] = updated;
278
+ return copy;
279
+ }
280
+ if (obj !== null && typeof obj === "object") {
281
+ const record = obj;
282
+ const key = String(head);
283
+ const next = record[key];
284
+ const updated = rest.length === 0 ? value : setAtPath(next, rest, value);
285
+ if (rest.length === 0) {
286
+ if (Object.is(next, value)) return obj;
287
+ } else if (Object.is(next, updated)) return obj;
288
+ return {
289
+ ...record,
290
+ [key]: updated
291
+ };
292
+ }
293
+ return rest.length === 0 ? value : setAtPath(void 0, rest, value);
294
+ }
295
+ function toPathSegment(prop) {
296
+ if (typeof prop === "symbol") return null;
297
+ if (prop === "length") return null;
298
+ if (/^\d+$/.test(prop)) return Number(prop);
299
+ return prop;
300
+ }
301
+ function trackSelectPath(rootState, selector) {
302
+ const path = [];
303
+ const track = (value) => {
304
+ if (value === null || typeof value !== "object" && typeof value !== "function") return value;
305
+ return new Proxy(value, { get(target, prop, receiver) {
306
+ const seg = toPathSegment(prop);
307
+ if (seg != null) path.push(seg);
308
+ const next = Reflect.get(target, prop, receiver);
309
+ return seg != null && next !== null && typeof next === "object" ? track(next) : next;
310
+ } });
311
+ };
312
+ selector(track(rootState));
313
+ return path;
314
+ }
315
+ function createSelectAccessor(root, selector) {
316
+ const path = trackSelectPath(root(), selector);
317
+ const selected = selector(root());
318
+ const resolved = path.length === 0 ? root() : getAtPath(root(), path);
319
+ if (!Object.is(selected, resolved)) {
320
+ const msg = "select(): selector must only traverse nested properties or array indexes — derived or transformed values are not supported.";
321
+ if (__DEV__) warn(msg);
322
+ throw new Error(msg);
323
+ }
324
+ if (__DEV__ && path.length === 0) warn("select(): selector did not traverse nested state — bind writes may replace the entire root value.");
325
+ return markSignalAccessor((...args) => {
326
+ if (args.length === 0) return path.length === 0 ? selector(root()) : getAtPath(root(), path);
327
+ const next = path.length === 0 ? args[0] : setAtPath(root(), path, args[0]);
328
+ if (!Object.is(root(), next)) root(next);
329
+ });
178
330
  }
179
331
  function ilhaRaw(value) {
180
332
  return {
@@ -229,6 +381,19 @@ function normalizeJsxChildren(props, children) {
229
381
  const propChildren = props && "children" in props ? props.children : void 0;
230
382
  return (children.length > 0 ? children : propChildren === void 0 ? [] : [propChildren]).flat(1);
231
383
  }
384
+ function normalizeJsxSlotKey(raw) {
385
+ const key = String(raw);
386
+ if (key.trim().length === 0) throw new Error("jsx key requires a non-empty string.");
387
+ if (key.includes(":")) throw new Error(`jsx key cannot contain the slot separator ":" (got "${key}").`);
388
+ return key;
389
+ }
390
+ /** React-style `key` (props or 3rd jsx arg) on island components → `k:{key}` slot ids. */
391
+ function extractJsxSlotKey(props, keyArg) {
392
+ const fromProps = props?.key;
393
+ const raw = keyArg ?? (typeof fromProps === "string" || typeof fromProps === "number" ? fromProps : void 0);
394
+ if (raw == null) return void 0;
395
+ return normalizeJsxSlotKey(raw);
396
+ }
232
397
  function serializeStyle(value) {
233
398
  return Object.entries(value).map(([k, v]) => {
234
399
  if (!SAFE_CSS_PROP_RE.test(k)) return "";
@@ -281,17 +446,25 @@ function renderJsxElement(type, props, children) {
281
446
  } else if (children.length > 0 && __DEV__) warn(`Void element <${type}> should not have children. They will be ignored.`);
282
447
  return ilhaHtml(chunks, ...values);
283
448
  }
284
- function ilhaJsx(type, props, ...children) {
285
- const normalizedChildren = normalizeJsxChildren(props, children);
449
+ function ilhaJsx(type, props, maybeKey, ...restChildren) {
450
+ const hasKeyArg = typeof maybeKey === "string" || typeof maybeKey === "number";
451
+ const keyFromArg = hasKeyArg ? maybeKey : void 0;
452
+ const normalizedChildren = normalizeJsxChildren(props, hasKeyArg || maybeKey === void 0 ? restChildren : [maybeKey, ...restChildren]);
286
453
  if (typeof type === "function") {
454
+ const slotKey = extractJsxSlotKey(props, typeof keyFromArg === "string" || typeof keyFromArg === "number" ? keyFromArg : void 0);
287
455
  const componentProps = {
288
456
  ...props ?? {},
289
457
  ...normalizedChildren.length > 0 ? { children: normalizedChildren } : {}
290
458
  };
291
459
  delete componentProps["key"];
292
460
  const out = type(Object.keys(componentProps).length ? componentProps : {});
461
+ if (isIslandCall(out)) {
462
+ if (slotKey !== void 0) out.key = slotKey;
463
+ return ilhaHtml`${out}`;
464
+ }
465
+ if (isRawHtml(out)) return out;
466
+ if (typeof out === "string" && isIsland(type)) return ilhaRaw(emitIslandSlot(type, componentProps, slotKey));
293
467
  if (typeof out === "string") return ilhaHtml`${out}`;
294
- if (out && typeof out === "object" && RAW in out) return out;
295
468
  return ilhaHtml`${out}`;
296
469
  }
297
470
  return renderJsxElement(type, props, normalizedChildren);
@@ -300,13 +473,13 @@ function ilhaFragment(props, ...children) {
300
473
  const normalizedChildren = normalizeJsxChildren(props, children);
301
474
  return ilhaHtml(["", ...normalizedChildren.map(() => "")], ...normalizedChildren);
302
475
  }
303
- function ilhaJsxDEV(type, props) {
304
- return ilhaJsx(type, props);
476
+ function ilhaJsxDEV(type, props, maybeKey, _source, _self) {
477
+ return ilhaJsx(type, props, maybeKey);
305
478
  }
306
479
  function interpolateValue(v) {
307
480
  if (v == null || v === true || v === false) return "";
308
481
  if (Array.isArray(v)) return v.map(interpolateValue).join("");
309
- if (typeof v === "object" && RAW in v) return v.value;
482
+ if (isRawHtml(v)) return v.value;
310
483
  if (isIslandCall(v)) {
311
484
  const call = typeof v === "function" ? v() : v;
312
485
  return emitIslandSlot(call.island, call.props, call.key);
@@ -432,8 +605,15 @@ function ilhaHtml(strings, ...values) {
432
605
  value: dedentString(result)
433
606
  };
434
607
  }
608
+ /** Duck-type RawHtml — works across duplicate `ilha` bundles (distinct RAW symbols). */
609
+ function isRawHtml(v) {
610
+ if (typeof v !== "object" || v === null || !("value" in v)) return false;
611
+ if (typeof v.value !== "string") return false;
612
+ if (RAW in v) return true;
613
+ return Object.getOwnPropertySymbols(v).some((s) => s.description === "ilha.raw");
614
+ }
435
615
  function unwrapHtml(v) {
436
- return typeof v === "object" && RAW in v ? v.value : v;
616
+ return isRawHtml(v) ? v.value : v;
437
617
  }
438
618
  const contextRegistry = /* @__PURE__ */ new Map();
439
619
  function ilhaContext(key, initial) {
@@ -496,6 +676,38 @@ function batch(fn) {
496
676
  endBatch();
497
677
  }
498
678
  }
679
+ function createDerivedAccessor(read, write) {
680
+ const accessor = markSignalAccessor((...args) => {
681
+ if (args.length > 0) {
682
+ if (write) write(args[0]);
683
+ else if (__DEV__) warn("derived values are read-only");
684
+ return;
685
+ }
686
+ return read().value;
687
+ });
688
+ return new Proxy(accessor, { get(target, prop, receiver) {
689
+ if (prop === "loading" || prop === "value" || prop === "error") return read()[prop];
690
+ return Reflect.get(target, prop, receiver);
691
+ } });
692
+ }
693
+ function defaultDerivedAccessor() {
694
+ return createDerivedAccessor(() => ({
695
+ loading: false,
696
+ value: void 0,
697
+ error: void 0
698
+ }));
699
+ }
700
+ function buildDerivedAccessors(envelopes) {
701
+ const accessors = /* @__PURE__ */ new Map();
702
+ for (const [key, envelope] of Object.entries(envelopes)) accessors.set(key, createDerivedAccessor(() => envelope, (value) => {
703
+ envelope.loading = false;
704
+ envelope.value = value;
705
+ envelope.error = void 0;
706
+ }));
707
+ return new Proxy({}, { get(_, key) {
708
+ return accessors.get(key) ?? defaultDerivedAccessor();
709
+ } });
710
+ }
499
711
  function createDerivedProxy(entries, state, input, derivedSnapshot) {
500
712
  const envelopes = /* @__PURE__ */ new Map();
501
713
  for (const entry of entries) {
@@ -539,14 +751,21 @@ function createDerivedProxy(entries, state, input, derivedSnapshot) {
539
751
  const env = signal(initialEnvelope);
540
752
  envelopes.set(entry.key, env);
541
753
  }
754
+ const accessors = /* @__PURE__ */ new Map();
755
+ for (const entry of entries) {
756
+ const env = envelopes.get(entry.key);
757
+ accessors.set(entry.key, createDerivedAccessor(() => env(), (value) => {
758
+ const prevSub = setActiveSub(void 0);
759
+ env({
760
+ loading: false,
761
+ value,
762
+ error: void 0
763
+ });
764
+ setActiveSub(prevSub);
765
+ }));
766
+ }
542
767
  const proxy = new Proxy({}, { get(_, key) {
543
- const env = envelopes.get(key);
544
- if (!env) return {
545
- loading: false,
546
- value: void 0,
547
- error: void 0
548
- };
549
- return env();
768
+ return accessors.get(key) ?? defaultDerivedAccessor();
550
769
  } });
551
770
  const setup = () => {
552
771
  const stops = [];
@@ -1003,17 +1222,18 @@ var IlhaBuilder = class IlhaBuilder {
1003
1222
  }
1004
1223
  });
1005
1224
  if (!results.some((r) => r.result instanceof Promise) || sync) {
1006
- const derived = {};
1007
- for (const r of results) if (r.result instanceof Promise) derived[r.key] = {
1225
+ const envelopes = {};
1226
+ for (const r of results) if (r.result instanceof Promise) envelopes[r.key] = {
1008
1227
  loading: true,
1009
1228
  value: void 0,
1010
1229
  error: void 0
1011
1230
  };
1012
- else derived[r.key] = {
1231
+ else envelopes[r.key] = {
1013
1232
  loading: false,
1014
1233
  value: r.result,
1015
1234
  error: void 0
1016
1235
  };
1236
+ const derived = buildDerivedAccessors(envelopes);
1017
1237
  const prevSub = setActiveSub(void 0);
1018
1238
  try {
1019
1239
  const { html } = renderWithCtx(() => fn({
@@ -1047,8 +1267,9 @@ var IlhaBuilder = class IlhaBuilder {
1047
1267
  };
1048
1268
  }
1049
1269
  })).then(async (resolved) => {
1050
- const derived = {};
1051
- for (const r of resolved) derived[r.key] = r.envelope;
1270
+ const envelopes = {};
1271
+ for (const r of resolved) envelopes[r.key] = r.envelope;
1272
+ const derived = buildDerivedAccessors(envelopes);
1052
1273
  const prevSub = setActiveSub(void 0);
1053
1274
  try {
1054
1275
  const { html } = await renderWithCtx(() => fn({
@@ -1161,10 +1382,38 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1161
1382
  }
1162
1383
  }
1163
1384
  const mountedSlots = /* @__PURE__ */ new Map();
1385
+ const leavingSlots = /* @__PURE__ */ new Map();
1386
+ function teardownMountedSlot(id, entry) {
1387
+ mountedSlots.delete(id);
1388
+ const token = Symbol();
1389
+ const result = entry.unmount();
1390
+ const remove = (stub) => {
1391
+ const leaving = leavingSlots.get(id);
1392
+ if (!leaving || leaving.token !== token) return;
1393
+ leaving.el.remove();
1394
+ stub?.remove();
1395
+ leavingSlots.delete(id);
1396
+ };
1397
+ if (result instanceof Promise) {
1398
+ let stub;
1399
+ if (entry.el.isConnected) {
1400
+ stub = document.createElement("div");
1401
+ stub.setAttribute(SLOT_ATTR, id);
1402
+ entry.el.replaceWith(stub);
1403
+ }
1404
+ leavingSlots.set(id, {
1405
+ el: entry.el,
1406
+ token
1407
+ });
1408
+ result.finally(() => remove(stub));
1409
+ } else entry.el.remove();
1410
+ }
1164
1411
  function findSlot(id) {
1412
+ const leavingEls = new Set([...leavingSlots.values()].map((l) => l.el));
1165
1413
  const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
1166
1414
  const candidates = host.querySelectorAll(`[${SLOT_ATTR}="${escaped}"]`);
1167
1415
  for (const candidate of candidates) {
1416
+ if (leavingEls.has(candidate)) continue;
1168
1417
  let el = candidate;
1169
1418
  while (el && el !== host) {
1170
1419
  if (el.hasAttribute("data-ilha")) break;
@@ -1175,10 +1424,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1175
1424
  return null;
1176
1425
  }
1177
1426
  function mountSlots(slotMap) {
1178
- for (const [id, entry] of mountedSlots) if (!slotMap.has(id)) {
1179
- entry.unmount();
1180
- mountedSlots.delete(id);
1181
- }
1427
+ for (const [id, entry] of mountedSlots) if (!slotMap.has(id)) teardownMountedSlot(id, entry);
1182
1428
  for (const [id, { island: childIsland, props }] of slotMap) {
1183
1429
  const existing = mountedSlots.get(id);
1184
1430
  if (existing) {
@@ -1229,6 +1475,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1229
1475
  listeners.splice(0, listeners.length, ...listeners.filter((l) => l.entry !== entry));
1230
1476
  }
1231
1477
  const eventTarget = event.target instanceof Element ? event.target : listenerTarget;
1478
+ const handlerTarget = entry.selector === "" ? eventTarget : listenerTarget;
1232
1479
  let handlerSignal;
1233
1480
  if (entry.abortable) {
1234
1481
  let entryMap = invocationControllers.get(entry);
@@ -1250,7 +1497,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1250
1497
  derived,
1251
1498
  input,
1252
1499
  host,
1253
- target: eventTarget,
1500
+ target: handlerTarget,
1254
1501
  event,
1255
1502
  signal: handlerSignal
1256
1503
  });
@@ -1291,11 +1538,11 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1291
1538
  input
1292
1539
  }), hasExistingContent ? host : void 0);
1293
1540
  if (!hasExistingContent) host.innerHTML = stylePrefix + initial.html;
1294
- attachListeners();
1295
1541
  let stopBindings = applyTemplateBindings(host, initial.binds);
1296
1542
  cleanups.push(() => stopBindings());
1297
1543
  mountSlots(initial.slots);
1298
1544
  cleanups.push(() => mountedSlots.forEach((entry) => entry.unmount()));
1545
+ attachListeners();
1299
1546
  if (!shouldSkipOnMount) for (const entry of onMounts) {
1300
1547
  const prevSub = setActiveSub(void 0);
1301
1548
  let userCleanup;
@@ -1331,6 +1578,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1331
1578
  try {
1332
1579
  userCleanup = entry.fn({
1333
1580
  state,
1581
+ derived,
1334
1582
  input,
1335
1583
  host,
1336
1584
  signal: runSignal
@@ -1365,19 +1613,34 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1365
1613
  if (!initialized) {
1366
1614
  initialized = true;
1367
1615
  if (rendered === initialRenderedHtml) return;
1616
+ if (mountedSlots.size > 0 && mountedSlots.size === newSlotMap.size && [...newSlotMap.keys()].every((id) => mountedSlots.has(id)) && isStableInlineSlotMount(initialRenderedHtml, rendered, newSlotMap.keys())) return;
1368
1617
  }
1369
1618
  detachListeners();
1370
1619
  stopBindings();
1620
+ const prevMountedCount = mountedSlots.size;
1621
+ const removedSlotIds = [];
1371
1622
  for (const [id, entry] of mountedSlots) if (!newSlotMap.has(id)) {
1372
- entry.unmount();
1373
- mountedSlots.delete(id);
1623
+ teardownMountedSlot(id, entry);
1624
+ removedSlotIds.push(id);
1625
+ }
1626
+ for (const id of removedSlotIds) {
1627
+ if (leavingSlots.has(id)) continue;
1628
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
1629
+ for (const el of host.querySelectorAll(`[${SLOT_ATTR}="${escaped}"]`)) el.remove();
1374
1630
  }
1375
1631
  const preserved = /* @__PURE__ */ new Map();
1632
+ const listShrunk = newSlotMap.size < prevMountedCount;
1376
1633
  for (const [id, entry] of mountedSlots) {
1377
1634
  if (!newSlotMap.has(id)) continue;
1378
1635
  if (!entry.el.isConnected) continue;
1636
+ if (listShrunk && id.startsWith("p:")) {
1637
+ teardownMountedSlot(id, entry);
1638
+ continue;
1639
+ }
1379
1640
  const stub = document.createElement("div");
1380
1641
  stub.setAttribute(SLOT_ATTR, id);
1642
+ const incomingProps = serializeSlotProps(newSlotMap.get(id)?.props);
1643
+ if (incomingProps !== "") stub.setAttribute(PROPS_ATTR, incomingProps);
1381
1644
  entry.el.replaceWith(stub);
1382
1645
  preserved.set(id, entry.el);
1383
1646
  }
@@ -1400,16 +1663,23 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1400
1663
  if (__DEV__ && _mountedHosts) _mountedHosts.delete(host);
1401
1664
  stopRender();
1402
1665
  detachListeners();
1666
+ const pending = [];
1667
+ for (const [, entry] of mountedSlots) {
1668
+ const result = entry.unmount();
1669
+ if (result instanceof Promise) pending.push(result);
1670
+ }
1403
1671
  if (transition?.leave) {
1404
1672
  const result = transition.leave(host);
1405
- if (result instanceof Promise) {
1406
- result.then(() => {
1407
- for (const c of cleanups) c();
1408
- }).catch(console.error);
1409
- return;
1410
- }
1673
+ if (result instanceof Promise) pending.push(result);
1411
1674
  }
1412
- for (const c of cleanups) c();
1675
+ const finish = () => {
1676
+ for (const c of cleanups) c();
1677
+ };
1678
+ if (pending.length > 0) return Promise.all(pending).then(finish).catch((err) => {
1679
+ console.error(err);
1680
+ finish();
1681
+ });
1682
+ finish();
1413
1683
  };
1414
1684
  const updateProps = (nextProps) => {
1415
1685
  if (tornDown) return;
@@ -1569,7 +1839,14 @@ function mountAll(registry, options = {}) {
1569
1839
  io.disconnect();
1570
1840
  });
1571
1841
  } else els.forEach(activateEl);
1572
- return { unmount: () => unmounts.forEach((u) => u()) };
1842
+ return { unmount: () => {
1843
+ const pending = [];
1844
+ for (const u of unmounts) {
1845
+ const result = u();
1846
+ if (result instanceof Promise) pending.push(result);
1847
+ }
1848
+ if (pending.length > 0) return Promise.all(pending).then(() => {});
1849
+ } };
1573
1850
  }
1574
1851
  const rootBuilder = new IlhaBuilder({
1575
1852
  schema: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",