ilha 0.9.2 → 0.9.4

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.
@@ -38,6 +38,14 @@ declare const ISLAND_CALL: unique symbol;
38
38
  * retain a handle to push updated props into it on subsequent parent
39
39
  * re-renders. Not part of the public surface. */
40
40
  declare const ISLAND_MOUNT_INTERNAL: unique symbol;
41
+ /** @internal Live mount handles keyed by host element. Lets @ilha/router adopt
42
+ * islands hydrated by `ilha.mount()` (whose handles it never saw) and push new
43
+ * loader props into them in place instead of remounting. Entries are removed
44
+ * on unmount. Not part of the public surface. */
45
+ declare const ISLAND_MOUNT_HANDLES: WeakMap<Element, {
46
+ unmount: () => void | Promise<void>;
47
+ updateProps: (props?: Record<string, unknown>) => void;
48
+ }>;
41
49
  interface RawHtml {
42
50
  [RAW]: true;
43
51
  value: string;
@@ -350,4 +358,4 @@ declare const context: typeof ilhaContextFn & {
350
358
  clear(): void;
351
359
  };
352
360
  //#endregion
353
- export { mount as A, css as C, ilhaComputed as D, ilha as E, raw as M, untrack as N, ilhaEffect as O, context as S, html as T, OnMountContext as _, ErrorSource as a, __ilhaJsxSlot as b, HandlerContextFor as c, Island as d, IslandDerived as f, MountResult as g, MountOptions as h, ErrorContext as i, onUncaughtError as j, ilhaSignal as k, HydratableOptions as l, KeyedIsland as m, DerivedValue as n, ExternalSignal as o, IslandState as p, EffectContext as r, HandlerContext as s, DerivedAccessor as t, ISLAND_MOUNT_INTERNAL as u, RawHtml as v, from as w, batch as x, SignalAccessor as y };
361
+ export { ilhaSignal as A, context as C, ilha as D, html as E, onUncaughtError as M, raw as N, ilhaComputed as O, untrack as P, batch as S, from as T, MountResult as _, ErrorSource as a, SignalAccessor as b, HandlerContextFor as c, ISLAND_MOUNT_INTERNAL as d, Island as f, MountOptions as g, KeyedIsland as h, ErrorContext as i, mount as j, ilhaEffect as k, HydratableOptions as l, IslandState as m, DerivedValue as n, ExternalSignal as o, IslandDerived as p, EffectContext as r, HandlerContext as s, DerivedAccessor as t, ISLAND_MOUNT_HANDLES as u, OnMountContext as v, css as w, __ilhaJsxSlot as x, RawHtml as y };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as mount, C as css, D as ilhaComputed, E as ilha, M as raw, N as untrack, O as ilhaEffect, S as context, T as html, _ as OnMountContext, a as ErrorSource, b as __ilhaJsxSlot, c as HandlerContextFor, d as Island, f as IslandDerived, g as MountResult, h as MountOptions, i as ErrorContext, j as onUncaughtError, k as ilhaSignal, l as HydratableOptions, m as KeyedIsland, n as DerivedValue, o as ExternalSignal, p as IslandState, r as EffectContext, s as HandlerContext, t as DerivedAccessor, u as ISLAND_MOUNT_INTERNAL, v as RawHtml, w as from, x as batch, y as SignalAccessor } from "./index-C0SWn9oq.js";
2
- export { DerivedAccessor, DerivedValue, EffectContext, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_INTERNAL, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, __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 ilhaSignal, C as context, D as ilha, E as html, M as onUncaughtError, N as raw, O as ilhaComputed, P as untrack, S as batch, T as from, _ as MountResult, a as ErrorSource, b as SignalAccessor, c as HandlerContextFor, d as ISLAND_MOUNT_INTERNAL, f as Island, g as MountOptions, h as KeyedIsland, i as ErrorContext, j as mount, k as ilhaEffect, l as HydratableOptions, m as IslandState, n as DerivedValue, o as ExternalSignal, p as IslandDerived, r as EffectContext, s as HandlerContext, t as DerivedAccessor, u as ISLAND_MOUNT_HANDLES, v as OnMountContext, w as css, x as __ilhaJsxSlot, y as RawHtml } from "./index-Dx_q_SGo.js";
2
+ export { DerivedAccessor, DerivedValue, EffectContext, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilha as default, ilhaEffect as effect, from, html, ilhaSignal, ilhaSignal as signal, mount, onUncaughtError, raw, untrack };
package/dist/index.js CHANGED
@@ -121,6 +121,7 @@ function isStableInlineSlotMount(initialHtml, renderedHtml, slotIds) {
121
121
  }
122
122
  return true;
123
123
  }
124
+ /** Serialized form of slot props, matching the decoded `data-ilha-props` attr. */
124
125
  function serializeSlotProps(props) {
125
126
  return props === void 0 ? "" : JSON.stringify(props);
126
127
  }
@@ -138,7 +139,19 @@ const MORPH_CONTROLLER_ATTRS = /* @__PURE__ */ new Set([
138
139
  "data-selected",
139
140
  "data-panel-open"
140
141
  ]);
142
+ /**
143
+ * Opt-in contract for controller-owned attributes: an element carrying
144
+ * `data-morph-preserve="attr-a attr-b class"` keeps those attributes exactly
145
+ * as they are in the live DOM — the morph neither overwrites nor removes them.
146
+ * The Areia list above stays as a built-in default for `[data-slot]` elements.
147
+ */
148
+ const MORPH_PRESERVE_ATTR = "data-morph-preserve";
141
149
  function shouldPreserveMorphAttr(el, name) {
150
+ if (name === MORPH_PRESERVE_ATTR) return el.hasAttribute(MORPH_PRESERVE_ATTR);
151
+ const custom = el.getAttribute(MORPH_PRESERVE_ATTR);
152
+ if (custom !== null) {
153
+ for (const token of custom.split(/\s+/)) if (token === name) return true;
154
+ }
142
155
  return el.matches(MORPH_CONTROLLER_SLOT_SELECTOR) && MORPH_CONTROLLER_ATTRS.has(name);
143
156
  }
144
157
  function syncAttributes(from, to) {
@@ -151,18 +164,30 @@ function syncAttributes(from, to) {
151
164
  if (!to.hasAttribute(name)) from.removeAttribute(name);
152
165
  }
153
166
  }
167
+ /**
168
+ * Morph identity key: explicit `data-key`, or the child-island slot id.
169
+ * Slot hosts are matched by id like keyed list items so a mounted child
170
+ * island's host element is reconciled IN PLACE on parent re-renders — never
171
+ * detached and reinserted, which would blur a focused element inside it.
172
+ */
173
+ function morphKeyOf(el) {
174
+ const k = el.getAttribute("data-key");
175
+ if (k !== null) return `k:${k}`;
176
+ const s = el.getAttribute(SLOT_ATTR);
177
+ return s === null ? null : `s:${s}`;
178
+ }
154
179
  function morphChildren(fromParent, toParent) {
155
180
  const toNodes = Array.from(toParent.childNodes);
156
181
  let fromKeyed = null;
157
182
  for (const child of fromParent.children) {
158
- const k = child.getAttribute("data-key");
183
+ const k = morphKeyOf(child);
159
184
  if (k !== null && !(fromKeyed ??= /* @__PURE__ */ new Map()).has(k)) fromKeyed.set(k, child);
160
185
  }
161
186
  let toKeys = null;
162
187
  if (fromKeyed !== null) {
163
188
  toKeys = /* @__PURE__ */ new Set();
164
189
  for (const child of toParent.children) {
165
- const k = child.getAttribute("data-key");
190
+ const k = morphKeyOf(child);
166
191
  if (k !== null) toKeys.add(k);
167
192
  }
168
193
  }
@@ -170,7 +195,7 @@ function morphChildren(fromParent, toParent) {
170
195
  const toNode = toNodes[i];
171
196
  let fromNode = fromParent.childNodes[i];
172
197
  if (fromKeyed !== null) {
173
- const toKey = toNode.nodeType === 1 ? toNode.getAttribute("data-key") : null;
198
+ const toKey = toNode.nodeType === 1 ? morphKeyOf(toNode) : null;
174
199
  if (toKey !== null) {
175
200
  const match = fromKeyed.get(toKey);
176
201
  if (match) {
@@ -182,7 +207,7 @@ function morphChildren(fromParent, toParent) {
182
207
  }
183
208
  }
184
209
  if (fromNode instanceof Element) {
185
- const fromKey = fromNode.getAttribute("data-key");
210
+ const fromKey = morphKeyOf(fromNode);
186
211
  if (fromKey !== null && fromKey !== toKey && toKeys.has(fromKey)) {
187
212
  fromParent.insertBefore(toNode.cloneNode(true), fromNode);
188
213
  continue;
@@ -208,6 +233,18 @@ function morphChildren(fromParent, toParent) {
208
233
  fromParent.replaceChild(toEl.cloneNode(true), fromEl);
209
234
  continue;
210
235
  }
236
+ if (fromEl.hasAttribute(LEAVING_ATTR)) {
237
+ fromParent.replaceChild(toEl.cloneNode(true), fromEl);
238
+ continue;
239
+ }
240
+ {
241
+ const slotId = toEl.getAttribute(SLOT_ATTR);
242
+ if (slotId !== null && fromEl.getAttribute(SLOT_ATTR) === slotId) {
243
+ const props = toEl.getAttribute(PROPS_ATTR);
244
+ if (props !== null && fromEl.getAttribute(PROPS_ATTR) !== props) fromEl.setAttribute(PROPS_ATTR, props);
245
+ continue;
246
+ }
247
+ }
211
248
  if (fromEl.localName === "input" && fromEl.type !== toEl.type) {
212
249
  fromParent.replaceChild(toEl.cloneNode(true), fromEl);
213
250
  continue;
@@ -222,6 +259,22 @@ function morphChildren(fromParent, toParent) {
222
259
  if (newValue !== hadValue) fromEl.value = newValue ?? "";
223
260
  continue;
224
261
  }
262
+ if (fromEl.localName === "select") {
263
+ const before = /* @__PURE__ */ new Map();
264
+ for (const o of fromEl.options) before.set(o, {
265
+ attr: o.hasAttribute("selected"),
266
+ live: o.selected
267
+ });
268
+ syncAttributes(fromEl, toEl);
269
+ morphChildren(fromEl, toEl);
270
+ const options = Array.from(fromEl.options);
271
+ if (options.some((o) => o.hasAttribute("selected") !== (before.get(o)?.attr ?? false))) for (const o of options) o.selected = o.hasAttribute("selected");
272
+ else for (const o of options) {
273
+ const prev = before.get(o);
274
+ if (prev && o.selected !== prev.live) o.selected = prev.live;
275
+ }
276
+ continue;
277
+ }
225
278
  syncAttributes(fromEl, toEl);
226
279
  if (fromEl.localName === "textarea") {
227
280
  const newText = toEl.textContent ?? "";
@@ -234,9 +287,62 @@ function morphChildren(fromParent, toParent) {
234
287
  }
235
288
  while (fromParent.childNodes.length > toNodes.length) fromParent.lastChild.remove();
236
289
  }
290
+ /**
291
+ * Invariant: the morph must never lose focus for an element that survives it.
292
+ * Surviving slot hosts and keyed elements are patched in place, but a genuine
293
+ * reorder still detaches-and-reinserts an ancestor of `document.activeElement`,
294
+ * which blurs it in real engines — snapshot before, restore after.
295
+ */
296
+ function snapshotFocusForMorph() {
297
+ if (typeof document === "undefined") return null;
298
+ const active = document.activeElement;
299
+ if (!(active instanceof HTMLElement) || active === document.body) return null;
300
+ let selection = null;
301
+ let range = null;
302
+ try {
303
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) selection = {
304
+ start: active.selectionStart,
305
+ end: active.selectionEnd,
306
+ dir: active.selectionDirection
307
+ };
308
+ else if (active.isContentEditable) {
309
+ const sel = window.getSelection();
310
+ if (sel && sel.rangeCount > 0) range = sel.getRangeAt(0).cloneRange();
311
+ }
312
+ } catch {}
313
+ return {
314
+ active,
315
+ selection,
316
+ range
317
+ };
318
+ }
319
+ function restoreFocusAfterMorph(snapshot) {
320
+ if (!snapshot) return;
321
+ const { active, selection, range } = snapshot;
322
+ if (!active.isConnected) return;
323
+ try {
324
+ if (document.activeElement !== active) active.focus({ preventScroll: true });
325
+ if (selection && selection.start !== null) {
326
+ const el = active;
327
+ if (el.selectionStart !== selection.start || el.selectionEnd !== selection.end) el.setSelectionRange(selection.start, selection.end, selection.dir ?? "none");
328
+ }
329
+ if (range && range.startContainer.isConnected) {
330
+ const sel = window.getSelection();
331
+ if (sel) {
332
+ const current = sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
333
+ if (!(current !== null && current.startContainer === range.startContainer && current.startOffset === range.startOffset && current.endContainer === range.endContainer && current.endOffset === range.endOffset)) {
334
+ sel.removeAllRanges();
335
+ sel.addRange(range);
336
+ }
337
+ }
338
+ }
339
+ } catch {}
340
+ }
237
341
  function morphInner(from, to) {
238
342
  if (from.localName !== to.localName || from.namespaceURI !== to.namespaceURI) throw new Error("[ilha] morph: elements must match");
343
+ const focus = snapshotFocusForMorph();
239
344
  morphChildren(from, to);
345
+ restoreFocusAfterMorph(focus);
240
346
  }
241
347
  function isStandardSchema(value) {
242
348
  if (value == null || typeof value !== "object") return false;
@@ -277,7 +383,17 @@ const ISLAND_CALL = Symbol.for("ilha.islandCall");
277
383
  * re-renders. Not part of the public surface. */
278
384
  const ISLAND_MOUNT_INTERNAL = Symbol.for("ilha.islandMountInternal");
279
385
  const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
386
+ /** @internal Live mount handles keyed by host element. Lets @ilha/router adopt
387
+ * islands hydrated by `ilha.mount()` (whose handles it never saw) and push new
388
+ * loader props into them in place instead of remounting. Entries are removed
389
+ * on unmount. Not part of the public surface. */
390
+ const ISLAND_MOUNT_HANDLES = /* @__PURE__ */ new WeakMap();
280
391
  const SLOT_ATTR = "data-ilha-slot";
392
+ /** Marks a slot host whose child island is mid leave-transition: it has been
393
+ * unmounted (and its slot id stripped) but stays connected so the leave
394
+ * animation can paint. The morph replaces such elements instead of matching
395
+ * or mutating them. */
396
+ const LEAVING_ATTR = "data-ilha-leaving";
281
397
  const PROPS_ATTR = "data-ilha-props";
282
398
  const STATE_ATTR = "data-ilha-state";
283
399
  const CSS_ATTR = "data-ilha-css";
@@ -1516,6 +1632,8 @@ var IlhaBuilder = class IlhaBuilder {
1516
1632
  leavingSlots.delete(id);
1517
1633
  };
1518
1634
  if (result instanceof Promise) {
1635
+ entry.el.removeAttribute(SLOT_ATTR);
1636
+ entry.el.setAttribute(LEAVING_ATTR, "");
1519
1637
  leavingSlots.set(id, {
1520
1638
  el: entry.el,
1521
1639
  token
@@ -1742,6 +1860,7 @@ var IlhaBuilder = class IlhaBuilder {
1742
1860
  cleanups.push(stopDerived);
1743
1861
  let initialized = false;
1744
1862
  const initialRenderedHtml = initial.html;
1863
+ let lastRendered = null;
1745
1864
  let renderEpoch = 0;
1746
1865
  const stopRender = effect(() => {
1747
1866
  const epoch = ++renderEpoch;
@@ -1758,11 +1877,24 @@ var IlhaBuilder = class IlhaBuilder {
1758
1877
  const next = newSlotMap.get(id);
1759
1878
  if (next) entry.updateProps(next.props);
1760
1879
  }
1761
- if (rendered === initialRenderedHtml) return;
1880
+ if (rendered === initialRenderedHtml) {
1881
+ lastRendered = rendered;
1882
+ return;
1883
+ }
1884
+ }
1885
+ if (rendered === initialRenderedHtml) {
1886
+ lastRendered = rendered;
1887
+ return;
1762
1888
  }
1763
- if (rendered === initialRenderedHtml) return;
1764
1889
  if (mountedSlots.size > 0 && mountedSlots.size === newSlotMap.size && [...newSlotMap.keys()].every((id) => mountedSlots.has(id)) && isStableInlineSlotMount(initialRenderedHtml, rendered, newSlotMap.keys())) return;
1765
1890
  }
1891
+ if (rendered === lastRendered && mountedSlots.size === newSlotMap.size && [...newSlotMap].every(([id, next]) => mountedSlots.get(id)?.island === next.island)) {
1892
+ for (const [id, entry] of mountedSlots) {
1893
+ const next = newSlotMap.get(id);
1894
+ if (next) entry.updateProps(next.props);
1895
+ }
1896
+ return;
1897
+ }
1766
1898
  detachListeners();
1767
1899
  stopBindings();
1768
1900
  const prevMountedCount = mountedSlots.size;
@@ -1780,34 +1912,33 @@ var IlhaBuilder = class IlhaBuilder {
1780
1912
  }
1781
1913
  const applyMorph = () => {
1782
1914
  if (epoch !== renderEpoch) return;
1783
- const preserved = /* @__PURE__ */ new Map();
1784
- const listShrunk = newSlotMap.size < prevMountedCount;
1785
- for (const [id, entry] of mountedSlots) {
1786
- if (!newSlotMap.has(id)) continue;
1787
- if (!entry.el.isConnected) continue;
1788
- if (listShrunk && id.startsWith("p:")) {
1789
- teardownMountedSlot(id, entry);
1790
- continue;
1915
+ if (newSlotMap.size < prevMountedCount) {
1916
+ let divergeFrom = Infinity;
1917
+ for (const [id, entry] of mountedSlots) {
1918
+ if (!id.startsWith("p:")) continue;
1919
+ const next = newSlotMap.get(id);
1920
+ if (!next) continue;
1921
+ const index = Number(id.slice(2));
1922
+ if (Number.isNaN(index) || index >= divergeFrom) continue;
1923
+ if (serializeSlotProps(next.props) !== (entry.el.getAttribute(PROPS_ATTR) ?? "")) divergeFrom = index;
1924
+ }
1925
+ for (const [id, entry] of mountedSlots) {
1926
+ if (!newSlotMap.has(id)) continue;
1927
+ if (!entry.el.isConnected) continue;
1928
+ if (id.startsWith("p:") && Number(id.slice(2)) >= divergeFrom) teardownMountedSlot(id, entry);
1791
1929
  }
1792
- const stubTag = entry.el.tagName.toLowerCase();
1793
- const stub = document.createElement(stubTag);
1794
- stub.setAttribute(SLOT_ATTR, id);
1795
- const incomingProps = serializeSlotProps(newSlotMap.get(id)?.props);
1796
- if (incomingProps !== "") stub.setAttribute(PROPS_ATTR, incomingProps);
1797
- entry.el.replaceWith(stub);
1798
- preserved.set(id, entry.el);
1799
1930
  }
1800
1931
  const tpl = document.createElement("template");
1801
1932
  const morphRootTag = host.tagName.toLowerCase();
1802
1933
  tpl.innerHTML = `<${morphRootTag}>${html}</${morphRootTag}>`;
1803
1934
  morphInner(host, tpl.content.firstElementChild);
1804
- if (preserved.size > 0) {
1805
- const rehomeIndex = buildSlotIndex();
1806
- for (const [id, liveEl] of preserved) {
1807
- const stub = rehomeIndex.get(id);
1808
- if (!stub) continue;
1809
- stub.replaceWith(liveEl);
1810
- }
1935
+ lastRendered = rendered;
1936
+ let rehomeIndex = null;
1937
+ for (const [id, entry] of mountedSlots) {
1938
+ if (!newSlotMap.has(id) || entry.el.isConnected) continue;
1939
+ rehomeIndex ??= buildSlotIndex();
1940
+ const stub = rehomeIndex.get(id);
1941
+ if (stub && stub !== entry.el) stub.replaceWith(entry.el);
1811
1942
  }
1812
1943
  attachListeners();
1813
1944
  stopBindings = applyTemplateBindings(host, newBinds);
@@ -1823,6 +1954,7 @@ var IlhaBuilder = class IlhaBuilder {
1823
1954
  const unmount = () => {
1824
1955
  if (tornDown) return;
1825
1956
  tornDown = true;
1957
+ ISLAND_MOUNT_HANDLES.delete(host);
1826
1958
  stopRender();
1827
1959
  detachListeners();
1828
1960
  const pending = [];
@@ -1851,10 +1983,12 @@ var IlhaBuilder = class IlhaBuilder {
1851
1983
  if (shallowEqualInput(inputSignal(), next)) return;
1852
1984
  inputSignal(next);
1853
1985
  };
1854
- return {
1986
+ const handle = {
1855
1987
  unmount,
1856
1988
  updateProps
1857
1989
  };
1990
+ ISLAND_MOUNT_HANDLES.set(host, handle);
1991
+ return handle;
1858
1992
  }
1859
1993
  /**
1860
1994
  * Dual-mode island invocation:
@@ -2130,4 +2264,4 @@ const from = ilhaFrom;
2130
2264
  const context = ilhaContext;
2131
2265
 
2132
2266
  //#endregion
2133
- export { ISLAND_MOUNT_INTERNAL, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilha as default, ilhaEffect as effect, from, html, ilhaSignal, ilhaSignal as signal, mount, onUncaughtError, raw, untrack };
2267
+ export { ISLAND_MOUNT_HANDLES, ISLAND_MOUNT_INTERNAL, __ilhaJsxSlot, batch, ilhaComputed as computed, context, css, ilha as default, ilhaEffect as effect, from, html, ilhaSignal, ilhaSignal as signal, mount, onUncaughtError, raw, untrack };
@@ -1,2 +1,2 @@
1
- import { a as JSX, r as jsxDEV, t as Fragment } from "./jsx-runtime-DBCAZ9YL.js";
1
+ import { a as JSX, r as jsxDEV, t as Fragment } from "./jsx-runtime-DysRyz9R.js";
2
2
  export { Fragment, type JSX, jsxDEV };
@@ -1,4 +1,4 @@
1
- import { v as RawHtml } from "./index-C0SWn9oq.js";
1
+ import { y as RawHtml } from "./index-Dx_q_SGo.js";
2
2
 
3
3
  //#region src/jsx-types.d.ts
4
4
  declare namespace JSX {
@@ -1,2 +1,2 @@
1
- import { a as JSX, i as jsxs, n as jsx, r as jsxDEV, t as Fragment } from "./jsx-runtime-DBCAZ9YL.js";
1
+ import { a as JSX, i as jsxs, n as jsx, r as jsxDEV, t as Fragment } from "./jsx-runtime-DysRyz9R.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.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "keywords": [
6
6
  "framework-free",