@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/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
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
assertCloneableProps,
|
|
38
38
|
connectIslandWorker,
|
|
39
39
|
cssStyleValue,
|
|
40
|
+
isKeepAliveClient,
|
|
40
41
|
registerSubIslandMounter,
|
|
41
42
|
type ConnectIslandWorkerConfig,
|
|
42
43
|
type IslandClient,
|
|
@@ -149,6 +150,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
149
150
|
const prevProps = new Map<number, WireProps>();
|
|
150
151
|
/** sub-id → 'type#handler' → attached proxy listener (kept for removal). */
|
|
151
152
|
const nodeListeners = new Map<number, Map<string, { fn: ProxyEventHandler; capture?: boolean }>>();
|
|
153
|
+
/** sub-id → the parent-doc observeSize detacher a `measure` op registered. */
|
|
154
|
+
const measureCleanups = new Map<number, () => void>();
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
157
|
* sub-id → slot name for anchors THIS mount's `slots` claimed. A claimed
|
|
@@ -345,6 +348,8 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
345
348
|
}
|
|
346
349
|
case 'remove': {
|
|
347
350
|
nodes.get(op.child)?.remove();
|
|
351
|
+
measureCleanups.get(op.child)?.();
|
|
352
|
+
measureCleanups.delete(op.child);
|
|
348
353
|
releaseDetachedSlots();
|
|
349
354
|
break;
|
|
350
355
|
}
|
|
@@ -421,9 +426,34 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
421
426
|
}
|
|
422
427
|
case 'clear': {
|
|
423
428
|
el.replaceChildren();
|
|
429
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
430
|
+
measureCleanups.clear();
|
|
424
431
|
releaseDetachedSlots();
|
|
425
432
|
break;
|
|
426
433
|
}
|
|
434
|
+
case 'measure': {
|
|
435
|
+
const node = nodes.get(op.id);
|
|
436
|
+
if (!(node instanceof ProxyElement) || measureCleanups.has(op.id)) break;
|
|
437
|
+
// The sub-worker's measure request tunnels through the parent's own
|
|
438
|
+
// channel: observe the translated proxy node — the real driver
|
|
439
|
+
// measures the real element — and forward each pushed rect down as
|
|
440
|
+
// a setRects batch in the SUB worker's id space.
|
|
441
|
+
measureCleanups.set(
|
|
442
|
+
op.id,
|
|
443
|
+
doc.observeSize(node, (rect) => {
|
|
444
|
+
void islandCall(
|
|
445
|
+
'setRects',
|
|
446
|
+
client.setRects(instance, [{ id: op.id, x: rect.x, y: rect.y, w: rect.w, h: rect.h }]),
|
|
447
|
+
).then((ops) => applyOps(ops, 'setRects'));
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
case 'unmeasure': {
|
|
453
|
+
measureCleanups.get(op.id)?.();
|
|
454
|
+
measureCleanups.delete(op.id);
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
427
457
|
case 'emit': {
|
|
428
458
|
if (op.name === CALLBACK_EVENT) {
|
|
429
459
|
const p = op.payload as { id?: number; args?: unknown[] } | undefined;
|
|
@@ -443,7 +473,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
443
473
|
}
|
|
444
474
|
}
|
|
445
475
|
|
|
446
|
-
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
|
|
476
|
+
function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'setRects' | 'flush'): void {
|
|
447
477
|
opsApplied += ops.length;
|
|
448
478
|
onActivity?.();
|
|
449
479
|
const t0 = performance.now();
|
|
@@ -465,7 +495,7 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
465
495
|
|
|
466
496
|
/** Times a worker→sub-worker round-trip for the devtools inspector. */
|
|
467
497
|
const islandCall = <T>(
|
|
468
|
-
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
|
|
498
|
+
method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'setRects' | 'updateProps' | 'flush' | 'unmount',
|
|
469
499
|
p: Promise<T>,
|
|
470
500
|
): Promise<T> => {
|
|
471
501
|
const t0 = performance.now();
|
|
@@ -562,16 +592,27 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
562
592
|
setMode(mode);
|
|
563
593
|
|
|
564
594
|
/* ── Pushed-size forwarding ─────────────────────────────────────────────
|
|
565
|
-
* The
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
595
|
+
* The sub-island's container IS `el` — its real box is what the sub app's
|
|
596
|
+
* layout should size to. observeSize emits a measure op up the parent's
|
|
597
|
+
* queue so the main driver's ResizeObserver tracks the REAL element and
|
|
598
|
+
* forwards its box; until the first nonzero rect lands the parent's own
|
|
599
|
+
* container box forwards as the fallback (the same claim markContainer
|
|
600
|
+
* makes). An all-zero measured rect stays fallback-eligible: "no layout
|
|
601
|
+
* yet" is indistinguishable from "no real layout engine" (in-process
|
|
602
|
+
* tests), and the parent box is the better answer in both. */
|
|
603
|
+
let measuredEl = false;
|
|
570
604
|
const pushSize = (w: number, h: number): void => {
|
|
571
605
|
if (destroyed) return;
|
|
572
606
|
void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
|
|
573
607
|
};
|
|
574
|
-
doc.
|
|
608
|
+
const unobserveEl = doc.observeSize(el, (rect) => {
|
|
609
|
+
if (rect.w === 0 && rect.h === 0) return;
|
|
610
|
+
measuredEl = true;
|
|
611
|
+
pushSize(rect.w, rect.h);
|
|
612
|
+
});
|
|
613
|
+
doc.onResize((w, h) => {
|
|
614
|
+
if (!measuredEl) pushSize(w, h);
|
|
615
|
+
});
|
|
575
616
|
const initial = getInstanceSize(parentInstance);
|
|
576
617
|
if (initial !== undefined) pushSize(initial.w, initial.h);
|
|
577
618
|
|
|
@@ -602,11 +643,17 @@ export async function mountSubIsland(opts: MountSubIslandOptions): Promise<SubIs
|
|
|
602
643
|
destroyed = true;
|
|
603
644
|
emitDevtools({ type: 'island:unmount', instance });
|
|
604
645
|
for (const subId of [...slotNodes.keys()]) unmountSlot(subId);
|
|
646
|
+
unobserveEl();
|
|
647
|
+
for (const cleanup of measureCleanups.values()) cleanup();
|
|
648
|
+
measureCleanups.clear();
|
|
605
649
|
unsubscribe?.();
|
|
606
650
|
if (pollTimer !== null) clearInterval(pollTimer);
|
|
607
651
|
const remaining = (clientMounts.get(client) ?? 1) - 1;
|
|
608
652
|
clientMounts.set(client, remaining);
|
|
609
|
-
|
|
653
|
+
// keepAlive clients release the instance but keep the thread warm;
|
|
654
|
+
// worker-owned and last-mount clients terminate outright.
|
|
655
|
+
if (!isKeepAliveClient(client) && (givenClient === undefined || remaining <= 0))
|
|
656
|
+
client.terminate();
|
|
610
657
|
else void islandCall('unmount', client.unmount(instance)).catch(() => {});
|
|
611
658
|
},
|
|
612
659
|
};
|