@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 +2 -2
- package/src/index.ts +1 -0
- package/src/island.ts +244 -23
- package/src/ops.ts +50 -9
- package/src/worker/defineWorkers.ts +29 -1
- package/src/worker/dom/css.ts +3 -1
- package/src/worker/dom/document.ts +116 -14
- package/src/worker/dom/element.ts +60 -26
- package/src/worker/dom/html.ts +1 -0
- package/src/worker/dom/node.ts +36 -0
- package/src/worker/dom/resizeObserver.ts +104 -0
- package/src/worker/dom/window.ts +19 -3
- package/src/worker/index.ts +2 -1
- package/src/worker/instance.ts +103 -8
- package/src/worker/proxyDom.ts +3 -0
- package/src/worker/subIsland.ts +51 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/islands",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
22
|
+
"@atolljs/core": "0.1.11"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
package/src/index.ts
CHANGED
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 =
|
|
798
|
-
const 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 ? (
|
|
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 =
|
|
816
|
-
if (child !== undefined)
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
|
|
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
|
|
224
|
-
* geometry channel
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* `doc.
|
|
229
|
-
*
|
|
230
|
-
* their ops ride back in this method's
|
|
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,
|
package/src/worker/dom/css.ts
CHANGED
|
@@ -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 (
|
|
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) => {
|