@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 +2 -2
- package/src/index.ts +1 -0
- package/src/island.ts +261 -23
- package/src/ops.ts +79 -9
- package/src/worker/defineWorkers.ts +29 -1
- package/src/worker/dom/css.ts +3 -1
- package/src/worker/dom/document.ts +122 -13
- 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 +52 -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';
|
|
@@ -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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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 =
|
|
781
|
-
const 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 ? (
|
|
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 =
|
|
799
|
-
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
|
+
}
|
|
800
1008
|
child?.parentNode?.removeChild(child);
|
|
801
1009
|
break;
|
|
802
1010
|
}
|
|
803
1011
|
case 'update': {
|
|
804
|
-
const node =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
|
|
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
|
|
195
|
-
* geometry channel
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* `doc.
|
|
200
|
-
*
|
|
201
|
-
* 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.
|
|
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,
|
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) => {
|