@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.
@@ -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 + previously-allocated nodes, handlers, and ops
233
- * still queued. `instances` is not pruned on removeChild (event dispatch may
234
- * still resolve detached target ids), so liveNodes is nodes-ever-allocated.
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 => handlers.get(id);
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 ONLY geometry channel: the main
257
- * thread can only measure the island's root box, so the proxy DOM serves
258
- * this value to `doc.body`/`documentElement` and to elements explicitly
259
- * marked `doc.markContainer(el)` — every other element keeps the honest 0.
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
 
@@ -24,6 +24,9 @@ export {
24
24
  installInstanceDispatcher,
25
25
  installDomShim,
26
26
  } from './dom/window';
27
+ export {
28
+ ProxyResizeObserver,
29
+ } from './dom/resizeObserver';
27
30
  export type {
28
31
  WindowShim,
29
32
  WindowFacadeBundle,
@@ -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 only geometry channel that exists is the parent island's measured
566
- * box — forward it as the sub-island's container size (same claim
567
- * `doc.markContainer` makes: "pretend my box is the container's"). Sub
568
- * apps that genuinely fill their mount get a usable size; others see the
569
- * parent's box — honest enough, and the alternative is permanent zeros. */
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.onResize(pushSize);
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
- if (givenClient === undefined || remaining <= 0) client.terminate();
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
  };