@atolljs/islands 0.1.10 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "htmlparser2": "^12.0.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@atolljs/core": "0.1.10"
22
+ "@atolljs/core": "0.1.12"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  EventPayload,
50
50
  EventRef,
51
51
  IslandWorkerMethods,
52
+ MeasuredRect,
52
53
  Op,
53
54
  WireProps,
54
55
  } from './ops';
package/src/island.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  isEventRef,
37
37
  type EventPayload,
38
38
  type IslandWorkerMethods,
39
+ type MeasuredRect,
39
40
  type Op,
40
41
  type WireProps,
41
42
  } from './ops';
@@ -82,20 +83,40 @@ export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
82
83
  * polling and the page needs no special headers.
83
84
  */
84
85
  doorbell?: boolean;
86
+ /**
87
+ * Default false — when true, the last island to destroy on this client
88
+ * leaves the worker ALIVE but idle: the instance still unmounts (dispose
89
+ * hooks run, timers clear, ops stop, driver state releases) but the
90
+ * thread, its module graph, and any worker-side caches stay resident, so
91
+ * the next `mount` on this client is a warm start. Nothing runs while
92
+ * idle — the instance is gone; only the process survives.
93
+ */
94
+ keepAlive?: boolean;
85
95
  }
86
96
 
97
+ /* Clients opted out of idle-termination by `keepAlive` — a WeakSet so the
98
+ * flag never pins a client that would otherwise collect. */
99
+ const keepAliveClients = new WeakSet<object>();
100
+
101
+ /** True when this client stays resident after its last island destroys. */
102
+ export const isKeepAliveClient = (client: object): boolean => keepAliveClients.has(client);
103
+
87
104
  /** One island client: one dedicated worker, one doorbell buffer. */
88
105
  export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWorkerDefinition>({
89
106
  worker,
90
107
  doorbell,
108
+ keepAlive,
91
109
  ...options
92
- }: ConnectIslandWorkerConfig): WorkerClient<W, DoorbellSpec> =>
93
- connectWorker<W, DoorbellSpec>({
110
+ }: ConnectIslandWorkerConfig): WorkerClient<W, DoorbellSpec> => {
111
+ const client = connectWorker<W, DoorbellSpec>({
94
112
  ...options,
95
113
  sharedMemory: doorbell === false ? undefined : makeDoorbell(),
96
114
  worker,
97
115
  workers: 1,
98
116
  });
117
+ if (keepAlive) keepAliveClients.add(client);
118
+ return client;
119
+ };
99
120
 
100
121
  interface MountIslandBaseOptions {
101
122
  /**
@@ -512,6 +533,69 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
512
533
  /** instance id → slot name, for elements carrying `data-atoll-slot`. */
513
534
  const slotNodes = new Map<number, string>();
514
535
 
536
+ /* ── Per-element measure channel (measure ops → setRects pushes) ──────────
537
+ *
538
+ * Declared before applyOp's first run: a mount batch may already carry
539
+ * `measure` ops (an app calling doc.observeSize / reading geometry during
540
+ * mount), and every reference below is initialized by the time the first
541
+ * op applies. The pushes themselves defer to a macrotask — islandCall is
542
+ * initialized by then regardless of which op batch requested them. The
543
+ * ~100ms throttle matches the container-size channel (leading + trailing). */
544
+ /** ids the worker asked to be measured — RO targets + unmeasure/remove hygiene. */
545
+ const measuredIds = new Set<number>();
546
+ /** instance id → rect awaiting the next setRects flush. */
547
+ const pendingRects = new Map<number, MeasuredRect>();
548
+ /** Shared observer for every measured element — created on first use so
549
+ * islands that never measure pay nothing (and environments without
550
+ * ResizeObserver just get the one-time initial rect per element). */
551
+ let rectObserver: ResizeObserver | null = null;
552
+ let rectTimer: number | null = null;
553
+ let rectLastPush = -Infinity;
554
+
555
+ function flushRects(): void {
556
+ rectTimer = null;
557
+ if (pendingRects.size === 0 || destroyed) return;
558
+ rectLastPush = Date.now();
559
+ const batch = [...pendingRects.values()];
560
+ pendingRects.clear();
561
+ void islandCall('setRects', client.setRects(instance, batch)).then((ops) =>
562
+ applyOps(ops, 'setRects'),
563
+ );
564
+ }
565
+
566
+ function scheduleRectFlush(): void {
567
+ if (rectTimer !== null) return;
568
+ const elapsed = Date.now() - rectLastPush;
569
+ rectTimer = window.setTimeout(flushRects, elapsed >= 100 ? 0 : 100 - elapsed);
570
+ }
571
+
572
+ const queueRect = (id: number, node: Element): void => {
573
+ // getBoundingClientRect (not the RO entry's contentRect) so x/y are
574
+ // viewport-relative — the coordinates getBoundingClientRect reads
575
+ // worker-side must match.
576
+ const r = node.getBoundingClientRect();
577
+ pendingRects.set(id, { id, x: r.x, y: r.y, w: r.width, h: r.height });
578
+ scheduleRectFlush();
579
+ };
580
+
581
+ const ensureRectObserver = (): void => {
582
+ if (rectObserver === null && typeof ResizeObserver === 'function') {
583
+ rectObserver = new ResizeObserver((entries) => {
584
+ for (const entry of entries) {
585
+ const id = nodeIds.get(entry.target as Node);
586
+ if (id !== undefined) queueRect(id, entry.target as Element);
587
+ }
588
+ });
589
+ }
590
+ };
591
+
592
+ const unmeasure = (id: number): void => {
593
+ if (!measuredIds.delete(id)) return;
594
+ pendingRects.delete(id);
595
+ const node = nodes.get(id);
596
+ if (node !== undefined) rectObserver?.unobserve(node as Element);
597
+ };
598
+
515
599
  /** Hand a live element to its slot mount callback (or drop it silently). */
516
600
  const mountSlot = (node: HTMLElement, id: number, name: string): void => {
517
601
  // Always set the attribute — nested-remove detection below finds slot
@@ -549,6 +633,90 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
549
633
  }
550
634
  };
551
635
 
636
+ /* ── Detached-node tombstones ─────────────────────────────────────────────
637
+ * A `remove` op detaches the real node, but the worker may re-append the
638
+ * same id later (renderers reorder siblings: zrender restacks displayables,
639
+ * list libraries shuffle rows, `clear`+`append` remounts reuse their ids).
640
+ * Entries move into `detached` instead of vanishing so a later `append`
641
+ * can restore them; the cap bounds churn from create/remove cycles
642
+ * (tooltip text, transient overlays) that never comes back. Lookups
643
+ * promote on demand via nodeById — an op may reference a tombstoned
644
+ * descendant before its ancestor's re-append lands. */
645
+ const DETACHED_CAP = 1024;
646
+ interface Tombstone {
647
+ node: Node;
648
+ props?: WireProps;
649
+ listeners?: Map<string, EventListener>;
650
+ slot?: string;
651
+ }
652
+ const detached = new Map<number, Tombstone>();
653
+ /** tombstoned node → id — promote walks find subtree ids without nodeIds. */
654
+ const detachedIds = new Map<Node, number>();
655
+
656
+ const tombstoneId = (id: number, node: Node): void => {
657
+ // Drop the RO observation while the live map still resolves the node —
658
+ // after nodes.delete the unobserve would be a silent no-op and the
659
+ // observer would pin the detached element.
660
+ unmeasure(id);
661
+ detachedIds.set(node, id);
662
+ detached.set(id, {
663
+ node,
664
+ props: prevProps.get(id),
665
+ listeners: nodeListeners.get(id),
666
+ // The slot name survives unmountSlot's slotNodes.delete only on the
667
+ // element's marker attribute — read it from the DOM, not the map.
668
+ slot: isElementNode(node) ? (node.getAttribute('data-atoll-slot') ?? undefined) : undefined,
669
+ });
670
+ nodes.delete(id);
671
+ nodeIds.delete(node);
672
+ prevProps.delete(id);
673
+ nodeListeners.delete(id);
674
+ if (detached.size > DETACHED_CAP) {
675
+ // Map order is insertion order — evict the oldest tombstone. A later
676
+ // op referencing an evicted id takes the normal missing-node path.
677
+ const oldest = detached.keys().next().value;
678
+ if (oldest !== undefined) {
679
+ const dead = detached.get(oldest);
680
+ detached.delete(oldest);
681
+ if (dead !== undefined) detachedIds.delete(dead.node);
682
+ }
683
+ }
684
+ };
685
+
686
+ /** Move a node AND its whole subtree into tombstones. */
687
+ const tombstoneSubtree = (rootId: number, root: Node): void => {
688
+ const walk = (node: Node): void => {
689
+ const id = node === root ? rootId : nodeIds.get(node);
690
+ if (id !== undefined && id !== 0) tombstoneId(id, node);
691
+ for (const child of Array.from(node.childNodes)) walk(child);
692
+ };
693
+ walk(root);
694
+ };
695
+
696
+ /** Restore a tombstoned node + descendants (re-append after remove). */
697
+ const promoteSubtree = (id: number): Node | undefined => {
698
+ const tomb = detached.get(id);
699
+ if (tomb === undefined) return nodes.get(id);
700
+ detached.delete(id);
701
+ detachedIds.delete(tomb.node);
702
+ nodes.set(id, tomb.node);
703
+ nodeIds.set(tomb.node, id);
704
+ if (tomb.props !== undefined) prevProps.set(id, tomb.props);
705
+ if (tomb.listeners !== undefined) nodeListeners.set(id, tomb.listeners);
706
+ if (tomb.slot !== undefined) {
707
+ slotNodes.set(id, tomb.slot);
708
+ opts.slots?.[tomb.slot]?.(tomb.node as HTMLElement);
709
+ }
710
+ for (const child of Array.from(tomb.node.childNodes)) {
711
+ const cid = detachedIds.get(child);
712
+ if (cid !== undefined) promoteSubtree(cid);
713
+ }
714
+ return tomb.node;
715
+ };
716
+
717
+ /** nodes.get that promotes tombstoned entries on demand. */
718
+ const nodeById = (id: number): Node | undefined => nodes.get(id) ?? promoteSubtree(id);
719
+
552
720
  let opsApplied = 0;
553
721
  let flushCalls = 0;
554
722
  let mode: Mode = initialMode ?? 'push';
@@ -556,8 +724,42 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
556
724
  let pollTimer: number | null = null;
557
725
  let destroyed = false;
558
726
 
727
+ /* ── Dispatch queue ───────────────────────────────────────────────────────
728
+ * Every real event used to fire its own postMessage the moment it landed:
729
+ * a pointer sweep queues round-trips faster than the worker drains them,
730
+ * and each in-flight call pins its payload + the island's maps. The queue
731
+ * preserves DOM order while bounding in-flight calls. Move-type events
732
+ * coalesce — a queued move superseded by a newer one for the same
733
+ * (handler, type) is dropped before it hits the wire; only the freshest
734
+ * position matters for pointer tracking. Boundary events (out/over/down/
735
+ * up/wheel) never merge and never overtake a queued move, so enter/leave
736
+ * edges stay ordered after the moves they coalesce past. */
737
+ const COALESCE_EVENTS = new Set(['mousemove', 'pointermove', 'touchmove', 'drag', 'dragover']);
738
+ const MAX_IN_FLIGHT_DISPATCH = 4;
739
+ /* How long destroy() waits for the worker's unmount batch before force-
740
+ * tearing down. Generous enough for a busy worker draining real tasks;
741
+ * short enough that a wedged worker is visibly gone soon. */
742
+ const UNMOUNT_GRACE_MS = 1500;
743
+ const dispatchQueue: Array<{ handlerId: number; key: string | null; payload: EventPayload }> = [];
744
+ let inFlightDispatches = 0;
745
+
746
+ const pumpDispatches = (): void => {
747
+ while (!destroyed && inFlightDispatches < MAX_IN_FLIGHT_DISPATCH && dispatchQueue.length > 0) {
748
+ const next = dispatchQueue.shift()!;
749
+ inFlightDispatches++;
750
+ void islandCall('dispatch', client.dispatch(next.handlerId, next.payload))
751
+ .then((ops) => applyOps(ops, 'dispatch'))
752
+ .catch((err) => console.error(`[island ${instance}] dispatch failed`, err))
753
+ .finally(() => {
754
+ inFlightDispatches--;
755
+ pumpDispatches();
756
+ });
757
+ }
758
+ };
759
+
559
760
  function listenerFor(handlerId: number): EventListener {
560
761
  return (e: Event) => {
762
+ if (destroyed) return;
561
763
  const target = e.target;
562
764
  const payload = buildEventPayload(
563
765
  e,
@@ -565,11 +767,13 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
565
767
  target !== null ? nodeIds.get(target as Node) : undefined,
566
768
  nodeIds.get((e as MouseEvent).relatedTarget as Node),
567
769
  );
568
- // The whole point: an event = one postMessage round-trip. The worker
569
- // re-renders, we apply whatever ops come back.
570
- void islandCall('dispatch', client.dispatch(handlerId, payload))
571
- .then((ops) => applyOps(ops, 'dispatch'))
572
- .catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
770
+ const key = COALESCE_EVENTS.has(e.type) ? `${handlerId}:${e.type}` : null;
771
+ if (key !== null) {
772
+ const stale = dispatchQueue.findIndex((q) => q.key === key);
773
+ if (stale !== -1) dispatchQueue.splice(stale, 1);
774
+ }
775
+ dispatchQueue.push({ handlerId, key, payload });
776
+ pumpDispatches();
573
777
  };
574
778
  }
575
779
 
@@ -742,6 +946,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
742
946
  function mergeStyle(node: Element, id: number, changes: Record<string, string>): void {
743
947
  const elStyle = (node as HTMLElement).style;
744
948
  const elStyleMap = elStyle as unknown as Record<string, string>;
949
+ // Real DOM keeps the (now-empty) style attribute after the last property
950
+ // clears; happy-dom drops it. Preserve the distinction a never-styled
951
+ // element has — absence only when the attribute never existed.
952
+ const hadStyleAttr = node.hasAttribute('style');
745
953
  const prev = prevProps.get(id) ?? {};
746
954
  const merged = { ...((prev.style ?? {}) as Record<string, string>) };
747
955
  for (const [k, v] of Object.entries(changes)) {
@@ -760,6 +968,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
760
968
  }
761
969
  merged[k] = v;
762
970
  }
971
+ if (hadStyleAttr && !node.hasAttribute('style')) node.setAttribute('style', '');
763
972
  prev.style = merged;
764
973
  prevProps.set(id, prev);
765
974
  }
@@ -794,8 +1003,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
794
1003
  break;
795
1004
  }
796
1005
  case 'append': {
797
- let parent = nodes.get(op.parent);
798
- const child = nodes.get(op.child);
1006
+ let parent = nodeById(op.parent);
1007
+ const child = nodeById(op.child);
799
1008
  if (!parent || !child) {
800
1009
  console.error(`[island] append skipped: parent=${op.parent}→${String(parent)} child=${op.child}→${String(child)}`);
801
1010
  break;
@@ -807,18 +1016,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
807
1016
  if (isElementNode(parent) && parent.tagName === 'TEMPLATE') {
808
1017
  parent = (parent as HTMLTemplateElement).content;
809
1018
  }
810
- const before = op.before !== undefined ? (nodes.get(op.before) ?? null) : null;
1019
+ const before = op.before !== undefined ? (nodeById(op.before) ?? null) : null;
811
1020
  parent.insertBefore(child, before);
812
1021
  break;
813
1022
  }
814
1023
  case 'remove': {
815
- const child = nodes.get(op.child);
816
- if (child !== undefined) unmountSlotSubtree(child);
1024
+ const child = nodeById(op.child);
1025
+ if (child !== undefined) {
1026
+ unmountSlotSubtree(child);
1027
+ // unmeasure + every map eviction happens inside tombstoneSubtree:
1028
+ // descendants get no remove op of their own, so the walk is the
1029
+ // only place they can leave nodes/prevProps/nodeListeners.
1030
+ tombstoneSubtree(op.child, child);
1031
+ }
817
1032
  child?.parentNode?.removeChild(child);
818
1033
  break;
819
1034
  }
820
1035
  case 'update': {
821
- const node = nodes.get(op.id);
1036
+ const node = nodeById(op.id);
822
1037
  if (!isElementNode(node)) break;
823
1038
  const prev = prevProps.get(op.id) ?? {};
824
1039
  applyProps(node, op.id, prev, op.props);
@@ -826,24 +1041,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
826
1041
  break;
827
1042
  }
828
1043
  case 'utext': {
829
- const node = nodes.get(op.id);
1044
+ const node = nodeById(op.id);
830
1045
  if (node) node.textContent = op.text;
831
1046
  break;
832
1047
  }
833
1048
  case 'attr': {
834
- const node = nodes.get(op.id);
1049
+ const node = nodeById(op.id);
835
1050
  if (!isElementNode(node)) break;
836
1051
  setAttr(node, op.id, op.name, op.value);
837
1052
  break;
838
1053
  }
839
1054
  case 'style': {
840
- const node = nodes.get(op.id);
1055
+ const node = nodeById(op.id);
841
1056
  if (!isElementNode(node)) break;
842
1057
  mergeStyle(node, op.id, op.props);
843
1058
  break;
844
1059
  }
845
1060
  case 'listen': {
846
- const node = nodes.get(op.id);
1061
+ const node = nodeById(op.id);
847
1062
  if (!isElementNode(node)) break;
848
1063
  // Keyed by type + handler id so proxy listeners can't collide with
849
1064
  // the React-prop listener for the same event name, and so unlisten
@@ -861,7 +1076,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
861
1076
  break;
862
1077
  }
863
1078
  case 'unlisten': {
864
- const node = nodes.get(op.id);
1079
+ const node = nodeById(op.id);
865
1080
  if (!isElementNode(node)) break;
866
1081
  const key = `${op.type}#${op.handler}`;
867
1082
  const listener = nodeListeners.get(op.id)?.get(key);
@@ -881,6 +1096,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
881
1096
  // like a `remove` op would so slots callbacks see null and
882
1097
  // slotNodes doesn't leak.
883
1098
  unmountSlotSubtree(el);
1099
+ // Children detach with their ids tombstoned — a remount's following
1100
+ // `append` re-promotes them rather than leaking map entries.
1101
+ for (const child of Array.from(el.childNodes)) {
1102
+ const id = nodeIds.get(child);
1103
+ if (id !== undefined) tombstoneSubtree(id, child);
1104
+ }
884
1105
  el.replaceChildren();
885
1106
  break;
886
1107
  }
@@ -901,10 +1122,26 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
901
1122
  onEvent?.(op.name, op.payload);
902
1123
  break;
903
1124
  }
1125
+ case 'measure': {
1126
+ const node = nodeById(op.id);
1127
+ if (!isElementNode(node)) break;
1128
+ measuredIds.add(op.id);
1129
+ ensureRectObserver();
1130
+ rectObserver?.observe(node);
1131
+ // The initial rect ships immediately — ResizeObserver only ticks on
1132
+ // later resizes (and on mount for a detached-then-appended node),
1133
+ // so without it a read-heavy mount would wait a frame for nothing.
1134
+ queueRect(op.id, node);
1135
+ break;
1136
+ }
1137
+ case 'unmeasure': {
1138
+ unmeasure(op.id);
1139
+ break;
1140
+ }
904
1141
  }
905
1142
  }
906
1143
 
907
- function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
1144
+ function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'setRects' | 'flush' | 'unmount'): void {
908
1145
  opsApplied += ops.length;
909
1146
  onActivity?.();
910
1147
  const t0 = performance.now();
@@ -923,7 +1160,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
923
1160
 
924
1161
  /** Times a main→worker round-trip for the devtools inspector. */
925
1162
  const islandCall = <T>(
926
- method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
1163
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
927
1164
  p: Promise<T>,
928
1165
  ): Promise<T> => {
929
1166
  const t0 = performance.now();
@@ -1101,32 +1338,47 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1101
1338
  emitDevtools({ type: 'island:unmount', instance });
1102
1339
  unsubscribe?.();
1103
1340
  resizeObserver?.disconnect();
1341
+ rectObserver?.disconnect();
1104
1342
  if (pollTimer !== null) clearInterval(pollTimer);
1105
1343
  if (sizeTimer !== null) clearTimeout(sizeTimer);
1344
+ if (rectTimer !== null) clearTimeout(rectTimer);
1345
+ pendingRects.clear();
1346
+ measuredIds.clear();
1106
1347
  const remaining = (clientMounts.get(client) ?? 1) - 1;
1107
1348
  clientMounts.set(client, remaining);
1108
- if (remaining > 0) {
1109
- // The worker still serves sibling mounts — hand this one back. The
1110
- // detach ops are irrelevant if our element is already gone, so the
1111
- // return batch is dropped. Best-effort: the instance map and its
1112
- // handlers release regardless.
1113
- void islandCall('unmount', client.unmount(instance)).catch(() => {});
1114
- } else {
1115
- // Last island — still run the instance's teardown first so the app's
1116
- // dispose hook cancels deferred work (library timers, animation
1117
- // loops) and the proxy doc unwinds. In a real worker terminate may
1118
- // kill the thread before unmount lands — equally fine, process
1119
- // death is teardown — but in-process workers need this or the
1120
- // instance zombies on and its pending callbacks mutate foreign docs.
1121
- void islandCall('unmount', client.unmount(instance))
1122
- .catch(() => {})
1123
- .finally(() => {
1124
- // A remount may have registered while teardown was in flight
1125
- // (app/client input swap does destroy → mount back-to-back) —
1126
- // terminate only when the client is still truly idle.
1127
- if ((clientMounts.get(client) ?? 0) === 0) client.terminate();
1128
- });
1129
- }
1349
+ /* One bounded teardown for both cases. The worker's unmount batch is
1350
+ * still awaited so the app's dispose hook cancels deferred work and
1351
+ * the `clear` op can fire slots[name](null) for slot anchors — but
1352
+ * the wait is capped by UNMOUNT_GRACE_MS. A wedged worker (a task
1353
+ * that never settles) must not pin the worker, this island's driver
1354
+ * state, or the pending-call closures forever. finish() releases the
1355
+ * maps eagerly so ops arriving late replay against empty maps (all
1356
+ * lookups miss → no-ops), and on the last mount it terminates the
1357
+ * worker unconditionally — as long as no remount registered while
1358
+ * teardown was in flight (app/client swap does destroy → mount
1359
+ * back-to-back; terminate only when the client is truly idle). */
1360
+ let finished = false;
1361
+ const finish = () => {
1362
+ if (finished) return;
1363
+ finished = true;
1364
+ nodes.clear();
1365
+ detached.clear();
1366
+ detachedIds.clear();
1367
+ prevProps.clear();
1368
+ nodeListeners.clear();
1369
+ cbTable.clear();
1370
+ slotNodes.clear();
1371
+ dispatchQueue.length = 0;
1372
+ // keepAlive clients stay resident — the instance is unmounted but
1373
+ // the thread idles so the next mount is a warm start.
1374
+ if (remaining <= 0 && !isKeepAliveClient(client) && (clientMounts.get(client) ?? 0) === 0)
1375
+ client.terminate();
1376
+ };
1377
+ void islandCall('unmount', client.unmount(instance))
1378
+ .then((ops) => applyOps(ops, 'unmount'))
1379
+ .catch(() => {})
1380
+ .finally(finish);
1381
+ setTimeout(finish, UNMOUNT_GRACE_MS);
1130
1382
  },
1131
1383
  };
1132
1384
  // Dashboard commands (`island.*`) — registered only while devtools is on.
package/src/ops.ts CHANGED
@@ -84,7 +84,39 @@ export type Op =
84
84
  * `onEvent(name, payload)` instead of mutating the DOM. Payloads must be
85
85
  * structured-cloneable — same rule as props.
86
86
  */
87
- | { t: 'emit'; name: string; payload?: unknown };
87
+ | { t: 'emit'; name: string; payload?: unknown }
88
+ /**
89
+ * Request real geometry for `id` — the worker-side proxy DOM emits this
90
+ * when `doc.observeSize(el, cb)` registers (and once, lazily, when an
91
+ * unmarked element's geometry is read). The driver measures the real
92
+ * node, observes it with a ResizeObserver, and pushes rects back through
93
+ * the `setRects` task. Fire-and-forget: reads keep returning the honest 0
94
+ * until the first push lands. Older drivers ignore the op; the reads stay
95
+ * 0 — same degradation as an element that never gets measured.
96
+ */
97
+ | { t: 'measure'; id: number }
98
+ /**
99
+ * Cancel a `measure` request — emitted when the last `observeSize`
100
+ * observer for `id` detaches. Elements auto-requested by a geometry read
101
+ * stay observed for the element's lifetime (the driver also unobserves
102
+ * on `remove`/`clear`).
103
+ */
104
+ | { t: 'unmeasure'; id: number };
105
+
106
+ /**
107
+ * One measured box, pushed main→worker by the `setRects` task. `x`/`y` are
108
+ * the node's viewport-relative position (getBoundingClientRect), `w`/`h`
109
+ * its size — worker-side reads derive top/left/right/bottom from these.
110
+ * Updates fire on element RESIZE (the driver's ResizeObserver); position-only
111
+ * moves don't retrigger, matching what chart/map libraries actually consume.
112
+ */
113
+ export interface MeasuredRect {
114
+ id: number;
115
+ x: number;
116
+ y: number;
117
+ w: number;
118
+ h: number;
119
+ }
88
120
 
89
121
  /**
90
122
  * Wire-serialized props. Functions named `on*` (onClick, onInput, …) cross as
@@ -220,16 +252,25 @@ export type IslandWorkerMethods = {
220
252
  /** Invoke the worker handler a `__evt` ref or `listen` op points at. */
221
253
  dispatch(handlerId: number, payload: EventPayload): Op[];
222
254
  /**
223
- * Push the island container's measured box into the instance — the ONLY
224
- * geometry channel that exists. The driver observes the island's `el`
225
- * with a ResizeObserver and calls this once at mount and on resizes
226
- * (throttled ~100ms). The instance stores {w,h}; proxy-DOM geometry reads on
227
- * `doc.body`/`documentElement` and on elements marked via
228
- * `doc.markContainer(el)` then return it — deeper elements keep the
229
- * honest 0. Handlers registered via `doc.onResize` re-run on each push;
230
- * their ops ride back in this method's return batch.
255
+ * Push the island container's measured box into the instance — the
256
+ * always-on geometry channel (per-element rects additionally flow through
257
+ * `setRects` for elements that requested measurement). The driver observes
258
+ * the island's `el` with a ResizeObserver and calls this once at mount and
259
+ * on resizes (throttled ~100ms). The instance stores {w,h}; proxy-DOM
260
+ * geometry reads on `doc.body`/`documentElement` and on elements marked via
261
+ * `doc.markContainer(el)` then return it. Handlers registered via
262
+ * `doc.onResize` re-run on each push; their ops ride back in this method's
263
+ * return batch.
231
264
  */
232
265
  setSize(instance: string, width: number, height: number): Op[];
266
+ /**
267
+ * Push freshly measured element rects — the answer to `measure` ops and
268
+ * the driver's per-element ResizeObserver ticks. Each rect updates the
269
+ * instance's per-element store and fires the element's `observeSize`
270
+ * callbacks inside the instance's scope, so their ops ride back in this
271
+ * method's return batch like `setSize`'s onResize handlers.
272
+ */
273
+ setRects(instance: string, rects: MeasuredRect[]): Op[];
233
274
  /** Drain ops committed outside a task (passive effects, timers). */
234
275
  flush(instance: string): Op[];
235
276
  /**
@@ -79,9 +79,11 @@ import {
79
79
  setDoorbellContract,
80
80
  setInstanceContract,
81
81
  setInstanceSize,
82
+ setInstanceRect,
83
+ clearInstanceRects,
82
84
  takeOps,
83
85
  } from './instance';
84
- import type { EventPayload, IslandWorkerMethods, Op } from '../ops';
86
+ import type { EventPayload, IslandWorkerMethods, MeasuredRect, Op } from '../ops';
85
87
 
86
88
  /**
87
89
  * Registry value shapes:
@@ -573,6 +575,31 @@ function createIslandRuntime(
573
575
  });
574
576
  },
575
577
 
578
+ /**
579
+ * The measured-element channel: rects the driver pushed in answer to
580
+ * `measure` ops. Stored in the instance-level map FIRST so geometry
581
+ * reads answer even for mounts without a materialized proxy document
582
+ * (a React element adopted later sees its rect immediately); then
583
+ * per-element observeSize callbacks fire inside the instance's scope,
584
+ * their ops riding back in the batch like onResize handlers.
585
+ */
586
+ setRects(instance: string, rects: MeasuredRect[]): Op[] {
587
+ const mounted = mounts.get(instance);
588
+ const doc =
589
+ mounted !== undefined && isImperativeInstance(mounted)
590
+ ? mounted.imperative.doc
591
+ : peekInstanceDoc(instance);
592
+ if (doc === undefined) {
593
+ // No doc yet — store rects raw so adopted elements still answer.
594
+ for (const rect of rects) setInstanceRect(instance, rect);
595
+ return takeOps(instance);
596
+ }
597
+ return runInInstance(mounted?.key ?? instance, () => {
598
+ (doc as InternalDocument)._notifyRects(rects);
599
+ return takeOps(instance);
600
+ });
601
+ },
602
+
576
603
  /**
577
604
  * Tear down ONE instance while the worker keeps serving its others —
578
605
  * the multi-island-per-worker counterpart to process death: disposes
@@ -585,6 +612,7 @@ function createIslandRuntime(
585
612
  if (mounted === undefined) return [];
586
613
  mounts.delete(instance);
587
614
  setInstanceContract(instance, undefined);
615
+ clearInstanceRects(instance);
588
616
  return runInInstance(instance, () => {
589
617
  if (isImperativeInstance(mounted)) {
590
618
  // The app's dispose hook cancels deferred work (library timers,
@@ -89,6 +89,7 @@ export function styleProxyFor(el: ProxyElement): CSSStyleDeclaration {
89
89
  };
90
90
  if (prop === 'cssText') {
91
91
  return Object.entries(t)
92
+ .filter(([, v]) => v !== '')
92
93
  .map(([k, v]) => `${hyphenate(k)}: ${v};`)
93
94
  .join(' ');
94
95
  }
@@ -96,7 +97,8 @@ export function styleProxyFor(el: ProxyElement): CSSStyleDeclaration {
96
97
  return undefined;
97
98
  },
98
99
  set: (_t, prop, v) => {
99
- if (typeof prop === 'string') el._writeStyle(prop, String(v));
100
+ if (prop === 'cssText') el._replaceStyleText(String(v));
101
+ else if (typeof prop === 'string') el._writeStyle(prop, String(v));
100
102
  return true;
101
103
  },
102
104
  deleteProperty: (_t, prop) => {