@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.
@@ -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,
@@ -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 only geometry channel that exists is the parent island's measured
565
- * box — forward it as the sub-island's container size (same claim
566
- * `doc.markContainer` makes: "pretend my box is the container's"). Sub
567
- * apps that genuinely fill their mount get a usable size; others see the
568
- * 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;
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.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
+ });
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;