@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.
@@ -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,
@@ -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 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. */
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.onResize(pushSize);
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;