@atolljs/islands 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
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.9"
22
+ "@atolljs/core": "0.1.11"
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';
@@ -227,6 +228,7 @@ function buildEventPayload(
227
228
  e: Event,
228
229
  target: EventTarget | null,
229
230
  targetId: number | undefined,
231
+ relatedTargetId?: number,
230
232
  ): EventPayload {
231
233
  const input = target as HTMLInputElement | null;
232
234
  const mouse = e as MouseEvent;
@@ -243,6 +245,18 @@ function buildEventPayload(
243
245
  const coord = (v: unknown): number | undefined =>
244
246
  num(v) ?? (pointerFam ? 0 : undefined);
245
247
  const button = num(mouse.button) ?? (pointerFam ? 0 : undefined);
248
+ // currentTarget is the element this listener was attached to — the exact
249
+ // element hit-testing libraries normalize pointer coords against
250
+ // (zrender's `normalizeEvent(this.dom, e)`). Checked structurally, not
251
+ // via `instanceof Element`: in in-process embeddings the worker-side
252
+ // dispatcher may point the global at ProxyElement.
253
+ const ct = e.currentTarget as Element | null;
254
+ const localRect =
255
+ ct && typeof ct.getBoundingClientRect === 'function'
256
+ ? ct.getBoundingClientRect()
257
+ : null;
258
+ const local = (v: number | undefined, origin: number): number | undefined =>
259
+ localRect && typeof v === 'number' ? v - origin : undefined;
246
260
  return {
247
261
  type: e.type,
248
262
  value: input && 'value' in input ? input.value : undefined,
@@ -252,6 +266,8 @@ function buildEventPayload(
252
266
  clientY: coord(mouse.clientY),
253
267
  screenX: coord(mouse.screenX),
254
268
  screenY: coord(mouse.screenY),
269
+ zrX: local(mouse.clientX, localRect?.left ?? 0),
270
+ zrY: local(mouse.clientY, localRect?.top ?? 0),
255
271
  button,
256
272
  which:
257
273
  typeof mouse.which === 'number' && mouse.which !== 0
@@ -273,6 +289,7 @@ function buildEventPayload(
273
289
  pointerType: typeof pointer.pointerType === 'string' ? pointer.pointerType : undefined,
274
290
  scrollTop: input instanceof HTMLElement ? input.scrollTop : undefined,
275
291
  targetId,
292
+ relatedTargetId,
276
293
  };
277
294
  }
278
295
 
@@ -496,6 +513,69 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
496
513
  /** instance id → slot name, for elements carrying `data-atoll-slot`. */
497
514
  const slotNodes = new Map<number, string>();
498
515
 
516
+ /* ── Per-element measure channel (measure ops → setRects pushes) ──────────
517
+ *
518
+ * Declared before applyOp's first run: a mount batch may already carry
519
+ * `measure` ops (an app calling doc.observeSize / reading geometry during
520
+ * mount), and every reference below is initialized by the time the first
521
+ * op applies. The pushes themselves defer to a macrotask — islandCall is
522
+ * initialized by then regardless of which op batch requested them. The
523
+ * ~100ms throttle matches the container-size channel (leading + trailing). */
524
+ /** ids the worker asked to be measured — RO targets + unmeasure/remove hygiene. */
525
+ const measuredIds = new Set<number>();
526
+ /** instance id → rect awaiting the next setRects flush. */
527
+ const pendingRects = new Map<number, MeasuredRect>();
528
+ /** Shared observer for every measured element — created on first use so
529
+ * islands that never measure pay nothing (and environments without
530
+ * ResizeObserver just get the one-time initial rect per element). */
531
+ let rectObserver: ResizeObserver | null = null;
532
+ let rectTimer: number | null = null;
533
+ let rectLastPush = -Infinity;
534
+
535
+ function flushRects(): void {
536
+ rectTimer = null;
537
+ if (pendingRects.size === 0 || destroyed) return;
538
+ rectLastPush = Date.now();
539
+ const batch = [...pendingRects.values()];
540
+ pendingRects.clear();
541
+ void islandCall('setRects', client.setRects(instance, batch)).then((ops) =>
542
+ applyOps(ops, 'setRects'),
543
+ );
544
+ }
545
+
546
+ function scheduleRectFlush(): void {
547
+ if (rectTimer !== null) return;
548
+ const elapsed = Date.now() - rectLastPush;
549
+ rectTimer = window.setTimeout(flushRects, elapsed >= 100 ? 0 : 100 - elapsed);
550
+ }
551
+
552
+ const queueRect = (id: number, node: Element): void => {
553
+ // getBoundingClientRect (not the RO entry's contentRect) so x/y are
554
+ // viewport-relative — the coordinates getBoundingClientRect reads
555
+ // worker-side must match.
556
+ const r = node.getBoundingClientRect();
557
+ pendingRects.set(id, { id, x: r.x, y: r.y, w: r.width, h: r.height });
558
+ scheduleRectFlush();
559
+ };
560
+
561
+ const ensureRectObserver = (): void => {
562
+ if (rectObserver === null && typeof ResizeObserver === 'function') {
563
+ rectObserver = new ResizeObserver((entries) => {
564
+ for (const entry of entries) {
565
+ const id = nodeIds.get(entry.target as Node);
566
+ if (id !== undefined) queueRect(id, entry.target as Element);
567
+ }
568
+ });
569
+ }
570
+ };
571
+
572
+ const unmeasure = (id: number): void => {
573
+ if (!measuredIds.delete(id)) return;
574
+ pendingRects.delete(id);
575
+ const node = nodes.get(id);
576
+ if (node !== undefined) rectObserver?.unobserve(node as Element);
577
+ };
578
+
499
579
  /** Hand a live element to its slot mount callback (or drop it silently). */
500
580
  const mountSlot = (node: HTMLElement, id: number, name: string): void => {
501
581
  // Always set the attribute — nested-remove detection below finds slot
@@ -533,6 +613,90 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
533
613
  }
534
614
  };
535
615
 
616
+ /* ── Detached-node tombstones ─────────────────────────────────────────────
617
+ * A `remove` op detaches the real node, but the worker may re-append the
618
+ * same id later (renderers reorder siblings: zrender restacks displayables,
619
+ * list libraries shuffle rows, `clear`+`append` remounts reuse their ids).
620
+ * Entries move into `detached` instead of vanishing so a later `append`
621
+ * can restore them; the cap bounds churn from create/remove cycles
622
+ * (tooltip text, transient overlays) that never comes back. Lookups
623
+ * promote on demand via nodeById — an op may reference a tombstoned
624
+ * descendant before its ancestor's re-append lands. */
625
+ const DETACHED_CAP = 1024;
626
+ interface Tombstone {
627
+ node: Node;
628
+ props?: WireProps;
629
+ listeners?: Map<string, EventListener>;
630
+ slot?: string;
631
+ }
632
+ const detached = new Map<number, Tombstone>();
633
+ /** tombstoned node → id — promote walks find subtree ids without nodeIds. */
634
+ const detachedIds = new Map<Node, number>();
635
+
636
+ const tombstoneId = (id: number, node: Node): void => {
637
+ // Drop the RO observation while the live map still resolves the node —
638
+ // after nodes.delete the unobserve would be a silent no-op and the
639
+ // observer would pin the detached element.
640
+ unmeasure(id);
641
+ detachedIds.set(node, id);
642
+ detached.set(id, {
643
+ node,
644
+ props: prevProps.get(id),
645
+ listeners: nodeListeners.get(id),
646
+ // The slot name survives unmountSlot's slotNodes.delete only on the
647
+ // element's marker attribute — read it from the DOM, not the map.
648
+ slot: isElementNode(node) ? (node.getAttribute('data-atoll-slot') ?? undefined) : undefined,
649
+ });
650
+ nodes.delete(id);
651
+ nodeIds.delete(node);
652
+ prevProps.delete(id);
653
+ nodeListeners.delete(id);
654
+ if (detached.size > DETACHED_CAP) {
655
+ // Map order is insertion order — evict the oldest tombstone. A later
656
+ // op referencing an evicted id takes the normal missing-node path.
657
+ const oldest = detached.keys().next().value;
658
+ if (oldest !== undefined) {
659
+ const dead = detached.get(oldest);
660
+ detached.delete(oldest);
661
+ if (dead !== undefined) detachedIds.delete(dead.node);
662
+ }
663
+ }
664
+ };
665
+
666
+ /** Move a node AND its whole subtree into tombstones. */
667
+ const tombstoneSubtree = (rootId: number, root: Node): void => {
668
+ const walk = (node: Node): void => {
669
+ const id = node === root ? rootId : nodeIds.get(node);
670
+ if (id !== undefined && id !== 0) tombstoneId(id, node);
671
+ for (const child of Array.from(node.childNodes)) walk(child);
672
+ };
673
+ walk(root);
674
+ };
675
+
676
+ /** Restore a tombstoned node + descendants (re-append after remove). */
677
+ const promoteSubtree = (id: number): Node | undefined => {
678
+ const tomb = detached.get(id);
679
+ if (tomb === undefined) return nodes.get(id);
680
+ detached.delete(id);
681
+ detachedIds.delete(tomb.node);
682
+ nodes.set(id, tomb.node);
683
+ nodeIds.set(tomb.node, id);
684
+ if (tomb.props !== undefined) prevProps.set(id, tomb.props);
685
+ if (tomb.listeners !== undefined) nodeListeners.set(id, tomb.listeners);
686
+ if (tomb.slot !== undefined) {
687
+ slotNodes.set(id, tomb.slot);
688
+ opts.slots?.[tomb.slot]?.(tomb.node as HTMLElement);
689
+ }
690
+ for (const child of Array.from(tomb.node.childNodes)) {
691
+ const cid = detachedIds.get(child);
692
+ if (cid !== undefined) promoteSubtree(cid);
693
+ }
694
+ return tomb.node;
695
+ };
696
+
697
+ /** nodes.get that promotes tombstoned entries on demand. */
698
+ const nodeById = (id: number): Node | undefined => nodes.get(id) ?? promoteSubtree(id);
699
+
536
700
  let opsApplied = 0;
537
701
  let flushCalls = 0;
538
702
  let mode: Mode = initialMode ?? 'push';
@@ -540,19 +704,52 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
540
704
  let pollTimer: number | null = null;
541
705
  let destroyed = false;
542
706
 
707
+ /* ── Dispatch queue ───────────────────────────────────────────────────────
708
+ * Every real event used to fire its own postMessage the moment it landed:
709
+ * a pointer sweep queues round-trips faster than the worker drains them,
710
+ * and each in-flight call pins its payload + the island's maps. The queue
711
+ * preserves DOM order while bounding in-flight calls. Move-type events
712
+ * coalesce — a queued move superseded by a newer one for the same
713
+ * (handler, type) is dropped before it hits the wire; only the freshest
714
+ * position matters for pointer tracking. Boundary events (out/over/down/
715
+ * up/wheel) never merge and never overtake a queued move, so enter/leave
716
+ * edges stay ordered after the moves they coalesce past. */
717
+ const COALESCE_EVENTS = new Set(['mousemove', 'pointermove', 'touchmove', 'drag', 'dragover']);
718
+ const MAX_IN_FLIGHT_DISPATCH = 4;
719
+ const dispatchQueue: Array<{ handlerId: number; key: string | null; payload: EventPayload }> = [];
720
+ let inFlightDispatches = 0;
721
+
722
+ const pumpDispatches = (): void => {
723
+ while (!destroyed && inFlightDispatches < MAX_IN_FLIGHT_DISPATCH && dispatchQueue.length > 0) {
724
+ const next = dispatchQueue.shift()!;
725
+ inFlightDispatches++;
726
+ void islandCall('dispatch', client.dispatch(next.handlerId, next.payload))
727
+ .then((ops) => applyOps(ops, 'dispatch'))
728
+ .catch((err) => console.error(`[island ${instance}] dispatch failed`, err))
729
+ .finally(() => {
730
+ inFlightDispatches--;
731
+ pumpDispatches();
732
+ });
733
+ }
734
+ };
735
+
543
736
  function listenerFor(handlerId: number): EventListener {
544
737
  return (e: Event) => {
738
+ if (destroyed) return;
545
739
  const target = e.target;
546
740
  const payload = buildEventPayload(
547
741
  e,
548
742
  target,
549
743
  target !== null ? nodeIds.get(target as Node) : undefined,
744
+ nodeIds.get((e as MouseEvent).relatedTarget as Node),
550
745
  );
551
- // The whole point: an event = one postMessage round-trip. The worker
552
- // re-renders, we apply whatever ops come back.
553
- void islandCall('dispatch', client.dispatch(handlerId, payload))
554
- .then((ops) => applyOps(ops, 'dispatch'))
555
- .catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
746
+ const key = COALESCE_EVENTS.has(e.type) ? `${handlerId}:${e.type}` : null;
747
+ if (key !== null) {
748
+ const stale = dispatchQueue.findIndex((q) => q.key === key);
749
+ if (stale !== -1) dispatchQueue.splice(stale, 1);
750
+ }
751
+ dispatchQueue.push({ handlerId, key, payload });
752
+ pumpDispatches();
556
753
  };
557
754
  }
558
755
 
@@ -725,6 +922,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
725
922
  function mergeStyle(node: Element, id: number, changes: Record<string, string>): void {
726
923
  const elStyle = (node as HTMLElement).style;
727
924
  const elStyleMap = elStyle as unknown as Record<string, string>;
925
+ // Real DOM keeps the (now-empty) style attribute after the last property
926
+ // clears; happy-dom drops it. Preserve the distinction a never-styled
927
+ // element has — absence only when the attribute never existed.
928
+ const hadStyleAttr = node.hasAttribute('style');
728
929
  const prev = prevProps.get(id) ?? {};
729
930
  const merged = { ...((prev.style ?? {}) as Record<string, string>) };
730
931
  for (const [k, v] of Object.entries(changes)) {
@@ -743,6 +944,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
743
944
  }
744
945
  merged[k] = v;
745
946
  }
947
+ if (hadStyleAttr && !node.hasAttribute('style')) node.setAttribute('style', '');
746
948
  prev.style = merged;
747
949
  prevProps.set(id, prev);
748
950
  }
@@ -777,8 +979,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
777
979
  break;
778
980
  }
779
981
  case 'append': {
780
- let parent = nodes.get(op.parent);
781
- const child = nodes.get(op.child);
982
+ let parent = nodeById(op.parent);
983
+ const child = nodeById(op.child);
782
984
  if (!parent || !child) {
783
985
  console.error(`[island] append skipped: parent=${op.parent}→${String(parent)} child=${op.child}→${String(child)}`);
784
986
  break;
@@ -790,18 +992,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
790
992
  if (isElementNode(parent) && parent.tagName === 'TEMPLATE') {
791
993
  parent = (parent as HTMLTemplateElement).content;
792
994
  }
793
- const before = op.before !== undefined ? (nodes.get(op.before) ?? null) : null;
995
+ const before = op.before !== undefined ? (nodeById(op.before) ?? null) : null;
794
996
  parent.insertBefore(child, before);
795
997
  break;
796
998
  }
797
999
  case 'remove': {
798
- const child = nodes.get(op.child);
799
- if (child !== undefined) unmountSlotSubtree(child);
1000
+ const child = nodeById(op.child);
1001
+ if (child !== undefined) {
1002
+ unmountSlotSubtree(child);
1003
+ // unmeasure + every map eviction happens inside tombstoneSubtree:
1004
+ // descendants get no remove op of their own, so the walk is the
1005
+ // only place they can leave nodes/prevProps/nodeListeners.
1006
+ tombstoneSubtree(op.child, child);
1007
+ }
800
1008
  child?.parentNode?.removeChild(child);
801
1009
  break;
802
1010
  }
803
1011
  case 'update': {
804
- const node = nodes.get(op.id);
1012
+ const node = nodeById(op.id);
805
1013
  if (!isElementNode(node)) break;
806
1014
  const prev = prevProps.get(op.id) ?? {};
807
1015
  applyProps(node, op.id, prev, op.props);
@@ -809,24 +1017,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
809
1017
  break;
810
1018
  }
811
1019
  case 'utext': {
812
- const node = nodes.get(op.id);
1020
+ const node = nodeById(op.id);
813
1021
  if (node) node.textContent = op.text;
814
1022
  break;
815
1023
  }
816
1024
  case 'attr': {
817
- const node = nodes.get(op.id);
1025
+ const node = nodeById(op.id);
818
1026
  if (!isElementNode(node)) break;
819
1027
  setAttr(node, op.id, op.name, op.value);
820
1028
  break;
821
1029
  }
822
1030
  case 'style': {
823
- const node = nodes.get(op.id);
1031
+ const node = nodeById(op.id);
824
1032
  if (!isElementNode(node)) break;
825
1033
  mergeStyle(node, op.id, op.props);
826
1034
  break;
827
1035
  }
828
1036
  case 'listen': {
829
- const node = nodes.get(op.id);
1037
+ const node = nodeById(op.id);
830
1038
  if (!isElementNode(node)) break;
831
1039
  // Keyed by type + handler id so proxy listeners can't collide with
832
1040
  // the React-prop listener for the same event name, and so unlisten
@@ -844,7 +1052,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
844
1052
  break;
845
1053
  }
846
1054
  case 'unlisten': {
847
- const node = nodes.get(op.id);
1055
+ const node = nodeById(op.id);
848
1056
  if (!isElementNode(node)) break;
849
1057
  const key = `${op.type}#${op.handler}`;
850
1058
  const listener = nodeListeners.get(op.id)?.get(key);
@@ -864,6 +1072,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
864
1072
  // like a `remove` op would so slots callbacks see null and
865
1073
  // slotNodes doesn't leak.
866
1074
  unmountSlotSubtree(el);
1075
+ // Children detach with their ids tombstoned — a remount's following
1076
+ // `append` re-promotes them rather than leaking map entries.
1077
+ for (const child of Array.from(el.childNodes)) {
1078
+ const id = nodeIds.get(child);
1079
+ if (id !== undefined) tombstoneSubtree(id, child);
1080
+ }
867
1081
  el.replaceChildren();
868
1082
  break;
869
1083
  }
@@ -884,10 +1098,26 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
884
1098
  onEvent?.(op.name, op.payload);
885
1099
  break;
886
1100
  }
1101
+ case 'measure': {
1102
+ const node = nodeById(op.id);
1103
+ if (!isElementNode(node)) break;
1104
+ measuredIds.add(op.id);
1105
+ ensureRectObserver();
1106
+ rectObserver?.observe(node);
1107
+ // The initial rect ships immediately — ResizeObserver only ticks on
1108
+ // later resizes (and on mount for a detached-then-appended node),
1109
+ // so without it a read-heavy mount would wait a frame for nothing.
1110
+ queueRect(op.id, node);
1111
+ break;
1112
+ }
1113
+ case 'unmeasure': {
1114
+ unmeasure(op.id);
1115
+ break;
1116
+ }
887
1117
  }
888
1118
  }
889
1119
 
890
- function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
1120
+ function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'setRects' | 'flush' | 'unmount'): void {
891
1121
  opsApplied += ops.length;
892
1122
  onActivity?.();
893
1123
  const t0 = performance.now();
@@ -906,7 +1136,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
906
1136
 
907
1137
  /** Times a main→worker round-trip for the devtools inspector. */
908
1138
  const islandCall = <T>(
909
- method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
1139
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
910
1140
  p: Promise<T>,
911
1141
  ): Promise<T> => {
912
1142
  const t0 = performance.now();
@@ -1084,16 +1314,23 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1084
1314
  emitDevtools({ type: 'island:unmount', instance });
1085
1315
  unsubscribe?.();
1086
1316
  resizeObserver?.disconnect();
1317
+ rectObserver?.disconnect();
1087
1318
  if (pollTimer !== null) clearInterval(pollTimer);
1088
1319
  if (sizeTimer !== null) clearTimeout(sizeTimer);
1320
+ if (rectTimer !== null) clearTimeout(rectTimer);
1321
+ pendingRects.clear();
1322
+ measuredIds.clear();
1089
1323
  const remaining = (clientMounts.get(client) ?? 1) - 1;
1090
1324
  clientMounts.set(client, remaining);
1091
1325
  if (remaining > 0) {
1092
- // The worker still serves sibling mounts — hand this one back. The
1093
- // detach ops are irrelevant if our element is already gone, so the
1094
- // return batch is dropped. Best-effort: the instance map and its
1095
- // handlers release regardless.
1096
- void islandCall('unmount', client.unmount(instance)).catch(() => {});
1326
+ // The worker still serves sibling mounts — hand this one back, but
1327
+ // replay the teardown batch anyway: the `clear` op unmounts slot
1328
+ // anchors inside our tree, firing slots[name](null) so slot render
1329
+ // roots come down. Dropping it would leave a slot-hosted sibling
1330
+ // mounted on this client and pin the worker alive.
1331
+ void islandCall('unmount', client.unmount(instance))
1332
+ .then((ops) => applyOps(ops, 'unmount'))
1333
+ .catch(() => {});
1097
1334
  } else {
1098
1335
  // Last island — still run the instance's teardown first so the app's
1099
1336
  // dispose hook cancels deferred work (library timers, animation
@@ -1102,6 +1339,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1102
1339
  // death is teardown — but in-process workers need this or the
1103
1340
  // instance zombies on and its pending callbacks mutate foreign docs.
1104
1341
  void islandCall('unmount', client.unmount(instance))
1342
+ .then((ops) => applyOps(ops, 'unmount'))
1105
1343
  .catch(() => {})
1106
1344
  .finally(() => {
1107
1345
  // A remount may have registered while teardown was in flight
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
@@ -124,6 +156,19 @@ export interface EventPayload {
124
156
  clientY?: number;
125
157
  screenX?: number;
126
158
  screenY?: number;
159
+ /**
160
+ * Pointer coords in the LISTENER element's local space:
161
+ * `clientX/Y − event.currentTarget.getBoundingClientRect()` computed
162
+ * main-thread where real rects exist. The proxy DOM has no measurement
163
+ * channel, so worker-side hit-testing libraries cannot derive local
164
+ * coords themselves: zrender's four-marker getBoundingClientRect
165
+ * transform degenerates, and e.offsetX is absent anyway. zrender's
166
+ * normalizeEvent early-returns on `e.zrX != null`, which is why the
167
+ * fields carry its names — charts (echarts SVG renderer) get working
168
+ * axis pointers/tooltips, any library can read them the same way.
169
+ */
170
+ zrX?: number;
171
+ zrY?: number;
127
172
  button?: number;
128
173
  /**
129
174
  * UIEvent.which — non-standard but set by every browser (1 = left button,
@@ -153,6 +198,15 @@ export interface EventPayload {
153
198
  scrollTop?: number;
154
199
  /** The worker instance id of `event.target`, when it maps to one. */
155
200
  targetId?: number;
201
+ /**
202
+ * The worker instance id of `event.relatedTarget` (MouseEvent/FocusEvent),
203
+ * when it maps to an op-created node. Lets over/out/leave/enter handlers
204
+ * distinguish "moved to a child" from "actually left the element" —
205
+ * zrender's mouseout gate (`toElement || relatedTarget` → isLocalEl walk)
206
+ * is the canonical consumer; without it every child transition looks like
207
+ * a real leave and charts flicker as tooltips hide/show.
208
+ */
209
+ relatedTargetId?: number;
156
210
  /**
157
211
  * The proxy-DOM node for `targetId` — SYNTHESIZED worker-side by the proxy
158
212
  * DOM when it wraps a listener (it never crosses the wire). Lets delegated
@@ -162,6 +216,13 @@ export interface EventPayload {
162
216
  * same proxy document.
163
217
  */
164
218
  target?: unknown;
219
+ /**
220
+ * The proxy-DOM node for `relatedTargetId` — SYNTHESIZED worker-side like
221
+ * `target` (never crosses the wire). Handler code can walk
222
+ * `relatedTarget.parentNode` — e.g. zrender's isLocalEl — to test
223
+ * whether the pointer moved within the listener's subtree.
224
+ */
225
+ relatedTarget?: unknown;
165
226
  /**
166
227
  * Synthesized NO-OPS — added worker-side by the proxy DOM when it wraps a
167
228
  * listener's payload (they never cross the wire). They exist so library
@@ -191,16 +252,25 @@ export type IslandWorkerMethods = {
191
252
  /** Invoke the worker handler a `__evt` ref or `listen` op points at. */
192
253
  dispatch(handlerId: number, payload: EventPayload): Op[];
193
254
  /**
194
- * Push the island container's measured box into the instance — the ONLY
195
- * geometry channel that exists. The driver observes the island's `el`
196
- * with a ResizeObserver and calls this once at mount and on resizes
197
- * (throttled ~100ms). The instance stores {w,h}; proxy-DOM geometry reads on
198
- * `doc.body`/`documentElement` and on elements marked via
199
- * `doc.markContainer(el)` then return it — deeper elements keep the
200
- * honest 0. Handlers registered via `doc.onResize` re-run on each push;
201
- * 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.
202
264
  */
203
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[];
204
274
  /** Drain ops committed outside a task (passive effects, timers). */
205
275
  flush(instance: string): Op[];
206
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) => {