ilha 0.14.8 → 0.14.10

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/dist/define.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { i as Component } from "./types-CJF2qMK6.js";
2
2
  //#region src/define.d.ts
3
- declare const define: (name: string, component: Component) => void;
4
- //#endregion
5
- export { define };
3
+ export declare const define: (name: string, component: Component) => void;
4
+ //#endregion
package/dist/define.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as mount } from "./mount-BQ-zcg2x.js";
1
+ import { t as mount } from "./mount-BzNrfVW4.js";
2
2
 
3
3
  //#region src/define.ts
4
4
  const define = (name, component) => {
package/dist/index.d.ts CHANGED
@@ -16,9 +16,9 @@ interface IlhaContext<A> {
16
16
  readonly defaultValue: A;
17
17
  readonly Provider: (props: ContextProviderProps<A>) => View;
18
18
  }
19
- declare const createContext: <A>(defaultValue: A) => IlhaContext<A>;
19
+ export declare const createContext: <A>(defaultValue: A) => IlhaContext<A>;
20
20
  /** Read a context value as an atom handle. Subscribes the active fiber on read. */
21
- declare const context: <A>(ctx: IlhaContext<A>) => AtomHandle<A>;
21
+ export declare const context: <A>(ctx: IlhaContext<A>) => AtomHandle<A>;
22
22
  //#endregion
23
23
  //#region src/error-boundary.d.ts
24
24
  interface ErrorFallbackProps {
@@ -32,7 +32,7 @@ interface ErrorBoundaryProps {
32
32
  children?: View;
33
33
  }
34
34
  /** Catch subtree failures and paint a fallback. Supports reset(). */
35
- declare const ErrorBoundary: (props: ErrorBoundaryProps) => View;
35
+ export declare const ErrorBoundary: (props: ErrorBoundaryProps) => View;
36
36
  //#endregion
37
37
  //#region src/watch.d.ts
38
38
  /** Values delivered to watch callbacks. */
@@ -44,30 +44,30 @@ interface WatchFn {
44
44
  <A extends WatchValue>(source: AtomHandle<A> | Atom.Atom<A> | Stream$1.Stream<A, unknown, unknown>, fn: WatchCallback<A>): Instruction<undefined>;
45
45
  once: (fn: (ctx: WatchContext) => WatchOnceCleanup | undefined | Promise<WatchOnceCleanup | undefined>) => Instruction<undefined>;
46
46
  }
47
- declare const watch: WatchFn;
47
+ export declare const watch: WatchFn;
48
48
  //#endregion
49
49
  //#region src/atom.d.ts
50
50
  /** Key-order-insensitive deep equality for JSON-shaped values. */
51
- declare const structuralEqual: typeof Equal.equals;
51
+ export declare const structuralEqual: typeof Equal.equals;
52
52
  /** Read atoms inside `fn` without subscribing the active render. Sync only. */
53
- declare const untrack: <A>(fn: () => A) => A;
53
+ export declare const untrack: <A>(fn: () => A) => A;
54
54
  interface AtomFn {
55
55
  <A>(init: A | Atom.Atom<A> | Effect$1.Effect<A, unknown, AtomRegistry> | Stream$1.Stream<A, unknown, AtomRegistry>, options?: AtomOptions<A>): AtomHandle<A>;
56
56
  lazy: <A>(init: () => A) => AtomHandle<A>;
57
57
  }
58
- declare const atom: AtomFn;
59
- declare const batch: (f: () => void) => void;
58
+ export declare const atom: AtomFn;
59
+ export declare const batch: (f: () => void) => void;
60
60
  //#endregion
61
61
  //#region src/resource.d.ts
62
62
  /** Drop the cached value for `key` and refetch it in live components. */
63
- declare const invalidate: (key: string) => void;
63
+ export declare const invalidate: (key: string) => void;
64
64
  /**
65
65
  * Fetch-once, share-everywhere async state for `key`. Concurrent mounts
66
66
  * dedupe on one in-flight request; remounts seed from the shared cache
67
67
  * (and revalidate when `staleWhileRevalidate` holds). Call unconditionally
68
68
  * at the top level of a sync component, before any `await`.
69
69
  */
70
- declare const resource: <T>(key: string, fetcher: ResourceFetcher<T>, opts?: ResourceOptions) => Resource<T>;
70
+ export declare const resource: <T>(key: string, fetcher: ResourceFetcher<T>, opts?: ResourceOptions) => Resource<T>;
71
71
  //#endregion
72
72
  //#region src/sse.d.ts
73
73
  /**
@@ -76,10 +76,10 @@ declare const resource: <T>(key: string, fetcher: ResourceFetcher<T>, opts?: Res
76
76
  * an empty stream with status `"closed"`. Call unconditionally at the top
77
77
  * level of a component.
78
78
  */
79
- declare const fromEventSource: <T = string>(url: string, opts?: EventSourceOptions<T>) => EventSourceFeed<T>;
79
+ export declare const fromEventSource: <T = string>(url: string, opts?: EventSourceOptions<T>) => EventSourceFeed<T>;
80
80
  //#endregion
81
81
  //#region src/when.d.ts
82
- declare const when: <A, E = never, R = never>(stream: Stream$1.Stream<A, E, R>, body: (value: A) => Generator<Yielded, View | undefined, View>) => Instruction<undefined, E>;
82
+ export declare const when: <A, E = never, R = never>(stream: Stream$1.Stream<A, E, R>, body: (value: A) => Generator<Yielded, View | undefined, View>) => Instruction<undefined, E>;
83
83
  //#endregion
84
84
  //#region src/mount.d.ts
85
85
  interface RenderToStringOptions {
@@ -92,8 +92,8 @@ interface MountOptions {
92
92
  hydrate?: boolean;
93
93
  onError?: <E>(error: E) => void;
94
94
  }
95
- declare const mount: (el: Element, fn: Component, opts?: MountOptions) => (() => void);
96
- declare const renderToString: (fn: Component, opts?: RenderToStringOptions) => Promise<string>;
95
+ export declare const mount: (el: Element, fn: Component, opts?: MountOptions) => (() => void);
96
+ export declare const renderToString: (fn: Component, opts?: RenderToStringOptions) => Promise<string>;
97
97
  //#endregion
98
98
  //#region src/unsafe.d.ts
99
99
  /**
@@ -104,6 +104,6 @@ declare const renderToString: (fn: Component, opts?: RenderToStringOptions) => P
104
104
  * scripts inserted this way do not run, but event attributes and other
105
105
  * markup still apply.
106
106
  */
107
- declare const unsafe: (html: string) => UnsafeHtml;
107
+ export declare const unsafe: (html: string) => UnsafeHtml;
108
108
  //#endregion
109
- export { type AtomEquality, type AtomHandle, type AtomOptions, type Component, type ContextProviderProps, ErrorBoundary, type ErrorBoundaryProps, type ErrorFallback, type ErrorFallbackProps, type EventSourceFeed, type EventSourceOptions, type EventSourceStatus, Fragment, type IlhaContext, type MountOptions, type RenderToStringOptions, type Resource, type ResourceFetcher, type ResourceOptions, type UnsafeHtml, type View, type WatchCallback, type WatchContext, type WatchOnceCleanup, atom, batch, context, createContext, fromEventSource, h, invalidate, mount, renderToString, resource, structuralEqual, unsafe, untrack, watch, when };
109
+ export { type AtomEquality, type AtomHandle, type AtomOptions, type Component, type ContextProviderProps, type ErrorBoundaryProps, type ErrorFallback, type ErrorFallbackProps, type EventSourceFeed, type EventSourceOptions, type EventSourceStatus, Fragment, type IlhaContext, type MountOptions, type RenderToStringOptions, type Resource, type ResourceFetcher, type ResourceOptions, type UnsafeHtml, type View, type WatchCallback, type WatchContext, type WatchOnceCleanup, h };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as makeFiber, a as interpret, c as handleOwner, d as structuralEqual, f as untrack, g as getFiber, h as closeFiber, i as runSetup, l as instr, m as clearFiberView, n as renderToString, o as atom, p as wrapHandle, r as unsafe, s as batch, t as mount, u as isAtomHandle } from "./mount-BQ-zcg2x.js";
1
+ import { _ as makeFiber, a as interpret, c as handleOwner, d as structuralEqual, f as untrack, g as getFiber, h as closeFiber, i as runSetup, l as instr, m as clearFiberView, n as renderToString, o as atom, p as wrapHandle, r as unsafe, s as batch, t as mount, u as isAtomHandle } from "./mount-BzNrfVW4.js";
2
2
  import { d as errorView, g as isString, i as Fragment, p as isFunction, t as h } from "./vnode-M-oJMKcv.js";
3
3
  import * as Atom from "effect/reactivity/Atom";
4
4
  import * as Effect from "effect/Effect";
@@ -241,6 +241,18 @@ const watch = Object.assign(watchSource, { once: watchOnce });
241
241
  const sharedCache = /* @__PURE__ */ new Map();
242
242
  const liveRefetchers = /* @__PURE__ */ new Map();
243
243
  const cells$1 = /* @__PURE__ */ new WeakMap();
244
+ /** Unmount: stop every client cell of one fiber (all keys it ever used). */
245
+ const releaseCells = (byKey) => {
246
+ for (const [key, entry] of byKey) {
247
+ const c = entry;
248
+ c.alive = false;
249
+ c.ctrl?.abort();
250
+ const live = liveRefetchers.get(key);
251
+ live?.delete(c.onInvalidate);
252
+ if (live && live.size === 0) liveRefetchers.delete(key);
253
+ }
254
+ byKey.clear();
255
+ };
244
256
  const entryFor = (key) => sharedCache.get(key);
245
257
  /** Drop the cached value for `key` and refetch it in live components. */
246
258
  const invalidate = (key) => {
@@ -325,10 +337,13 @@ const resource = (key, fetcher, opts) => {
325
337
  }
326
338
  let cell = byKey.get(key);
327
339
  if (!cell) {
328
- cell = {
340
+ const fresh = {
329
341
  alive: true,
330
342
  ctrl: null,
331
- held: false,
343
+ onInvalidate: () => {
344
+ fresh.revalidate();
345
+ },
346
+ revalidate: () => {},
332
347
  sink: {
333
348
  data,
334
349
  error,
@@ -336,7 +351,14 @@ const resource = (key, fetcher, opts) => {
336
351
  },
337
352
  started: false
338
353
  };
354
+ cell = fresh;
339
355
  byKey.set(key, cell);
356
+ let live = liveRefetchers.get(key);
357
+ if (!live) {
358
+ live = /* @__PURE__ */ new Set();
359
+ liveRefetchers.set(key, live);
360
+ }
361
+ live.add(cell.onInvalidate);
340
362
  }
341
363
  cell.sink = {
342
364
  data,
@@ -411,6 +433,9 @@ const resource = (key, fetcher, opts) => {
411
433
  await start();
412
434
  } catch {}
413
435
  };
436
+ c.revalidate = () => {
437
+ settleStart();
438
+ };
414
439
  if (!c.started) {
415
440
  c.started = true;
416
441
  const swr = opts?.staleWhileRevalidate ?? true;
@@ -439,28 +464,9 @@ const resource = (key, fetcher, opts) => {
439
464
  }
440
465
  }
441
466
  }
442
- if (!c.held) {
443
- c.held = true;
444
- const onInvalidate = () => {
445
- settleStart();
446
- };
447
- watch.once(() => {
448
- let live = liveRefetchers.get(key);
449
- if (!live) {
450
- live = /* @__PURE__ */ new Set();
451
- liveRefetchers.set(key, live);
452
- }
453
- live.add(onInvalidate);
454
- return () => {
455
- c.alive = false;
456
- c.ctrl?.abort();
457
- const s = liveRefetchers.get(key);
458
- s?.delete(onInvalidate);
459
- if (s && s.size === 0) liveRefetchers.delete(key);
460
- if (fiberCells.get(key) === c) fiberCells.delete(key);
461
- };
462
- });
463
- }
467
+ watch.once(() => () => {
468
+ releaseCells(fiberCells);
469
+ });
464
470
  return {
465
471
  data,
466
472
  error,
@@ -1,8 +1,8 @@
1
1
  import { a as ComponentFn, d as JsxComponent, f as PropBag, l as Fragment, v as View } from "./types-CJF2qMK6.js";
2
2
  import { n as JSX, t as h } from "./vnode-CUTNGblA.js";
3
3
  //#region src/jsx-runtime.d.ts
4
- declare const jsx: (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null, key?: string | number) => ReturnType<typeof h>;
5
- declare const jsxs: (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null, key?: string | number) => ReturnType<typeof h>;
6
- declare const jsxDEV: (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null, key?: string | number) => ReturnType<typeof h>;
4
+ export declare const jsx: (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null, key?: string | number) => ReturnType<typeof h>;
5
+ export declare const jsxs: typeof jsx;
6
+ export declare const jsxDEV: typeof jsx;
7
7
  //#endregion
8
- export { Fragment, type JSX, type View, h, jsx, jsxDEV, jsxs };
8
+ export { Fragment, type JSX, type View, h };
@@ -512,39 +512,71 @@ const eventTypeFromProp = (key) => {
512
512
  return type.length >= 3 ? type : void 0;
513
513
  };
514
514
  const isEventProp = (key) => eventTypeFromProp(key) !== void 0;
515
+ const dispatch = (el, type, ev) => {
516
+ const fiber = el.__ilhaFiber;
517
+ if (!fiber || fiber.closed) return;
518
+ const fn = el.__ilhaHandlers?.get(type);
519
+ if (!isFunction(fn)) return;
520
+ const out = fn(ev);
521
+ if (Effect$1.isEffect(out)) fiber.run(out);
522
+ };
523
+ /** One native listener per type; it reads the latest handler at dispatch. */
524
+ const listen = (el, type) => {
525
+ el.__ilhaEvents ??= /* @__PURE__ */ new Set();
526
+ if (el.__ilhaEvents.has(type)) return;
527
+ el.__ilhaEvents.add(type);
528
+ el.addEventListener(type, (ev) => {
529
+ dispatch(el, type, ev);
530
+ });
531
+ };
532
+ const bindSsrActions = (el, props, fiber) => {
533
+ const seen = /* @__PURE__ */ new Set();
534
+ for (const key of Object.keys(props)) {
535
+ const type = eventTypeFromProp(key);
536
+ if (!type || seen.has(type)) continue;
537
+ seen.add(type);
538
+ const fn = props[key];
539
+ if (!isFunction(fn)) continue;
540
+ const branded = fn[ACTION_CALL$1];
541
+ const rec = branded?.k ? {
542
+ a: branded.a ?? [],
543
+ k: branded.k
544
+ } : void 0;
545
+ if (!rec) continue;
546
+ const id = `${type}:${fiber.runtime.ssrEventI}`;
547
+ fiber.runtime.ssrEventI += 1;
548
+ fiber.runtime.ssrActions[id] = rec;
549
+ const existing = el.dataset.ilhaOn;
550
+ el.dataset.ilhaOn = existing ? `${existing},${id}` : id;
551
+ }
552
+ };
515
553
  const bindEvents = (node, props, fiber) => {
516
554
  const el = node;
517
- el.__ilhaProps = props;
518
- el.__ilhaEvents ??= /* @__PURE__ */ new Set();
555
+ if (fiber.runtime.ssr) {
556
+ bindSsrActions(el, props, fiber);
557
+ return;
558
+ }
559
+ const handlers = /* @__PURE__ */ new Map();
519
560
  for (const key of Object.keys(props)) {
520
561
  const type = eventTypeFromProp(key);
521
- if (!type) continue;
522
- if (el.__ilhaEvents.has(type)) continue;
523
- el.__ilhaEvents.add(type);
524
- if (fiber.runtime.ssr) {
525
- const fn = props[key];
526
- if (!isFunction(fn)) continue;
527
- const branded = fn[ACTION_CALL$1];
528
- const rec = branded?.k ? {
529
- a: branded.a ?? [],
530
- k: branded.k
531
- } : void 0;
532
- if (!rec) continue;
533
- const id = `${type}:${fiber.runtime.ssrEventI}`;
534
- fiber.runtime.ssrEventI += 1;
535
- fiber.runtime.ssrActions[id] = rec;
536
- const existing = el.dataset.ilhaOn;
537
- el.dataset.ilhaOn = existing ? `${existing},${id}` : id;
538
- continue;
539
- }
540
- el.addEventListener(type, (ev) => {
541
- if (fiber.closed) return;
542
- const fn = el.__ilhaProps?.[key];
543
- if (!isFunction(fn)) return;
544
- const out = fn(ev);
545
- if (Effect$1.isEffect(out)) fiber.run(out);
546
- });
562
+ if (type && !handlers.has(type)) handlers.set(type, props[key]);
547
563
  }
564
+ el.__ilhaHandlers = handlers;
565
+ el.__ilhaFiber = fiber;
566
+ for (const type of handlers.keys()) listen(el, type);
567
+ };
568
+ /**
569
+ * The morph kept `live` in place of the freshly rendered `fresh`: hand the
570
+ * render's handlers to the live node, so closures never go stale and handlers
571
+ * added on a later render still fire.
572
+ */
573
+ const adoptEvents = (live, fresh) => {
574
+ const from = fresh;
575
+ if (!from.__ilhaFiber) return;
576
+ const to = live;
577
+ to.__ilhaHandlers = from.__ilhaHandlers;
578
+ to.__ilhaFiber = from.__ilhaFiber;
579
+ for (const type of from.__ilhaHandlers?.keys() ?? []) listen(to, type);
548
580
  };
549
581
 
550
582
  //#endregion
@@ -556,14 +588,17 @@ const morphKeyOf = (el) => {
556
588
  const s = el.getAttribute(SLOT_ATTR);
557
589
  return s === null ? null : `s:${s}`;
558
590
  };
591
+ /** Whether `data-morph-preserve` on `el` lists `token`. */
592
+ const hasPreserveToken = (el, token) => {
593
+ const custom = el.getAttribute(PRESERVE_ATTR);
594
+ if (custom === null) return false;
595
+ for (const listed of custom.split(/\s+/u)) if (listed === token) return true;
596
+ return false;
597
+ };
559
598
  const shouldPreserveMorphAttr = (el, name) => {
560
599
  if (name === "value" || name === "checked" || name === "selected") return true;
561
600
  if (name === PRESERVE_ATTR) return el.hasAttribute(PRESERVE_ATTR);
562
- const custom = el.getAttribute(PRESERVE_ATTR);
563
- if (custom !== null) {
564
- for (const token of custom.split(/\s+/u)) if (token === name) return true;
565
- }
566
- return false;
601
+ return hasPreserveToken(el, name);
567
602
  };
568
603
  const syncAttributes = (from, to) => {
569
604
  for (const { name, value } of to.attributes) {
@@ -578,9 +613,26 @@ const syncAttributes = (from, to) => {
578
613
  if (!to.hasAttribute(name)) from.removeAttribute(name);
579
614
  }
580
615
  };
616
+ /** Deepest focused element, looking through open shadow roots. */
617
+ const deepActiveElement = () => {
618
+ let active = document.activeElement;
619
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
620
+ return active;
621
+ };
622
+ const snapshotRanges = (active) => {
623
+ if (!active.isContentEditable) return null;
624
+ const sel = document.getSelection();
625
+ if (!sel || sel.rangeCount === 0) return null;
626
+ const ranges = [];
627
+ for (let i = 0; i < sel.rangeCount; i += 1) {
628
+ const range = sel.getRangeAt(i);
629
+ if (active.contains(range.startContainer)) ranges.push(range.cloneRange());
630
+ }
631
+ return ranges.length > 0 ? ranges : null;
632
+ };
581
633
  const snapshotFocus = () => {
582
634
  if (globalThis.document === void 0) return null;
583
- const active = document.activeElement;
635
+ const active = deepActiveElement();
584
636
  if (!(active instanceof HTMLElement) || active === document.body) return null;
585
637
  let selection = null;
586
638
  try {
@@ -592,13 +644,23 @@ const snapshotFocus = () => {
592
644
  } catch {}
593
645
  return {
594
646
  active,
647
+ ranges: snapshotRanges(active),
595
648
  selection
596
649
  };
597
650
  };
651
+ const restoreRanges = (ranges) => {
652
+ const sel = document.getSelection();
653
+ if (!sel || !ranges.every((r) => r.startContainer.isConnected)) return;
654
+ sel.removeAllRanges();
655
+ for (const r of ranges) sel.addRange(r);
656
+ };
598
657
  const restoreFocus = (snapshot) => {
599
658
  if (!snapshot || !snapshot.active.isConnected) return;
600
659
  try {
601
- if (document.activeElement !== snapshot.active) snapshot.active.focus({ preventScroll: true });
660
+ if (deepActiveElement() !== snapshot.active) {
661
+ snapshot.active.focus({ preventScroll: true });
662
+ if (snapshot.ranges) restoreRanges(snapshot.ranges);
663
+ }
602
664
  const sel = snapshot.selection;
603
665
  if (sel && sel.start !== null) {
604
666
  const el = snapshot.active;
@@ -606,6 +668,122 @@ const restoreFocus = (snapshot) => {
606
668
  }
607
669
  } catch {}
608
670
  };
671
+ const STAND_IN_ATTR = "data-ilha-reuse";
672
+ const standIns = /* @__PURE__ */ new WeakMap();
673
+ /** Live nodes with an outstanding stand-in: the morph must not consume them. */
674
+ const claimed = /* @__PURE__ */ new WeakSet();
675
+ /** Placeholder for a connected live node inside a scratch render tree. */
676
+ const standInFor = (live) => {
677
+ const el = document.createElementNS(live.namespaceURI, live.localName);
678
+ for (const name of [SLOT_ATTR, KEY_ATTR]) {
679
+ const v = live.getAttribute(name);
680
+ if (v !== null) el.setAttribute(name, v);
681
+ }
682
+ el.setAttribute(STAND_IN_ATTR, "");
683
+ standIns.set(el, live);
684
+ claimed.add(live);
685
+ return el;
686
+ };
687
+ const liveOf = (node) => {
688
+ const live = standIns.get(node);
689
+ if (!live) return node;
690
+ claimed.delete(live);
691
+ return live;
692
+ };
693
+ /**
694
+ * Insert `node` before `ref`. Connected nodes move with `moveBefore` where the
695
+ * browser has it, so they keep focus, iframe, dialog, and element state.
696
+ */
697
+ const place = (parent, node, ref) => {
698
+ if (node === ref || node.parentNode === parent && node.nextSibling === ref) return;
699
+ if (node.isConnected && parent.isConnected && parent.moveBefore) try {
700
+ parent.moveBefore(node, ref);
701
+ return;
702
+ } catch {}
703
+ if (ref) ref.before(node);
704
+ else parent.append(node);
705
+ };
706
+ /** Swap stand-ins nested in `node` (now in the document) for live nodes. */
707
+ const resolveNested = (node) => {
708
+ if (!(node instanceof Element) || !node.firstElementChild) return;
709
+ const nested = node.querySelectorAll(`[${STAND_IN_ATTR}]`);
710
+ for (const s of nested) {
711
+ const live = liveOf(s);
712
+ if (live !== s && s.parentNode) {
713
+ place(s.parentNode, live, s);
714
+ s.remove();
715
+ }
716
+ }
717
+ };
718
+ /**
719
+ * Put a rendered node into the live tree: stand-ins become their live nodes,
720
+ * and stand-ins nested in fresh subtrees resolve once the subtree is attached.
721
+ */
722
+ const insertRendered = (parent, node, ref) => {
723
+ const live = liveOf(node);
724
+ place(parent, live, ref);
725
+ if (live === node) resolveNested(node);
726
+ };
727
+ /** Longest increasing run of old indexes: those nodes never move. */
728
+ const stableSet = (nodes, oldIndex) => {
729
+ const tails = [];
730
+ const prev = [];
731
+ for (let i = 0; i < nodes.length; i += 1) {
732
+ const node = nodes[i];
733
+ const idx = node ? oldIndex.get(node) : void 0;
734
+ if (idx === void 0) {
735
+ prev.push(-1);
736
+ continue;
737
+ }
738
+ let lo = 0;
739
+ let hi = tails.length;
740
+ while (lo < hi) {
741
+ const mid = Math.floor((lo + hi) / 2);
742
+ const tail = nodes[tails[mid] ?? 0];
743
+ if ((tail ? oldIndex.get(tail) ?? 0 : 0) < idx) lo = mid + 1;
744
+ else hi = mid;
745
+ }
746
+ prev.push(lo > 0 ? tails[lo - 1] ?? -1 : -1);
747
+ tails[lo] = i;
748
+ }
749
+ const stable = /* @__PURE__ */ new Set();
750
+ let k = tails.length > 0 ? tails.at(-1) ?? -1 : -1;
751
+ while (k >= 0) {
752
+ const node = nodes[k];
753
+ if (node) stable.add(node);
754
+ k = prev[k] ?? -1;
755
+ }
756
+ return stable;
757
+ };
758
+ /**
759
+ * Make `parent`'s children exactly `nodes` with the fewest moves. Nodes that
760
+ * keep their relative order stay attached; removed nodes leave first.
761
+ */
762
+ const placeChildren = (parent, rendered) => {
763
+ const focus = snapshotFocus();
764
+ const nodes = rendered.map(liveOf);
765
+ const wanted = new Set(nodes);
766
+ const oldIndex = /* @__PURE__ */ new Map();
767
+ let child = parent.firstChild;
768
+ while (child) {
769
+ const next = child.nextSibling;
770
+ if (wanted.has(child)) oldIndex.set(child, oldIndex.size);
771
+ else child.remove();
772
+ child = next;
773
+ }
774
+ const stable = stableSet(nodes, oldIndex);
775
+ let next = null;
776
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
777
+ const node = nodes[i];
778
+ if (!node) continue;
779
+ if (!stable.has(node)) {
780
+ place(parent, node, next);
781
+ if (!oldIndex.has(node)) resolveNested(node);
782
+ }
783
+ next = node;
784
+ }
785
+ restoreFocus(focus);
786
+ };
609
787
  const buildKeyedIndex = (parent) => {
610
788
  let fromKeyed = null;
611
789
  for (const child of parent.children) {
@@ -667,18 +845,43 @@ const morphSelect = (fromEl, toEl, api) => {
667
845
  if (prev && o.selected !== prev.live) o.selected = prev.live;
668
846
  }
669
847
  };
848
+ /** Rendered element → the live element the morph kept in its place. */
849
+ const adoptedBy = /* @__PURE__ */ new WeakMap();
850
+ /** The element a rendered element ended up as in the document. */
851
+ const committedElement = (el) => adoptedBy.get(el) ?? el;
852
+ /** `live` stays in the document for `fresh`: move the render's bindings. */
853
+ const adopt = (live, fresh) => {
854
+ adoptedBy.set(fresh, live);
855
+ adoptEvents(live, fresh);
856
+ };
857
+ /**
858
+ * A contenteditable the render leaves empty is uncontrolled: the user's
859
+ * content (and caret) survives, like an uncontrolled textarea.
860
+ */
861
+ const isUncontrolledEditable = (from, to) => {
862
+ const mode = to.getAttribute("contenteditable");
863
+ return mode !== null && mode !== "false" && !to.hasChildNodes() && from.getAttribute("contenteditable") === mode;
864
+ };
865
+ /** Swap `fromNode` for a rendered node without ever detaching live nodes. */
866
+ const replaceRendered = (fromNode, toNode) => {
867
+ const parent = fromNode.parentNode;
868
+ if (!parent) return;
869
+ insertRendered(parent, toNode, fromNode);
870
+ fromNode.remove();
871
+ };
670
872
  const morphElementPair = (fromEl, toEl, api) => {
671
873
  if (fromEl.localName !== toEl.localName) {
672
- fromEl.replaceWith(toEl);
874
+ replaceRendered(fromEl, toEl);
673
875
  return;
674
876
  }
675
877
  const toSlot = toEl.getAttribute(SLOT_ATTR);
676
878
  const fromSlot = fromEl.getAttribute(SLOT_ATTR);
677
879
  if (toSlot !== null && fromSlot === toSlot) return;
678
880
  if (toSlot !== null) {
679
- fromEl.replaceWith(toEl);
881
+ replaceRendered(fromEl, toEl);
680
882
  return;
681
883
  }
884
+ adopt(fromEl, toEl);
682
885
  if (fromEl.localName === "input") {
683
886
  morphInput(fromEl, toEl);
684
887
  return;
@@ -687,10 +890,18 @@ const morphElementPair = (fromEl, toEl, api) => {
687
890
  morphSelect(fromEl, toEl, api);
688
891
  return;
689
892
  }
893
+ const keepChildren = hasPreserveToken(fromEl, "children") || hasPreserveToken(toEl, "children") || isUncontrolledEditable(fromEl, toEl);
690
894
  syncAttributes(fromEl, toEl);
895
+ if (keepChildren) return;
691
896
  if (fromEl.localName === "textarea") morphTextarea(fromEl, toEl);
692
897
  else api.morphChildren(fromEl, toEl);
693
898
  };
899
+ /** Keyed node the new render drops and no stand-in still needs. */
900
+ const isStaleKeyed = (node, toKeys) => {
901
+ if (!(node instanceof Element) || claimed.has(node)) return false;
902
+ const key = morphKeyOf(node);
903
+ return key !== null && !toKeys.has(key);
904
+ };
694
905
  const alignKeyedNode = (fromParent, fromNode, toNode, fromKeyed, toKeys) => {
695
906
  const toKey = toNode.nodeType === 1 ? morphKeyOf(toNode) : null;
696
907
  let current = fromNode;
@@ -698,9 +909,13 @@ const alignKeyedNode = (fromParent, fromNode, toNode, fromKeyed, toKeys) => {
698
909
  const match = fromKeyed.get(toKey);
699
910
  if (match) {
700
911
  fromKeyed.delete(toKey);
912
+ while (current && current !== match && isStaleKeyed(current, toKeys)) {
913
+ const next = current.nextSibling;
914
+ current.remove();
915
+ current = next ?? void 0;
916
+ }
701
917
  if (match !== current) {
702
- current?.before(match);
703
- if (!current) fromParent.append(match);
918
+ place(fromParent, match, current ?? null);
704
919
  current = match;
705
920
  }
706
921
  }
@@ -708,12 +923,31 @@ const alignKeyedNode = (fromParent, fromNode, toNode, fromKeyed, toKeys) => {
708
923
  if (current instanceof Element) {
709
924
  const fromKey = morphKeyOf(current);
710
925
  if (fromKey !== null && fromKey !== toKey && toKeys.has(fromKey)) {
711
- current.before(toNode);
926
+ insertRendered(fromParent, toNode, current);
712
927
  return "continue";
713
928
  }
714
929
  }
715
930
  return current;
716
931
  };
932
+ const morphChild = ({ api, fromParent, fromNode, toNode }) => {
933
+ if (standIns.has(toNode)) {
934
+ insertRendered(fromParent, toNode, fromNode ?? null);
935
+ return;
936
+ }
937
+ if (!fromNode || claimed.has(fromNode)) {
938
+ insertRendered(fromParent, toNode, fromNode ?? null);
939
+ return;
940
+ }
941
+ if (fromNode.nodeType !== toNode.nodeType) {
942
+ replaceRendered(fromNode, toNode);
943
+ return;
944
+ }
945
+ if (fromNode.nodeType === 3 || fromNode.nodeType === 8) {
946
+ if (fromNode.nodeValue !== toNode.nodeValue) fromNode.nodeValue = toNode.nodeValue;
947
+ return;
948
+ }
949
+ if (fromNode.nodeType === 1) morphElementPair(fromNode, toNode, api);
950
+ };
717
951
  const api = { morphChildren: (fromParent, toParent) => {
718
952
  const toNodes = [...toParent.childNodes];
719
953
  const fromKeyed = buildKeyedIndex(fromParent);
@@ -727,19 +961,12 @@ const api = { morphChildren: (fromParent, toParent) => {
727
961
  if (aligned === "continue") continue;
728
962
  fromNode = aligned;
729
963
  }
730
- if (!fromNode) {
731
- fromParent.append(toNode);
732
- continue;
733
- }
734
- if (fromNode.nodeType !== toNode.nodeType) {
735
- fromNode.replaceWith(toNode);
736
- continue;
737
- }
738
- if (fromNode.nodeType === 3 || fromNode.nodeType === 8) {
739
- if (fromNode.nodeValue !== toNode.nodeValue) fromNode.nodeValue = toNode.nodeValue;
740
- continue;
741
- }
742
- if (fromNode.nodeType === 1) morphElementPair(fromNode, toNode, api);
964
+ morphChild({
965
+ api,
966
+ fromNode,
967
+ fromParent,
968
+ toNode
969
+ });
743
970
  }
744
971
  while (fromParent.childNodes.length > toNodes.length) fromParent.lastChild?.remove();
745
972
  } };
@@ -948,14 +1175,28 @@ const isSkippedPropKey = (k) => k === "children" || k === "key" || k === "ref" |
948
1175
  * keyed reconciliation. Host differences live in `PaintOps`.
949
1176
  */
950
1177
  const createPainter = (ops) => {
1178
+ let paintDepth = 0;
1179
+ const pendingRefs = [];
1180
+ const commit = (fn) => {
1181
+ paintDepth += 1;
1182
+ try {
1183
+ fn();
1184
+ } finally {
1185
+ paintDepth -= 1;
1186
+ if (paintDepth === 0) for (const run of pendingRefs.splice(0)) run();
1187
+ }
1188
+ };
951
1189
  const applyFormControl = (el, key, v, fiber) => {
952
- const setProp = (x) => ops.setFormControl(el, key, x);
1190
+ const setProp = (x) => ops.setFormControl(ops.committed(el), key, x);
953
1191
  if (isAtomHandle(v)) {
954
1192
  const memo = el;
955
1193
  if (memo.__ilhaVal === v) return;
956
1194
  memo.__ilhaVal = v;
957
1195
  const unsub = fiber.registry.subscribe(v.atom, setProp, { immediate: true });
958
- fiber.holes.push({ dispose: unsub });
1196
+ fiber.holes.push({ dispose: () => {
1197
+ unsub();
1198
+ if (memo.__ilhaVal === v) memo.__ilhaVal = void 0;
1199
+ } });
959
1200
  } else setProp(v);
960
1201
  };
961
1202
  const trackHole = (parent, hole, extra, meta) => {
@@ -1001,7 +1242,9 @@ const createPainter = (ops) => {
1001
1242
  const { ref } = props;
1002
1243
  if (isFunction(ref)) {
1003
1244
  const refFn = ref;
1004
- refFn(ops.asElement(el));
1245
+ const attach = () => refFn(ops.asElement(ops.committed(el)));
1246
+ if (paintDepth > 0) pendingRefs.push(attach);
1247
+ else attach();
1005
1248
  fiber.holes.push({ dispose: () => refFn(null) });
1006
1249
  }
1007
1250
  };
@@ -1030,7 +1273,7 @@ const createPainter = (ops) => {
1030
1273
  nodes: [host]
1031
1274
  };
1032
1275
  },
1033
- paintHole: (fiber, view) => {
1276
+ paintHole: (fiber, view) => commit(() => {
1034
1277
  if (view === KEEP) return;
1035
1278
  let frame = fiber.islandFrame;
1036
1279
  if (!frame) {
@@ -1051,7 +1294,7 @@ const createPainter = (ops) => {
1051
1294
  }
1052
1295
  cframe.i = 0;
1053
1296
  const list = Array.isArray(view) ? view : null;
1054
- if (list && list.length > 0 && list.every((v) => isVNode(v) && v.key !== null && v.key !== void 0)) {
1297
+ if (list && list.length > 0 && list.every((v) => isVNode(v) && isFunction(v.type) && v.key !== null && v.key !== void 0)) {
1055
1298
  paintFns.keyedPaintHole(fiber, list);
1056
1299
  sweepComponentSlots(fiber);
1057
1300
  sweepIslandSlots(fiber);
@@ -1080,11 +1323,11 @@ const createPainter = (ops) => {
1080
1323
  }
1081
1324
  sweepComponentSlots(fiber);
1082
1325
  sweepIslandSlots(fiber);
1083
- }
1326
+ })
1084
1327
  };
1085
1328
  const materializeAtom = (view, fiber) => {
1086
1329
  const reused = findReusableAtomHost(fiber, view.atom);
1087
- if (reused) return [ops.createSlotHost(reused.host.getAttribute(SLOT_ATTR))];
1330
+ if (reused) return [ops.reuseNode(reused.host)];
1088
1331
  const { fiber: hole, nodes } = api.openHole(fiber);
1089
1332
  const [host] = nodes;
1090
1333
  if (!host) return nodes;
@@ -1133,7 +1376,7 @@ const createPainter = (ops) => {
1133
1376
  const existing = frame.slots[i];
1134
1377
  if (existing && existing.type === type && existing.host.isConnected && isFunction(existing.updateProps)) {
1135
1378
  existing.updateProps(props);
1136
- return [ops.asNode(existing.host)];
1379
+ return [ops.reuseNode(existing.host)];
1137
1380
  }
1138
1381
  if (existing) {
1139
1382
  const idx = fiber.holes.findIndex((h) => h.islandSlot === existing);
@@ -1185,7 +1428,7 @@ const createPainter = (ops) => {
1185
1428
  if (existing.type === type && !hole.closed) {
1186
1429
  hole.propsBox = { current: props };
1187
1430
  runSetup(hole, () => run(props));
1188
- return [ops.asNode(hole.root)];
1431
+ return [ops.reuseNode(hole.root)];
1189
1432
  }
1190
1433
  if (!hole.closed) {
1191
1434
  const idx = fiber.holes.findIndex((h) => h.holeFiber === hole);
@@ -1226,7 +1469,7 @@ const createPainter = (ops) => {
1226
1469
  if (reuse.propsBox) reuse.propsBox.current = props;
1227
1470
  const next = withFiber(fiber, () => run(reuse.propsBox?.current ?? props));
1228
1471
  if (next && !isThenable(next) && !isSetupFn(next)) reuse.paint(next);
1229
- return [ops.asNode(reuse.root)];
1472
+ return [ops.reuseNode(reuse.root)];
1230
1473
  }
1231
1474
  if (!k) return materializeUnkeyedComponent(run, type, props, fiber);
1232
1475
  const { fiber: khole, nodes: knodes } = api.openHole(fiber);
@@ -1302,11 +1545,11 @@ const createPainter = (ops) => {
1302
1545
  nodes.push(...made);
1303
1546
  }
1304
1547
  fiber.keyedHoles = next;
1305
- ops.clearRoot(fiber.root);
1306
- api.insert(fiber, nodes);
1548
+ ops.placeChildren(fiber.root, nodes);
1307
1549
  };
1308
1550
  return {
1309
1551
  applyProps: api.applyProps,
1552
+ commit,
1310
1553
  disposeHoles: (fiber, opts) => disposeHoles(fiber, opts),
1311
1554
  insert: api.insert,
1312
1555
  materialize: api.materialize,
@@ -1318,7 +1561,7 @@ const createPainter = (ops) => {
1318
1561
  //#region src/paint.ts
1319
1562
  const noopDisconnect = () => {};
1320
1563
  const core$1 = createPainter({
1321
- appendRoot: (root, node) => root.append(node),
1564
+ appendRoot: (root, node) => insertRendered(root, node, null),
1322
1565
  asElement: (el) => el,
1323
1566
  asNode: (host) => host,
1324
1567
  asRoot: (el) => el,
@@ -1326,6 +1569,7 @@ const core$1 = createPainter({
1326
1569
  clearRoot: (root) => {
1327
1570
  if (root instanceof Element) root.replaceChildren();
1328
1571
  },
1572
+ committed: committedElement,
1329
1573
  createElement: (tag, ns) => ns ? document.createElementNS(ns, tag) : document.createElement(tag),
1330
1574
  createRaw: (html, parent) => {
1331
1575
  const range = document.createRange();
@@ -1340,6 +1584,10 @@ const core$1 = createPainter({
1340
1584
  },
1341
1585
  createText: (text) => document.createTextNode(text),
1342
1586
  disconnect: noopDisconnect,
1587
+ placeChildren: (root, nodes) => {
1588
+ if (root instanceof Element) placeChildren(root, nodes);
1589
+ },
1590
+ reuseNode: (host) => host instanceof Element && host.isConnected ? standInFor(host) : host,
1343
1591
  setFormControl: (el, key, v) => {
1344
1592
  const node = el;
1345
1593
  if (key === "value") {
@@ -1398,7 +1646,7 @@ const paintHydrate = (fiber, view) => {
1398
1646
  root: el
1399
1647
  };
1400
1648
  el.replaceChildren();
1401
- for (const c of view.children) for (const n of core$1.materialize(c, childFiber)) el.append(n);
1649
+ for (const c of view.children) for (const n of core$1.materialize(c, childFiber)) insertRendered(el, n, null);
1402
1650
  };
1403
1651
  const paintMorphLive = (fiber, view) => {
1404
1652
  if (!fiber.liveEl) return;
@@ -1416,7 +1664,7 @@ const paintMorphLive = (fiber, view) => {
1416
1664
  refreshAllAtomHosts(fiber);
1417
1665
  pruneDisconnectedAtomHoles(fiber);
1418
1666
  };
1419
- const paint$1 = (fiber, view) => {
1667
+ const paintRoot = (fiber, view) => {
1420
1668
  fiber.islandFrame ??= {
1421
1669
  i: 0,
1422
1670
  slots: []
@@ -1447,6 +1695,11 @@ const paint$1 = (fiber, view) => {
1447
1695
  const first = fiber.root instanceof Element ? fiber.root.firstElementChild : null;
1448
1696
  if (first) fiber.liveEl = first;
1449
1697
  };
1698
+ const paint$1 = (fiber, view) => {
1699
+ core$1.commit(() => {
1700
+ paintRoot(fiber, view);
1701
+ });
1702
+ };
1450
1703
  const paintError$1 = (fiber, e) => {
1451
1704
  console.error(e);
1452
1705
  paint$1(fiber, errorView(e));
@@ -1669,6 +1922,7 @@ const ssrOps = {
1669
1922
  asRoot: (el) => ssrAs(el),
1670
1923
  bindEvents: bindSsrEvents,
1671
1924
  clearRoot: (root) => ssrAs(root).replaceChildren(),
1925
+ committed: (el) => el,
1672
1926
  createElement: createSsrElement,
1673
1927
  createRaw: (html, _parent) => [createSsrRaw(html)],
1674
1928
  createSlotHost: (slotId) => {
@@ -1681,6 +1935,12 @@ const ssrOps = {
1681
1935
  disconnect: (el) => {
1682
1936
  el.isConnected = false;
1683
1937
  },
1938
+ placeChildren: (root, nodes) => {
1939
+ const host = ssrAs(root);
1940
+ host.replaceChildren();
1941
+ for (const n of nodes) host.append(n);
1942
+ },
1943
+ reuseNode: (host) => ssrAs(host),
1684
1944
  setFormControl: (el, key, v) => {
1685
1945
  if (key === "value") el.setAttribute("value", String(v ?? ""));
1686
1946
  else if (v) el.setAttribute(key, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.14.8",
3
+ "version": "0.14.10",
4
4
  "description": "Generator-first island UI on Effect",
5
5
  "keywords": [
6
6
  "effect",
@@ -64,10 +64,10 @@
64
64
  "typecheck": "tsc -p tsconfig.types.json --noEmit"
65
65
  },
66
66
  "devDependencies": {
67
- "@happy-dom/global-registrator": "^20.11.2",
68
- "effect": "^4.0.0-rc.118"
67
+ "@happy-dom/global-registrator": "^20.14.5",
68
+ "effect": "^4.0.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "effect": "^4.0.0-rc.118"
71
+ "effect": "^4.0.0"
72
72
  }
73
73
  }