@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/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
|
@@ -149,6 +149,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
149
149
|
const prevProps = new Map<number, WireProps>();
|
|
150
150
|
/** sub-id → 'type#handler' → attached proxy listener (kept for removal). */
|
|
151
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>();
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
156
|
* sub-id → slot name for anchors THIS mount's `slots` claimed. A claimed
|
|
@@ -345,6 +347,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
345
347
|
}
|
|
346
348
|
case 'remove': {
|
|
347
349
|
nodes.get(op.child)?.remove();
|
|
350
|
+
measureCleanups.get(op.child)?.();
|
|
351
|
+
measureCleanups.delete(op.child);
|
|
348
352
|
releaseDetachedSlots();
|
|
349
353
|
break;
|
|
350
354
|
}
|
|
@@ -421,9 +425,34 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
421
425
|
}
|
|
422
426
|
case 'clear': {
|
|
423
427
|
el.replaceChildren();
|
|
428
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
429
|
+
measureCleanups.clear();
|
|
424
430
|
releaseDetachedSlots();
|
|
425
431
|
break;
|
|
426
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
|
+
}
|
|
427
456
|
case 'emit': {
|
|
428
457
|
if (op.name === CALLBACK_EVENT) {
|
|
429
458
|
const p = op.payload as { id?: number; args?: unknown[] } | undefined;
|
|
@@ -443,7 +472,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
443
472
|
}
|
|
444
473
|
}
|
|
445
474
|
|
|
446
|
-
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
|
|
475
|
+
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'setRects' | 'flush'): void {
|
|
447
476
|
opsApplied += ops.length;
|
|
448
477
|
onActivity?.();
|
|
449
478
|
const t0 = performance.now();
|
|
@@ -465,7 +494,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
465
494
|
|
|
466
495
|
/** Times a worker→sub-worker round-trip for the devtools inspector. */
|
|
467
496
|
const islandCall = <T>(
|
|
468
|
-
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
|
|
497
|
+
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
|
|
469
498
|
p: Promise<T>,
|
|
470
499
|
): Promise<T> => {
|
|
471
500
|
const t0 = performance.now();
|
|
@@ -562,16 +591,27 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
562
591
|
setMode(mode);
|
|
563
592
|
|
|
564
593
|
/* ── Pushed-size forwarding ─────────────────────────────────────────────
|
|
565
|
-
* The
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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;
|
|
570
603
|
const pushSize = (w: number, h: number): void => {
|
|
571
604
|
if (destroyed) return;
|
|
572
605
|
void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
|
|
573
606
|
};
|
|
574
|
-
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
|
+
});
|
|
575
615
|
const initial = getInstanceSize(parentInstance);
|
|
576
616
|
if (initial !== undefined) pushSize(initial.w, initial.h);
|
|
577
617
|
|
|
@@ -602,6 +642,9 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
602
642
|
destroyed = true;
|
|
603
643
|
emitDevtools({ type: 'island:unmount', instance });
|
|
604
644
|
for (const subId of [...slotNodes.keys()]) unmountSlot(subId);
|
|
645
|
+
unobserveEl();
|
|
646
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
647
|
+
measureCleanups.clear();
|
|
605
648
|
unsubscribe?.();
|
|
606
649
|
if (pollTimer !== null) clearInterval(pollTimer);
|
|
607
650
|
const remaining = (clientMounts.get(client) ?? 1) - 1;
|