@atolljs/islands 0.1.10 → 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.10",
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.10"
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';
@@ -512,6 +513,69 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
512
513
  /** instance id → slot name, for elements carrying `data-atoll-slot`. */
513
514
  const slotNodes = new Map<number, string>();
514
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
+
515
579
  /** Hand a live element to its slot mount callback (or drop it silently). */
516
580
  const mountSlot = (node: HTMLElement, id: number, name: string): void => {
517
581
  // Always set the attribute — nested-remove detection below finds slot
@@ -549,6 +613,90 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
549
613
  }
550
614
  };
551
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
+
552
700
  let opsApplied = 0;
553
701
  let flushCalls = 0;
554
702
  let mode: Mode = initialMode ?? 'push';
@@ -556,8 +704,38 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
556
704
  let pollTimer: number | null = null;
557
705
  let destroyed = false;
558
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
+
559
736
  function listenerFor(handlerId: number): EventListener {
560
737
  return (e: Event) => {
738
+ if (destroyed) return;
561
739
  const target = e.target;
562
740
  const payload = buildEventPayload(
563
741
  e,
@@ -565,11 +743,13 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
565
743
  target !== null ? nodeIds.get(target as Node) : undefined,
566
744
  nodeIds.get((e as MouseEvent).relatedTarget as Node),
567
745
  );
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));
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();
573
753
  };
574
754
  }
575
755
 
@@ -742,6 +922,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
742
922
  function mergeStyle(node: Element, id: number, changes: Record<string, string>): void {
743
923
  const elStyle = (node as HTMLElement).style;
744
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');
745
929
  const prev = prevProps.get(id) ?? {};
746
930
  const merged = { ...((prev.style ?? {}) as Record<string, string>) };
747
931
  for (const [k, v] of Object.entries(changes)) {
@@ -760,6 +944,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
760
944
  }
761
945
  merged[k] = v;
762
946
  }
947
+ if (hadStyleAttr && !node.hasAttribute('style')) node.setAttribute('style', '');
763
948
  prev.style = merged;
764
949
  prevProps.set(id, prev);
765
950
  }
@@ -794,8 +979,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
794
979
  break;
795
980
  }
796
981
  case 'append': {
797
- let parent = nodes.get(op.parent);
798
- const child = nodes.get(op.child);
982
+ let parent = nodeById(op.parent);
983
+ const child = nodeById(op.child);
799
984
  if (!parent || !child) {
800
985
  console.error(`[island] append skipped: parent=${op.parent}→${String(parent)} child=${op.child}→${String(child)}`);
801
986
  break;
@@ -807,18 +992,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
807
992
  if (isElementNode(parent) && parent.tagName === 'TEMPLATE') {
808
993
  parent = (parent as HTMLTemplateElement).content;
809
994
  }
810
- const before = op.before !== undefined ? (nodes.get(op.before) ?? null) : null;
995
+ const before = op.before !== undefined ? (nodeById(op.before) ?? null) : null;
811
996
  parent.insertBefore(child, before);
812
997
  break;
813
998
  }
814
999
  case 'remove': {
815
- const child = nodes.get(op.child);
816
- 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
+ }
817
1008
  child?.parentNode?.removeChild(child);
818
1009
  break;
819
1010
  }
820
1011
  case 'update': {
821
- const node = nodes.get(op.id);
1012
+ const node = nodeById(op.id);
822
1013
  if (!isElementNode(node)) break;
823
1014
  const prev = prevProps.get(op.id) ?? {};
824
1015
  applyProps(node, op.id, prev, op.props);
@@ -826,24 +1017,24 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
826
1017
  break;
827
1018
  }
828
1019
  case 'utext': {
829
- const node = nodes.get(op.id);
1020
+ const node = nodeById(op.id);
830
1021
  if (node) node.textContent = op.text;
831
1022
  break;
832
1023
  }
833
1024
  case 'attr': {
834
- const node = nodes.get(op.id);
1025
+ const node = nodeById(op.id);
835
1026
  if (!isElementNode(node)) break;
836
1027
  setAttr(node, op.id, op.name, op.value);
837
1028
  break;
838
1029
  }
839
1030
  case 'style': {
840
- const node = nodes.get(op.id);
1031
+ const node = nodeById(op.id);
841
1032
  if (!isElementNode(node)) break;
842
1033
  mergeStyle(node, op.id, op.props);
843
1034
  break;
844
1035
  }
845
1036
  case 'listen': {
846
- const node = nodes.get(op.id);
1037
+ const node = nodeById(op.id);
847
1038
  if (!isElementNode(node)) break;
848
1039
  // Keyed by type + handler id so proxy listeners can't collide with
849
1040
  // the React-prop listener for the same event name, and so unlisten
@@ -861,7 +1052,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
861
1052
  break;
862
1053
  }
863
1054
  case 'unlisten': {
864
- const node = nodes.get(op.id);
1055
+ const node = nodeById(op.id);
865
1056
  if (!isElementNode(node)) break;
866
1057
  const key = `${op.type}#${op.handler}`;
867
1058
  const listener = nodeListeners.get(op.id)?.get(key);
@@ -881,6 +1072,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
881
1072
  // like a `remove` op would so slots callbacks see null and
882
1073
  // slotNodes doesn't leak.
883
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
+ }
884
1081
  el.replaceChildren();
885
1082
  break;
886
1083
  }
@@ -901,10 +1098,26 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
901
1098
  onEvent?.(op.name, op.payload);
902
1099
  break;
903
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
+ }
904
1117
  }
905
1118
  }
906
1119
 
907
- 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 {
908
1121
  opsApplied += ops.length;
909
1122
  onActivity?.();
910
1123
  const t0 = performance.now();
@@ -923,7 +1136,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
923
1136
 
924
1137
  /** Times a main→worker round-trip for the devtools inspector. */
925
1138
  const islandCall = <T>(
926
- method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
1139
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
927
1140
  p: Promise<T>,
928
1141
  ): Promise<T> => {
929
1142
  const t0 = performance.now();
@@ -1101,16 +1314,23 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1101
1314
  emitDevtools({ type: 'island:unmount', instance });
1102
1315
  unsubscribe?.();
1103
1316
  resizeObserver?.disconnect();
1317
+ rectObserver?.disconnect();
1104
1318
  if (pollTimer !== null) clearInterval(pollTimer);
1105
1319
  if (sizeTimer !== null) clearTimeout(sizeTimer);
1320
+ if (rectTimer !== null) clearTimeout(rectTimer);
1321
+ pendingRects.clear();
1322
+ measuredIds.clear();
1106
1323
  const remaining = (clientMounts.get(client) ?? 1) - 1;
1107
1324
  clientMounts.set(client, remaining);
1108
1325
  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(() => {});
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(() => {});
1114
1334
  } else {
1115
1335
  // Last island — still run the instance's teardown first so the app's
1116
1336
  // dispose hook cancels deferred work (library timers, animation
@@ -1119,6 +1339,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
1119
1339
  // death is teardown — but in-process workers need this or the
1120
1340
  // instance zombies on and its pending callbacks mutate foreign docs.
1121
1341
  void islandCall('unmount', client.unmount(instance))
1342
+ .then((ops) => applyOps(ops, 'unmount'))
1122
1343
  .catch(() => {})
1123
1344
  .finally(() => {
1124
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
@@ -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) => {