@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 +2 -2
- package/src/index.ts +1 -0
- package/src/island.ts +294 -42
- 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 +56 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/islands",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
22
|
+
"@atolljs/core": "0.1.12"
|
|
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';
|
|
@@ -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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 =
|
|
798
|
-
const 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 ? (
|
|
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 =
|
|
816
|
-
if (child !== undefined)
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
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) => {
|