@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.
@@ -5,14 +5,17 @@ import {
5
5
  getActiveInstance,
6
6
  getLastActiveInstance,
7
7
  getInstanceSize,
8
+ getInstanceRect,
9
+ setInstanceRect,
8
10
  instances,
11
+ lookupInstance,
9
12
  markInstanceActive,
10
13
  pushOp,
11
14
  registerHandler,
12
15
  unregisterHandler,
13
16
  } from '../instance';
14
17
  import type { ElementInstance, HostInstance, TextInstance } from '../instance';
15
- import type { EventPayload, Op } from '../../ops';
18
+ import type { EventPayload, MeasuredRect, Op } from '../../ops';
16
19
 
17
20
  import {
18
21
  ProxyNode,
@@ -105,6 +108,18 @@ export interface ProxyDocument {
105
108
  * fill the island's box (a map viewport, a canvas host).
106
109
  */
107
110
  markContainer(el: ProxyElement): void;
111
+ /**
112
+ * Request REAL geometry for `el` — emits a `measure` op asking the driver
113
+ * to measure the actual node and ResizeObserver it. `cb` fires inside the
114
+ * instance's task scope on every `setRects` push (the first arrives as
115
+ * soon as the driver answers, then on every element resize), so observer
116
+ * mutations emit ops like onResize handlers. The element's geometry reads
117
+ * (`clientWidth`, `getBoundingClientRect`, …) serve the last pushed rect
118
+ * from then on. Returns a detacher — removing the last observer emits
119
+ * `unmeasure`. This backs the `ResizeObserver` shim; call it directly for
120
+ * the plain (rect) => void form.
121
+ */
122
+ observeSize(el: ProxyElement, cb: (rect: MeasuredRect) => void): () => void;
108
123
  /**
109
124
  * Register a handler fired every time the driver pushes a new container
110
125
  * size — the app's resize signal. Handlers run inside the instance's task
@@ -140,6 +155,11 @@ export class InternalDocument implements ProxyDocument {
140
155
  readonly _containerIds = new Set<number>([0]);
141
156
  /** Handlers fired when a setSize task pushes a new container size. */
142
157
  private readonly _resizeHandlers = new Set<(w: number, h: number) => void>();
158
+ /** element id → observeSize callbacks, fired on each setRects push. */
159
+ private readonly _sizeObservers = new Map<number, Set<(rect: MeasuredRect) => void>>();
160
+ /** ids a `measure` op was already emitted for — dedupes observeSize and
161
+ * the lazy auto-request a bare geometry read performs. */
162
+ readonly _measureRequested = new Set<number>();
143
163
  /** The window facade bundle — built lazily by windowFacadeFor, shared by
144
164
  * installDomShim and the instance dispatcher. */
145
165
  _windowBundle?: WindowFacadeBundle;
@@ -198,11 +218,21 @@ export class InternalDocument implements ProxyDocument {
198
218
  }
199
219
  }
200
220
 
201
- _warn(feature: string): void {
221
+ _warn(feature: string, message?: string): void {
202
222
  if (this._warned.has(feature)) return;
203
223
  this._warned.add(feature);
204
224
  console.warn(
205
- `[proxyDom] '${feature}' has no measurement channel — the only geometry the worker sees is the pushed container size (setSize); reads on unmarked elements return 0/empty.`,
225
+ message ??
226
+ `[proxyDom] '${feature}' has no synchronous answer — a measure request was emitted and reads return real geometry once the driver pushes it; subscribe via doc.observeSize or a ResizeObserver for updates.`,
227
+ );
228
+ }
229
+
230
+ /** Scroll-geometry warn — the measure channel delivers border boxes, not
231
+ * scroll offsets/overflow extents, so scroll* reads stay honest 0. */
232
+ _scrollWarn(feature: string): void {
233
+ this._warn(
234
+ feature,
235
+ `[proxyDom] '${feature}' has no channel — the worker sees border-box geometry only (setSize/measure pushes); scroll offsets and overflow extents read 0.`,
206
236
  );
207
237
  }
208
238
 
@@ -212,13 +242,71 @@ export class InternalDocument implements ProxyDocument {
212
242
  }
213
243
 
214
244
  /**
215
- * The size a geometry read on `el` may report: the pushed container box
216
- * when `el` is the root or was markContainer()ed, undefined otherwise
245
+ * Emit `measure` for `el` exactly once — shared by observeSize (explicit
246
+ * subscription) and by geometry reads on unmarked connected elements (the
247
+ * lazy path: a library reading clientWidth gets the honest 0 for now, and
248
+ * REAL geometry on subsequent reads once the driver's push lands).
249
+ * Disconnected/phantom nodes have no driver-side element to measure;
250
+ * requesting is skipped until the read happens while connected.
251
+ */
252
+ _requestMeasure(el: ProxyElement): void {
253
+ if (!el.isConnected) return;
254
+ const id = el.instance.id;
255
+ if (this._measureRequested.has(id)) return;
256
+ this._measureRequested.add(id);
257
+ el._op({ t: 'measure', id });
258
+ }
259
+
260
+ observeSize(el: ProxyElement, cb: (rect: MeasuredRect) => void): () => void {
261
+ this._assertAlive();
262
+ const id = el.instance.id;
263
+ let set = this._sizeObservers.get(id);
264
+ if (set === undefined) this._sizeObservers.set(id, (set = new Set()));
265
+ set.add(cb);
266
+ // Connected or not, request now: the driver's ResizeObserver delivers
267
+ // the first rect when the element mounts, so an early observe is safe.
268
+ if (!this._measureRequested.has(id)) {
269
+ this._measureRequested.add(id);
270
+ el._op({ t: 'measure', id });
271
+ }
272
+ return () => {
273
+ const observers = this._sizeObservers.get(id);
274
+ if (observers === undefined) return;
275
+ observers.delete(cb);
276
+ if (observers.size === 0) {
277
+ this._sizeObservers.delete(id);
278
+ this._measureRequested.delete(id);
279
+ el._op({ t: 'unmeasure', id });
280
+ }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Store pushed rects and fire per-element observers — the `setRects`
286
+ * task calls this inside the instance's scope, so observer mutations
287
+ * emit ops and ride back in the task's return batch.
288
+ */
289
+ _notifyRects(rects: MeasuredRect[]): void {
290
+ for (const rect of rects) {
291
+ setInstanceRect(this.instance, rect);
292
+ const observers = this._sizeObservers.get(rect.id);
293
+ if (observers !== undefined) for (const cb of [...observers]) cb(rect);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * The rect a geometry read on `el` may report: the element's own measured
299
+ * box when a setRects push arrived for it, else the pushed CONTAINER box
300
+ * when `el` is the root or was markContainer()ed (the old contract — a
301
+ * marked element claims "my box is the container's"), undefined otherwise
217
302
  * (also undefined when nothing has been pushed yet — reads return 0).
218
303
  */
219
- _sizeFor(el: ProxyElement): { w: number; h: number } | undefined {
304
+ _rectFor(el: ProxyElement): { x: number; y: number; w: number; h: number } | undefined {
305
+ const measured = getInstanceRect(this.instance, el.instance.id);
306
+ if (measured !== undefined) return measured;
220
307
  if (!this._containerIds.has(el.instance.id)) return undefined;
221
- return getInstanceSize(this.instance);
308
+ const size = getInstanceSize(this.instance);
309
+ return size === undefined ? undefined : { x: 0, y: 0, ...size };
222
310
  }
223
311
 
224
312
  onResize(cb: (w: number, h: number) => void): void {
@@ -288,7 +376,7 @@ export class InternalDocument implements ProxyDocument {
288
376
  if (self !== undefined) out.currentTarget = self;
289
377
  const id = payload.targetId;
290
378
  if (typeof id === 'number') {
291
- const instance = instances.get(id);
379
+ const instance = lookupInstance(id);
292
380
  if (instance !== undefined) {
293
381
  const node = this._wrap(instance);
294
382
  // Reflect the dispatched form state into the SHADOW attrs —
@@ -311,7 +399,7 @@ export class InternalDocument implements ProxyDocument {
311
399
  // (zrender's isLocalEl climbs parentNode to the listener root) to tell
312
400
  // "moved to a child" apart from "actually left".
313
401
  if (typeof payload.relatedTargetId === 'number') {
314
- const related = instances.get(payload.relatedTargetId);
402
+ const related = lookupInstance(payload.relatedTargetId);
315
403
  if (related !== undefined) out.relatedTarget = this._wrap(related);
316
404
  }
317
405
  if (out.composedPath === undefined) {
@@ -528,11 +616,23 @@ export class InternalDocument implements ProxyDocument {
528
616
  return out;
529
617
  }
530
618
 
531
- getComputedStyle(_el: ProxyElement): CSSStyleDeclaration {
532
- this._warn('getComputedStyle');
533
- // Every property reads ''; getPropertyValue('x') returns '' too.
534
- return new Proxy({} as Record<string, unknown>, {
535
- get: (_t, prop) => (prop === 'getPropertyValue' ? () => '' : ''),
619
+ getComputedStyle(el: ProxyElement): CSSStyleDeclaration {
620
+ // A measured box doubles as the computed box — 'width'/'height' are
621
+ // what layout libraries actually read (zrender's parseInt(stl.width),
622
+ // echarts' elementFromPoint-free sizing). Every other property stays
623
+ // the honest empty string.
624
+ const rect = this._rectFor(el);
625
+ const w = rect === undefined ? '' : `${rect.w}px`;
626
+ const h = rect === undefined ? '' : `${rect.h}px`;
627
+ if (rect === undefined) {
628
+ this._requestMeasure(el);
629
+ this._warn('getComputedStyle');
630
+ }
631
+ return new Proxy({ width: w, height: h } as Record<string, unknown>, {
632
+ get: (t, prop) =>
633
+ prop === 'getPropertyValue'
634
+ ? (k: string): string => (k === 'width' ? w : k === 'height' ? h : '')
635
+ : (t[prop as string] ?? ''),
536
636
  }) as unknown as CSSStyleDeclaration;
537
637
  }
538
638
 
@@ -544,6 +644,8 @@ export class InternalDocument implements ProxyDocument {
544
644
  this._disposed = true;
545
645
  if (instanceDocs.get(this.instance) === this) instanceDocs.delete(this.instance);
546
646
  this._resizeHandlers.clear();
647
+ this._sizeObservers.clear();
648
+ this._measureRequested.clear();
547
649
  for (const hid of this._handlerIds) unregisterHandler(hid);
548
650
  this._handlerIds.clear();
549
651
  }
@@ -16,6 +16,7 @@ import type { EventPayload, Op } from '../../ops';
16
16
  import {
17
17
  ProxyNode,
18
18
  ProxyFragment,
19
+ camelize,
19
20
  listenerOptsForWire,
20
21
  normalizeListenerOptions,
21
22
  type ProxyEventHandler,
@@ -437,16 +438,18 @@ export class ProxyElement extends ProxyNode {
437
438
  return copy;
438
439
  }
439
440
 
440
- /* ── Pushed-size geometry. The ONLY measured box is the island container:
441
- * `doc.body`/`documentElement` and markContainer()ed elements report the
442
- * last setSize push; everything else keeps the honest 0 and warns once
443
- * per document per API. A marked element reports the CONTAINER's box —
444
- * the driver can only measure the island's root, so "marked" means
445
- * "pretend my box is the container's", which is only honest for elements
446
- * that genuinely fill it. ── */
441
+ /* ── Geometry. Two pushed channels: the island container's setSize box
442
+ * (root + markContainer()ed elements) and per-element setRects boxes for
443
+ * elements the worker asked the driver to measure — via doc.observeSize,
444
+ * a ResizeObserver shim, or lazily by any of the reads below. Until the
445
+ * first push lands a read keeps the honest 0 and warns once per doc per
446
+ * API; a marked element keeps reporting the CONTAINER's box — "marked"
447
+ * still means "pretend my box is the container's" and is only honest
448
+ * for elements that genuinely fill it. ── */
447
449
  getBoundingClientRect(): DOMRect {
448
- const size = this.doc._sizeFor(this);
449
- if (size === undefined) {
450
+ const rect = this.doc._rectFor(this);
451
+ if (rect === undefined) {
452
+ this.doc._requestMeasure(this);
450
453
  this.doc._warn('getBoundingClientRect');
451
454
  return {
452
455
  x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0,
@@ -454,22 +457,23 @@ export class ProxyElement extends ProxyNode {
454
457
  } as DOMRect;
455
458
  }
456
459
  return {
457
- x: 0, y: 0, top: 0, left: 0, right: size.w, bottom: size.h,
458
- width: size.w, height: size.h,
460
+ x: rect.x, y: rect.y, top: rect.y, left: rect.x,
461
+ right: rect.x + rect.w, bottom: rect.y + rect.h,
462
+ width: rect.w, height: rect.h,
459
463
  toJSON: () => ({}),
460
464
  } as DOMRect;
461
465
  }
462
466
  get clientWidth(): number {
463
- return this._geom('clientWidth', (s) => s.w);
467
+ return this._geom('clientWidth', (r) => r.w);
464
468
  }
465
469
  get clientHeight(): number {
466
- return this._geom('clientHeight', (s) => s.h);
470
+ return this._geom('clientHeight', (r) => r.h);
467
471
  }
468
472
  get offsetWidth(): number {
469
- return this._geom('offsetWidth', (s) => s.w);
473
+ return this._geom('offsetWidth', (r) => r.w);
470
474
  }
471
475
  get offsetHeight(): number {
472
- return this._geom('offsetHeight', (s) => s.h);
476
+ return this._geom('offsetHeight', (r) => r.h);
473
477
  }
474
478
  /** Border edge widths — honestly 0 (the pushed box doesn't measure borders). */
475
479
  get clientLeft(): number {
@@ -489,25 +493,25 @@ export class ProxyElement extends ProxyNode {
489
493
  return null;
490
494
  }
491
495
  get scrollTop(): number {
492
- this.doc._warn('scrollTop');
496
+ this.doc._scrollWarn('scrollTop');
493
497
  return 0;
494
498
  }
495
499
  set scrollTop(_v: number) {
496
- this.doc._warn('scrollTop');
500
+ this.doc._scrollWarn('scrollTop');
497
501
  }
498
502
  get scrollLeft(): number {
499
- this.doc._warn('scrollLeft');
503
+ this.doc._scrollWarn('scrollLeft');
500
504
  return 0;
501
505
  }
502
506
  set scrollLeft(_v: number) {
503
- this.doc._warn('scrollLeft');
507
+ this.doc._scrollWarn('scrollLeft');
504
508
  }
505
509
  get scrollHeight(): number {
506
- this.doc._warn('scrollHeight');
510
+ this.doc._scrollWarn('scrollHeight');
507
511
  return 0;
508
512
  }
509
513
  get scrollWidth(): number {
510
- this.doc._warn('scrollWidth');
514
+ this.doc._scrollWarn('scrollWidth');
511
515
  return 0;
512
516
  }
513
517
 
@@ -622,21 +626,51 @@ export class ProxyElement extends ProxyNode {
622
626
  }
623
627
 
624
628
  /* internals */
625
- /** Geometry read shared by the pushed-size getters above. */
626
- _geom(feature: string, pick: (size: { w: number; h: number }) => number): number {
627
- const size = this.doc._sizeFor(this);
628
- if (size === undefined) {
629
+ /** Geometry read shared by the size getters above. */
630
+ _geom(
631
+ feature: string,
632
+ pick: (rect: { x: number; y: number; w: number; h: number }) => number,
633
+ ): number {
634
+ const rect = this.doc._rectFor(this);
635
+ if (rect === undefined) {
636
+ // Lazy measure request — the read keeps its honest 0 for now, but
637
+ // the driver's setRects push makes later reads real (and observeSize
638
+ // subscribers fire).
639
+ this.doc._requestMeasure(this);
629
640
  this.doc._warn(feature);
630
641
  return 0;
631
642
  }
632
- return pick(size);
643
+ return pick(rect);
633
644
  }
634
645
  _writeStyle(key: string, value: string): void {
635
646
  this.doc._assertAlive();
647
+ // '' is the protocol's "clear this key" — tracked as an empty entry so
648
+ // `key in style` stays true for touched props, like real CSSOM.
636
649
  if (this._styleProps[key] === value) return;
637
650
  this._styleProps[key] = value;
638
651
  this._op({ t: 'style', id: this.instance.id, props: { [key]: value } });
639
652
  }
653
+
654
+ /** style.cssText — a FULL replace, not another key. Parse the declarations
655
+ * and write each through the dedup path; keys absent from the new text
656
+ * clear. Storing the text under a 'cssText' key left stale per-key values
657
+ * that deduped real writes away (a hide's `visibility:hidden` stuck after
658
+ * a show's cssText replay). */
659
+ _replaceStyleText(cssText: string): void {
660
+ const next = new Map<string, string>();
661
+ for (const decl of cssText.split(';')) {
662
+ const sep = decl.indexOf(':');
663
+ if (sep <= 0) continue;
664
+ const raw = decl.slice(0, sep).trim();
665
+ const value = decl.slice(sep + 1).trim();
666
+ if (raw === '' || value === '') continue;
667
+ next.set(raw.startsWith('--') ? raw : camelize(raw), value);
668
+ }
669
+ for (const key of Object.keys(this._styleProps)) {
670
+ if (!next.has(key)) this._writeStyle(key, '');
671
+ }
672
+ for (const [key, value] of next) this._writeStyle(key, value);
673
+ }
640
674
  private _setAttr(name: string, value: string): void {
641
675
  this.doc._assertAlive();
642
676
  const v = String(value);
@@ -94,6 +94,7 @@ export function serializeNode(node: ProxyNode): string {
94
94
  const tag = node.instance.type;
95
95
  const attrs = new Map(node._attrs);
96
96
  const styleText = Object.entries(node._styleProps)
97
+ .filter(([, v]) => v !== '')
97
98
  .map(([k, v]) => `${hyphenate(k)}: ${v};`)
98
99
  .join(' ');
99
100
  if (styleText !== '') attrs.set('style', styleText);
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  allocId,
3
+ attachInstance,
3
4
  bumpOpsVersion,
5
+ detachInstance,
4
6
  getActiveInstance,
5
7
  getLastActiveInstance,
6
8
  getInstanceSize,
@@ -251,11 +253,21 @@ export class ProxyNode {
251
253
  this._children.splice(index, 0, child);
252
254
  if (ref.instance.id > 0) beforeId = ref.instance.id;
253
255
  }
256
+ // Restore instance/handler records if this child was previously
257
+ // tombstoned by a removeChild — re-append is legal (renderers reorder).
258
+ child._attachRecords();
254
259
  // Phantom nodes (fragments, shadow-only texts) have NEGATIVE ids and no
255
260
  // driver-side counterpart — skip ops when either endpoint is phantom.
256
261
  // Id 0 is NOT phantom: it's the island's real root container.
257
262
  if (this.instance.id >= 0 && child.instance.id > 0) {
258
263
  this._op({ t: 'append', parent: this.instance.id, child: child.instance.id, before: beforeId });
264
+ // A `remove` op unobserved this element driver-side — if it was still
265
+ // measure-requested worker-side, re-request so the driver's RO
266
+ // re-arms on the fresh attachment. Subtree descendants keep their
267
+ // driver-side observation (only the removed ROOT unobserved).
268
+ if (child.nodeType === 1 && this.doc._measureRequested.has(child.instance.id)) {
269
+ child._op({ t: 'measure', id: child.instance.id });
270
+ }
259
271
  }
260
272
  return child;
261
273
  }
@@ -268,6 +280,9 @@ export class ProxyNode {
268
280
  }
269
281
  this._children.splice(index, 1);
270
282
  child._parent = null;
283
+ // Tombstone the subtree's instance/handler records — without this the
284
+ // shared maps retain every node ever created. Re-append restores them.
285
+ child._detachRecords();
271
286
  if (this.instance.id >= 0 && child.instance.id > 0) {
272
287
  this._op({ t: 'remove', child: child.instance.id });
273
288
  }
@@ -351,6 +366,27 @@ export class ProxyNode {
351
366
  if (i !== -1) this._children.splice(i, 1);
352
367
  child._parent = null;
353
368
  }
369
+
370
+ /** Handler ids this node's records own: addEventListener hids (element
371
+ * subclass only) plus the prop-listener slots on the instance record. */
372
+ _recordHandlerIds(): number[] {
373
+ const listeners = (this as { _listeners?: Array<{ hid: number }> })._listeners;
374
+ const slots = (this.instance as ElementInstance).listenerSlots;
375
+ return [...(listeners?.map((l) => l.hid) ?? []), ...Object.values(slots ?? {})];
376
+ }
377
+
378
+ /** Tombstone this subtree's instance/handler records (removeChild). */
379
+ _detachRecords(): void {
380
+ if (this.instance.id > 0) detachInstance(this.instance.id, this._recordHandlerIds());
381
+ for (const c of this._children) c._detachRecords();
382
+ }
383
+
384
+ /** Restore this subtree's records on re-append (idempotent — a live node
385
+ * is simply not in the tombstone maps). */
386
+ _attachRecords(): void {
387
+ if (this.instance.id > 0) attachInstance(this.instance.id, this._recordHandlerIds());
388
+ for (const c of this._children) c._attachRecords();
389
+ }
354
390
  }
355
391
 
356
392
  /* ── ProxyText ─────────────────────────────────────────────────────────── */
@@ -0,0 +1,104 @@
1
+ import type { MeasuredRect } from '../../ops';
2
+ import {
3
+ activeInstanceDoc,
4
+ ambientDoc,
5
+ type InternalDocument,
6
+ } from './document';
7
+ import { ProxyElement } from './element';
8
+ import type { HostInstance } from '../instance';
9
+
10
+ /**
11
+ * A `ResizeObserver` over the pushed-measure channel — the class
12
+ * `globalThis.ResizeObserver`/`window.ResizeObserver` resolve to inside an
13
+ * island instance. Each `observe(el)` registers a `doc.observeSize`
14
+ * subscription (which emits the `measure` op once per element); each pushed
15
+ * rect invokes the callback with a single-entry batch, matching how real RO
16
+ * delivers per-element observations (the driver already batches the wire
17
+ * push, the DOM API just doesn't promise same-batch grouping).
18
+ *
19
+ * Entries carry the fields layout libraries read: `target`, `contentRect`
20
+ * (the measured box), and `borderBoxSize`/`contentBoxSize`/`devicePixelContentBoxSize`
21
+ * arrays (echarts zrender, recharts' ResponsiveContainer, and RO-polyfill-
22
+ * style code paths). Box options are accepted but ignored — the channel
23
+ * delivers one box and it answers both reads.
24
+ *
25
+ * No doc resolvable (module-scope or post-unmount construction) means there
26
+ * is nothing that could ever be measured — observe() silently no-ops rather
27
+ * than pinning a foreign document, the same resolution rule the
28
+ * `document`/`window` global dispatchers use.
29
+ */
30
+ export class ProxyResizeObserver {
31
+ private readonly _callback: ResizeObserverCallback;
32
+ /** proxy el → the observeSize detacher (unobserve/disconnect paths). */
33
+ private readonly _targets = new Map<ProxyElement, () => void>();
34
+
35
+ constructor(callback: ResizeObserverCallback) {
36
+ this._callback = callback;
37
+ }
38
+
39
+ observe(target: unknown, _options?: ResizeObserverOptions): void {
40
+ const doc = activeInstanceDoc() ?? ambientDoc();
41
+ if (doc === undefined) return;
42
+ const el = toProxyElement(doc, target);
43
+ if (el === null || this._targets.has(el)) return;
44
+ const unobserve = (doc as InternalDocument).observeSize(el, (rect) => {
45
+ this._callback(
46
+ [entryFor(el, rect)] as unknown as ResizeObserverEntry[],
47
+ this as unknown as ResizeObserver,
48
+ );
49
+ });
50
+ this._targets.set(el, unobserve);
51
+ }
52
+
53
+ unobserve(target: unknown): void {
54
+ const doc = activeInstanceDoc() ?? ambientDoc();
55
+ if (doc === undefined) return;
56
+ const el = toProxyElement(doc, target);
57
+ if (el === null) return;
58
+ this._targets.get(el)?.();
59
+ this._targets.delete(el);
60
+ }
61
+
62
+ disconnect(): void {
63
+ for (const unobserve of this._targets.values()) unobserve();
64
+ this._targets.clear();
65
+ }
66
+ }
67
+
68
+ /** Normalize an observe target — React/framework refs arrive adopted
69
+ * (ProxyElement) already, but a raw HostInstance record reached through a
70
+ * portal/instance lookup adopts on the spot. Anything else can't be
71
+ * measured. */
72
+ const toProxyElement = (doc: InternalDocument, target: unknown): ProxyElement | null => {
73
+ if (target instanceof ProxyElement) return target;
74
+ if (
75
+ target !== null &&
76
+ typeof target === 'object' &&
77
+ (target as HostInstance).kind === 'element'
78
+ ) {
79
+ const adopted = doc.adopt(target as HostInstance);
80
+ return adopted instanceof ProxyElement ? adopted : null;
81
+ }
82
+ return null;
83
+ };
84
+
85
+ /** The per-push entry: measured box as contentRect plus the *BoxSize
86
+ * triple libraries feature-probe for. `target` is the proxy element so
87
+ * handlers can correlate against their own refs. */
88
+ const entryFor = (el: ProxyElement, rect: MeasuredRect): Record<string, unknown> => ({
89
+ target: el,
90
+ contentRect: {
91
+ x: rect.x,
92
+ y: rect.y,
93
+ top: rect.y,
94
+ left: rect.x,
95
+ right: rect.x + rect.w,
96
+ bottom: rect.y + rect.h,
97
+ width: rect.w,
98
+ height: rect.h,
99
+ toJSON: () => ({}),
100
+ },
101
+ borderBoxSize: [{ inlineSize: rect.w, blockSize: rect.h }],
102
+ contentBoxSize: [{ inlineSize: rect.w, blockSize: rect.h }],
103
+ devicePixelContentBoxSize: [{ inlineSize: rect.w, blockSize: rect.h }],
104
+ });
@@ -23,6 +23,7 @@ import {
23
23
  type ProxyDocument,
24
24
  } from './document';
25
25
  import { ProxyElement } from './element';
26
+ import { ProxyResizeObserver } from './resizeObserver';
26
27
  import {
27
28
  ProxyNode,
28
29
  ProxyText,
@@ -58,6 +59,9 @@ export interface WindowShim {
58
59
  ): number;
59
60
  clearInterval(id?: number): void;
60
61
  getComputedStyle(el: unknown): Record<string, never>;
62
+ /** ResizeObserver over the pushed-measure channel — observe() registers
63
+ * a doc.observeSize subscription, pushed rects arrive as entries. */
64
+ ResizeObserver: typeof ProxyResizeObserver;
61
65
  /** Media queries can't be answered worker-side — always `matches: false`. */
62
66
  matchMedia(query: string): MediaQueryList;
63
67
  addEventListener(
@@ -251,9 +255,14 @@ const buildWindowFacade = (internal: InternalDocument): WindowFacadeBundle => {
251
255
  intervals.delete(id);
252
256
  globalClearInterval(id);
253
257
  },
254
- // No measurement channel — an empty declaration, same honesty rule as
255
- // doc.getComputedStyle (which warns once); the facade stays silent.
256
- getComputedStyle: () => ({}),
258
+ // Delegates to doc.getComputedStyle — measured elements report real
259
+ // width/height ('NNpx'), the unmeasured path warns once doc-side.
260
+ getComputedStyle: (el: unknown) =>
261
+ (el instanceof ProxyElement ? internal.getComputedStyle(el) : {}) as Record<string, never>,
262
+ // The measure channel as a DOM-standard observer — chart/map libraries
263
+ // that size off ResizeObserver (echarts, recharts ResponsiveContainer)
264
+ // get real pushed boxes unmodified.
265
+ ResizeObserver: ProxyResizeObserver,
257
266
  // No preference/quiz surface exists worker-side — media queries never
258
267
  // match. The shape is complete enough for feature-detection code.
259
268
  matchMedia: (query: string) =>
@@ -356,6 +365,7 @@ export function installInstanceDispatcher(): void {
356
365
  const prevComment = g.Comment;
357
366
  const prevMedia = g.HTMLMediaElement;
358
367
  const prevCustomElements = g.customElements;
368
+ const prevResizeObserver = g.ResizeObserver;
359
369
  // Explicit assignments override the fallback (never the in-instance doc) —
360
370
  // keeps pre-dispatcher semantics for out-of-instance code, and lets test
361
371
  // harnesses/suites restore globals by plain assignment.
@@ -438,6 +448,9 @@ export function installInstanceDispatcher(): void {
438
448
  prevCustomElements !== undefined ? prevCustomElements : CUSTOM_ELEMENTS_STUB,
439
449
  prevCustomElements,
440
450
  );
451
+ // A REAL ResizeObserver (happy-dom tests) keeps serving out-of-instance
452
+ // code; in-instance resolves to the measure-channel shim.
453
+ installClassGlobal('ResizeObserver', ProxyResizeObserver, prevResizeObserver);
441
454
  }
442
455
 
443
456
  /**
@@ -492,6 +505,7 @@ export function installDomShim(doc: ProxyDocument): () => void {
492
505
  const prevComment = g.Comment;
493
506
  const prevMedia = g.HTMLMediaElement;
494
507
  const prevCustomElements = g.customElements;
508
+ const prevResizeObserver = g.ResizeObserver;
495
509
 
496
510
  // The installed document becomes the ambient one for out-of-instance
497
511
  // readers: the explicit assignment wins the dispatcher's resolution,
@@ -506,6 +520,7 @@ export function installDomShim(doc: ProxyDocument): () => void {
506
520
  g.Comment = ProxyComment;
507
521
  if (typeof g.HTMLMediaElement !== 'function') g.HTMLMediaElement = HTML_MEDIA_STUB;
508
522
  if (g.customElements === undefined) g.customElements = CUSTOM_ELEMENTS_STUB;
523
+ if (g.ResizeObserver === undefined) g.ResizeObserver = ProxyResizeObserver;
509
524
 
510
525
  const uninstall = (): void => {
511
526
  bundle.teardown();
@@ -518,6 +533,7 @@ export function installDomShim(doc: ProxyDocument): () => void {
518
533
  g.Comment = prevComment;
519
534
  g.HTMLMediaElement = prevMedia;
520
535
  g.customElements = prevCustomElements;
536
+ g.ResizeObserver = prevResizeObserver;
521
537
  markInstanceActive(prevInstance);
522
538
  if (activeShimUninstall === uninstall) activeShimUninstall = null;
523
539
  };
@@ -69,6 +69,7 @@ export {
69
69
  ProxyElement,
70
70
  ProxyFragment,
71
71
  ProxyNode,
72
+ ProxyResizeObserver,
72
73
  ProxyText,
73
74
  docForInstance,
74
75
  } from './proxyDom';
@@ -82,7 +83,7 @@ export type {
82
83
  WireListenerOpts,
83
84
  } from './proxyDom';
84
85
 
85
- export type { EventPayload, IslandWorkerMethods, Op, WireProps } from '../ops';
86
+ export type { EventPayload, IslandWorkerMethods, MeasuredRect, Op, WireProps } from '../ops';
86
87
  export { isEventRef } from '../ops';
87
88
  export { renderMemory } from '../memory';
88
89
  export type { DoorbellSpec } from '../memory';