@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/src/worker/instance.ts
CHANGED
|
@@ -93,6 +93,63 @@ interface HandlerEntry {
|
|
|
93
93
|
}
|
|
94
94
|
/** handler id → prop function + owning instance. Lives worker-side; never serialized. */
|
|
95
95
|
const handlers = new Map<number, HandlerEntry>();
|
|
96
|
+
|
|
97
|
+
/* ── Detached-instance tombstones ───────────────────────────────────────────
|
|
98
|
+
* removeChild detaches a subtree but the app may re-append the same nodes
|
|
99
|
+
* later (zrender restacks displayables, list libs reorder rows). Records and
|
|
100
|
+
* handler entries move here instead of staying live forever — detached
|
|
101
|
+
* nodes can still materialize as dispatch targets (a click already in
|
|
102
|
+
* flight when its subtree left) and promote back on re-append. The cap
|
|
103
|
+
* bounds create/remove churn (tooltip text, transient overlays) that never
|
|
104
|
+
* returns. */
|
|
105
|
+
const DETACHED_CAP = 4096;
|
|
106
|
+
const detachedInstances = new Map<number, HostInstance>();
|
|
107
|
+
const detachedHandlers = new Map<number, HandlerEntry>();
|
|
108
|
+
|
|
109
|
+
const evictOldest = <V>(map: Map<number, V>): void => {
|
|
110
|
+
if (map.size <= DETACHED_CAP) return;
|
|
111
|
+
const oldest = map.keys().next().value;
|
|
112
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Move an instance record + its handler ids out of the live maps. */
|
|
116
|
+
export const detachInstance = (id: number, handlerIds: number[]): void => {
|
|
117
|
+
const record = instances.get(id);
|
|
118
|
+
if (record === undefined) return;
|
|
119
|
+
instances.delete(id);
|
|
120
|
+
detachedInstances.set(id, record);
|
|
121
|
+
evictOldest(detachedInstances);
|
|
122
|
+
for (const hid of handlerIds) {
|
|
123
|
+
const entry = handlers.get(hid);
|
|
124
|
+
if (entry !== undefined) {
|
|
125
|
+
handlers.delete(hid);
|
|
126
|
+
detachedHandlers.set(hid, entry);
|
|
127
|
+
evictOldest(detachedHandlers);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Restore an instance record + handler ids on re-append (idempotent). */
|
|
133
|
+
export const attachInstance = (id: number, handlerIds: number[]): void => {
|
|
134
|
+
const record = detachedInstances.get(id);
|
|
135
|
+
if (record !== undefined) {
|
|
136
|
+
detachedInstances.delete(id);
|
|
137
|
+
instances.set(id, record);
|
|
138
|
+
}
|
|
139
|
+
for (const hid of handlerIds) {
|
|
140
|
+
const entry = detachedHandlers.get(hid);
|
|
141
|
+
if (entry !== undefined) {
|
|
142
|
+
detachedHandlers.delete(hid);
|
|
143
|
+
handlers.set(hid, entry);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** instances.get that also resolves detached records — stale dispatches and
|
|
149
|
+
* relatedTarget materialization may still reference them. */
|
|
150
|
+
export const lookupInstance = (id: number): HostInstance | undefined =>
|
|
151
|
+
instances.get(id) ?? detachedInstances.get(id);
|
|
152
|
+
|
|
96
153
|
let nextId = 1;
|
|
97
154
|
let nextHandlerId = 1;
|
|
98
155
|
|
|
@@ -229,9 +286,9 @@ export const takeOps = (instance: string): Op[] =>
|
|
|
229
286
|
|
|
230
287
|
/**
|
|
231
288
|
* Structural memory snapshot of the proxy layer — how much shadow state the
|
|
232
|
-
* engine retains for live
|
|
233
|
-
*
|
|
234
|
-
*
|
|
289
|
+
* engine retains for live nodes, handlers, and ops still queued. Removed
|
|
290
|
+
* nodes leave `instances` for a bounded tombstone (event dispatch may still
|
|
291
|
+
* resolve recently-detached target ids), so liveNodes is attached-only.
|
|
235
292
|
*/
|
|
236
293
|
export const proxyInstanceStats = (): {
|
|
237
294
|
liveNodes: number;
|
|
@@ -247,16 +304,19 @@ export const proxyInstanceStats = (): {
|
|
|
247
304
|
),
|
|
248
305
|
});
|
|
249
306
|
|
|
250
|
-
export const getHandler = (id: number): HandlerEntry | undefined =>
|
|
307
|
+
export const getHandler = (id: number): HandlerEntry | undefined =>
|
|
308
|
+
handlers.get(id) ?? detachedHandlers.get(id);
|
|
251
309
|
|
|
252
310
|
/* ── Pushed container size (setSize channel) ────────────────────────────── */
|
|
253
311
|
|
|
254
312
|
/**
|
|
255
313
|
* instance key → the island container's last measured {w,h}, pushed by the
|
|
256
|
-
* driver's `setSize` task. This is the
|
|
257
|
-
* thread
|
|
258
|
-
* this value to `doc.body`/`documentElement` and to elements
|
|
259
|
-
* marked `doc.markContainer(el)
|
|
314
|
+
* driver's `setSize` task. This is the always-on geometry channel: the main
|
|
315
|
+
* thread measures the island's root box unconditionally, so the proxy DOM
|
|
316
|
+
* serves this value to `doc.body`/`documentElement` and to elements
|
|
317
|
+
* explicitly marked `doc.markContainer(el)`. Per-element rects arrive
|
|
318
|
+
* separately through `setRects`/`instanceRects` below; elements with neither
|
|
319
|
+
* keep the honest 0.
|
|
260
320
|
* Module-level (not per-document) so it survives imperative rebuilds that
|
|
261
321
|
* swap in a fresh proxy document.
|
|
262
322
|
*/
|
|
@@ -271,6 +331,40 @@ export const setInstanceSize = (instance: string, w: number, h: number): void =>
|
|
|
271
331
|
instanceSizes.set(instance, { w, h });
|
|
272
332
|
};
|
|
273
333
|
|
|
334
|
+
/* ── Measured element rects (setRects channel) ──────────────────────────── */
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* instance key → element id → last measured box, pushed by the driver's
|
|
338
|
+
* `setRects` task in answer to `measure` ops. Module-level for the same
|
|
339
|
+
* reason as `instanceSizes`: entries survive imperative doc rebuilds, and
|
|
340
|
+
* rects for reconciler-created elements are reachable the moment the id is
|
|
341
|
+
* adopted — no proxy document needs to exist.
|
|
342
|
+
*/
|
|
343
|
+
const instanceRects = new Map<string, Map<number, { x: number; y: number; w: number; h: number }>>();
|
|
344
|
+
|
|
345
|
+
/** The last pushed rect for one element id, or undefined if never measured. */
|
|
346
|
+
export const getInstanceRect = (
|
|
347
|
+
instance: string,
|
|
348
|
+
id: number,
|
|
349
|
+
): { x: number; y: number; w: number; h: number } | undefined =>
|
|
350
|
+
instanceRects.get(instance)?.get(id);
|
|
351
|
+
|
|
352
|
+
/** Store one element's measured box from a `setRects` push. */
|
|
353
|
+
export const setInstanceRect = (
|
|
354
|
+
instance: string,
|
|
355
|
+
rect: { id: number; x: number; y: number; w: number; h: number },
|
|
356
|
+
): void => {
|
|
357
|
+
let table = instanceRects.get(instance);
|
|
358
|
+
if (table === undefined) instanceRects.set(instance, (table = new Map()));
|
|
359
|
+
table.set(rect.id, { x: rect.x, y: rect.y, w: rect.w, h: rect.h });
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** Drop every measured rect for the instance — the unmount path calls this
|
|
363
|
+
* so stale geometry can't answer reads on a re-mounted instance's ids. */
|
|
364
|
+
export const clearInstanceRects = (instance: string): void => {
|
|
365
|
+
instanceRects.delete(instance);
|
|
366
|
+
};
|
|
367
|
+
|
|
274
368
|
/**
|
|
275
369
|
* Register a function in the handler table outside prop serialization —
|
|
276
370
|
* the proxy DOM's addEventListener uses this. The instance is stamped on the
|
|
@@ -292,6 +386,7 @@ export const registerHandler = (
|
|
|
292
386
|
export const unregisterHandler = (id: number): void => {
|
|
293
387
|
timed(() => {
|
|
294
388
|
handlers.delete(id);
|
|
389
|
+
detachedHandlers.delete(id);
|
|
295
390
|
});
|
|
296
391
|
};
|
|
297
392
|
|
package/src/worker/proxyDom.ts
CHANGED
package/src/worker/subIsland.ts
CHANGED
|
@@ -81,6 +81,7 @@ const clientMounts = new WeakMap<object, number>();
|
|
|
81
81
|
*/
|
|
82
82
|
const ENRICHED_KEYS = new Set([
|
|
83
83
|
'target',
|
|
84
|
+
'relatedTarget',
|
|
84
85
|
'currentTarget',
|
|
85
86
|
'composedPath',
|
|
86
87
|
'preventDefault',
|
|
@@ -148,6 +149,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
148
149
|
const prevProps = new Map<number, WireProps>();
|
|
149
150
|
/** sub-id → 'type#handler' → attached proxy listener (kept for removal). */
|
|
150
151
|
const nodeListeners = new Map<number, Map<string, { fn: ProxyEventHandler; capture?: boolean }>>();
|
|
152
|
+
/** sub-id → the parent-doc observeSize detacher a `measure` op registered. */
|
|
153
|
+
const measureCleanups = new Map<number, () => void>();
|
|
151
154
|
|
|
152
155
|
/**
|
|
153
156
|
* sub-id → slot name for anchors THIS mount's `slots` claimed. A claimed
|
|
@@ -344,6 +347,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
344
347
|
}
|
|
345
348
|
case 'remove': {
|
|
346
349
|
nodes.get(op.child)?.remove();
|
|
350
|
+
measureCleanups.get(op.child)?.();
|
|
351
|
+
measureCleanups.delete(op.child);
|
|
347
352
|
releaseDetachedSlots();
|
|
348
353
|
break;
|
|
349
354
|
}
|
|
@@ -420,9 +425,34 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
420
425
|
}
|
|
421
426
|
case 'clear': {
|
|
422
427
|
el.replaceChildren();
|
|
428
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
429
|
+
measureCleanups.clear();
|
|
423
430
|
releaseDetachedSlots();
|
|
424
431
|
break;
|
|
425
432
|
}
|
|
433
|
+
case 'measure': {
|
|
434
|
+
const node = nodes.get(op.id);
|
|
435
|
+
if (!(node instanceof ProxyElement) || measureCleanups.has(op.id)) break;
|
|
436
|
+
// The sub-worker's measure request tunnels through the parent's own
|
|
437
|
+
// channel: observe the translated proxy node — the real driver
|
|
438
|
+
// measures the real element — and forward each pushed rect down as
|
|
439
|
+
// a setRects batch in the SUB worker's id space.
|
|
440
|
+
measureCleanups.set(
|
|
441
|
+
op.id,
|
|
442
|
+
doc.observeSize(node, (rect) => {
|
|
443
|
+
void islandCall(
|
|
444
|
+
'setRects',
|
|
445
|
+
client.setRects(instance, [{ id: op.id, x: rect.x, y: rect.y, w: rect.w, h: rect.h }]),
|
|
446
|
+
).then((ops) => applyOps(ops, 'setRects'));
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case 'unmeasure': {
|
|
452
|
+
measureCleanups.get(op.id)?.();
|
|
453
|
+
measureCleanups.delete(op.id);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
426
456
|
case 'emit': {
|
|
427
457
|
if (op.name === CALLBACK_EVENT) {
|
|
428
458
|
const p = op.payload as { id?: number; args?: unknown[] } | undefined;
|
|
@@ -442,7 +472,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
442
472
|
}
|
|
443
473
|
}
|
|
444
474
|
|
|
445
|
-
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
|
|
475
|
+
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'setRects' | 'flush'): void {
|
|
446
476
|
opsApplied += ops.length;
|
|
447
477
|
onActivity?.();
|
|
448
478
|
const t0 = performance.now();
|
|
@@ -464,7 +494,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
464
494
|
|
|
465
495
|
/** Times a worker→sub-worker round-trip for the devtools inspector. */
|
|
466
496
|
const islandCall = <T>(
|
|
467
|
-
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
|
|
497
|
+
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
|
|
468
498
|
p: Promise<T>,
|
|
469
499
|
): Promise<T> => {
|
|
470
500
|
const t0 = performance.now();
|
|
@@ -561,16 +591,27 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
561
591
|
setMode(mode);
|
|
562
592
|
|
|
563
593
|
/* ── Pushed-size forwarding ─────────────────────────────────────────────
|
|
564
|
-
* The
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
594
|
+
* The sub-island's container IS `el` — its real box is what the sub app's
|
|
595
|
+
* layout should size to. observeSize emits a measure op up the parent's
|
|
596
|
+
* queue so the main driver's ResizeObserver tracks the REAL element and
|
|
597
|
+
* forwards its box; until the first nonzero rect lands the parent's own
|
|
598
|
+
* container box forwards as the fallback (the same claim markContainer
|
|
599
|
+
* makes). An all-zero measured rect stays fallback-eligible: "no layout
|
|
600
|
+
* yet" is indistinguishable from "no real layout engine" (in-process
|
|
601
|
+
* tests), and the parent box is the better answer in both. */
|
|
602
|
+
let measuredEl = false;
|
|
569
603
|
const pushSize = (w: number, h: number): void => {
|
|
570
604
|
if (destroyed) return;
|
|
571
605
|
void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
|
|
572
606
|
};
|
|
573
|
-
doc.
|
|
607
|
+
const unobserveEl = doc.observeSize(el, (rect) => {
|
|
608
|
+
if (rect.w === 0 && rect.h === 0) return;
|
|
609
|
+
measuredEl = true;
|
|
610
|
+
pushSize(rect.w, rect.h);
|
|
611
|
+
});
|
|
612
|
+
doc.onResize((w, h) => {
|
|
613
|
+
if (!measuredEl) pushSize(w, h);
|
|
614
|
+
});
|
|
574
615
|
const initial = getInstanceSize(parentInstance);
|
|
575
616
|
if (initial !== undefined) pushSize(initial.w, initial.h);
|
|
576
617
|
|
|
@@ -601,6 +642,9 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
601
642
|
destroyed = true;
|
|
602
643
|
emitDevtools({ type: 'island:unmount', instance });
|
|
603
644
|
for (const subId of [...slotNodes.keys()]) unmountSlot(subId);
|
|
645
|
+
unobserveEl();
|
|
646
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
647
|
+
measureCleanups.clear();
|
|
604
648
|
unsubscribe?.();
|
|
605
649
|
if (pollTimer !== null) clearInterval(pollTimer);
|
|
606
650
|
const remaining = (clientMounts.get(client) ?? 1) - 1;
|