@dimina-kit/view-anchor 0.1.0-dev.20260610082053

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.
@@ -0,0 +1,444 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createSizeAdvertiser } from './size-advertiser.js'
3
+ import type { AdvertisedSize } from './types.js'
4
+
5
+ // ── Reverse-direction stubs ──────────────────────────────────────────
6
+ //
7
+ // Mirrors the stub style of `view-anchor.test.ts`, with two deliberate
8
+ // differences the reverse primitive forces:
9
+ //
10
+ // - The advertiser reads `entry.borderBoxSize`, NOT
11
+ // `getBoundingClientRect()`. So `fire(blockSize, inlineSize)` builds a
12
+ // standards-shaped RO entry: an array of entries, each with
13
+ // `borderBoxSize: [{ blockSize, inlineSize }]` (and a matching
14
+ // contentBoxSize, present in real entries).
15
+ // - There is no `present` flag and no ZERO/terminal value, so we never
16
+ // model a detach path.
17
+ //
18
+ // `fakeRaf`/`flushRafs` queue RAF callbacks so a test decides *when* they
19
+ // run; `cancelAnimationFrame` is a spy that also evicts the queued entry so
20
+ // a cancelled RAF can never fire (lets us assert the dispose stale-RAF guard).
21
+
22
+ interface RoSize {
23
+ blockSize: number
24
+ inlineSize: number
25
+ }
26
+ interface RoEntry {
27
+ borderBoxSize: RoSize[]
28
+ contentBoxSize: RoSize[]
29
+ target: Element
30
+ }
31
+
32
+ class FakeResizeObserver {
33
+ static instances: FakeResizeObserver[] = []
34
+ observed: Element[] = []
35
+ disconnected = false
36
+ constructor(public cb: ResizeObserverCallback) {
37
+ FakeResizeObserver.instances.push(this)
38
+ }
39
+ observe(el: Element): void {
40
+ this.observed.push(el)
41
+ }
42
+ unobserve(): void {
43
+ /* unused */
44
+ }
45
+ disconnect(): void {
46
+ this.disconnected = true
47
+ }
48
+ /** Fire the RO callback with one standards-shaped entry for the observed
49
+ * target, carrying the given border-box block/inline extents. */
50
+ fire(blockSize: number, inlineSize: number): void {
51
+ const target = this.observed[0] ?? document.createElement('div')
52
+ const entry: RoEntry = {
53
+ borderBoxSize: [{ blockSize, inlineSize }],
54
+ contentBoxSize: [{ blockSize, inlineSize }],
55
+ target,
56
+ }
57
+ this.cb([entry] as unknown as ResizeObserverEntry[], this as unknown as ResizeObserver)
58
+ }
59
+ }
60
+
61
+ interface RafEntry {
62
+ id: number
63
+ cb: () => void
64
+ }
65
+ let rafQueue: RafEntry[] = []
66
+ let rafIdCounter = 0
67
+ function fakeRaf(cb: () => void): number {
68
+ rafIdCounter++
69
+ rafQueue.push({ id: rafIdCounter, cb })
70
+ return rafIdCounter
71
+ }
72
+ const cancelSpy = vi.fn()
73
+
74
+ beforeEach(() => {
75
+ FakeResizeObserver.instances = []
76
+ rafQueue = []
77
+ rafIdCounter = 0
78
+ cancelSpy.mockClear()
79
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
80
+ vi.stubGlobal(
81
+ 'requestAnimationFrame',
82
+ fakeRaf as unknown as typeof window.requestAnimationFrame,
83
+ )
84
+ vi.stubGlobal(
85
+ 'cancelAnimationFrame',
86
+ ((id: number) => {
87
+ cancelSpy(id)
88
+ // A cancelled RAF must never fire — evict it so flushRafs() can't run a
89
+ // callback the advertiser cancelled.
90
+ rafQueue = rafQueue.filter((e) => e.id !== id)
91
+ }) as unknown as typeof window.cancelAnimationFrame,
92
+ )
93
+ })
94
+
95
+ afterEach(() => {
96
+ vi.restoreAllMocks()
97
+ vi.unstubAllGlobals()
98
+ })
99
+
100
+ function flushRafs(): void {
101
+ const q = rafQueue
102
+ rafQueue = []
103
+ q.forEach((e) => e.cb())
104
+ }
105
+
106
+ /** Assert an observer was installed, then return it — a missing observer
107
+ * (stub no-op) surfaces as a clear "expected >= 1, got 0" instead of a
108
+ * TypeError on a later `.fire()`. */
109
+ function firstObserver(): FakeResizeObserver {
110
+ expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
111
+ return FakeResizeObserver.instances[0]!
112
+ }
113
+
114
+ /** A bare element. If the advertiser were (wrongly) reading geometry from
115
+ * getBoundingClientRect instead of the RO entry, this spy would catch it. */
116
+ function buildElement(): { el: HTMLElement; rectSpy: ReturnType<typeof vi.fn> } {
117
+ const el = document.createElement('div')
118
+ const rectSpy = vi.fn(() => {
119
+ throw new Error('getBoundingClientRect must not be called by the advertiser')
120
+ })
121
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
122
+ rectSpy as unknown as () => DOMRect,
123
+ )
124
+ return { el, rectSpy }
125
+ }
126
+
127
+ // ── Contract 1: source = RO border-box, never getBoundingClientRect ──
128
+ // Bug it catches: an advertiser that measures via getBoundingClientRect
129
+ // (content/layout box, affected by transforms/zoom) instead of the RO
130
+ // entry's border-box advertises the wrong number — and re-introduces the
131
+ // forced-reflow the RO path exists to avoid.
132
+
133
+ describe('createSizeAdvertiser — source is the RO border-box', () => {
134
+ it('reads borderBoxSize from the entry and never calls getBoundingClientRect', () => {
135
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
136
+ const { el, rectSpy } = buildElement()
137
+ createSizeAdvertiser(el, { axis: 'block', publish })
138
+ publish.mockClear()
139
+
140
+ // block axis must come from blockSize (200), not inlineSize (999).
141
+ firstObserver().fire(200, 999)
142
+ flushRafs()
143
+
144
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 200 })
145
+ expect(rectSpy).not.toHaveBeenCalled()
146
+ })
147
+
148
+ it('axis:inline reads inlineSize from the border-box entry', () => {
149
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
150
+ const { el } = buildElement()
151
+ createSizeAdvertiser(el, { axis: 'inline', publish })
152
+ publish.mockClear()
153
+
154
+ // inline axis must come from inlineSize (321), not blockSize (10).
155
+ firstObserver().fire(10, 321)
156
+ flushRafs()
157
+
158
+ expect(publish).toHaveBeenCalledWith({ axis: 'inline', extent: 321 })
159
+ })
160
+ })
161
+
162
+ // ── Contract 2: payload is a single-axis scalar ──────────────────────
163
+ // Bug it catches: a payload that leaks the second axis (or whose `axis`
164
+ // drifts from the owned axis) breaks the host's single-axis DAG invariant —
165
+ // the cross-process loop could feed both axes and oscillate.
166
+
167
+ describe('createSizeAdvertiser — single-axis scalar payload', () => {
168
+ it('publishes only { axis, extent } with axis equal to the owned axis', () => {
169
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
170
+ const { el } = buildElement()
171
+ createSizeAdvertiser(el, { axis: 'block', publish })
172
+ publish.mockClear()
173
+
174
+ firstObserver().fire(150, 280)
175
+ flushRafs()
176
+
177
+ expect(publish).toHaveBeenCalledTimes(1)
178
+ const payload = publish.mock.calls[0]![0]
179
+ expect(payload.axis).toBe('block')
180
+ expect(payload.extent).toBe(150)
181
+ // No second-axis field smuggled in.
182
+ expect(Object.keys(payload).sort()).toEqual(['axis', 'extent'])
183
+ })
184
+ })
185
+
186
+ // ── Contract 3: quantize + clamp-to-zero ─────────────────────────────
187
+ // Bug it catches: an advertiser that forwards raw subpixel floats causes
188
+ // per-frame jitter in the host's sizing; one that *drops* a negative frame
189
+ // (instead of clamping to 0) leaves the host stuck at a stale extent.
190
+
191
+ describe('createSizeAdvertiser — quantize and clamp', () => {
192
+ it('rounds the raw extent (Math.round)', () => {
193
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
194
+ const { el } = buildElement()
195
+ createSizeAdvertiser(el, { axis: 'block', publish })
196
+ publish.mockClear()
197
+
198
+ firstObserver().fire(100.49, 0)
199
+ flushRafs()
200
+
201
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 100 })
202
+ })
203
+
204
+ it('clamps a negative extent to 0 and STILL publishes that frame (not dropped)', () => {
205
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
206
+ const { el } = buildElement()
207
+ createSizeAdvertiser(el, { axis: 'block', publish })
208
+ publish.mockClear()
209
+
210
+ firstObserver().fire(-7.2, 0)
211
+ flushRafs()
212
+
213
+ expect(publish).toHaveBeenCalledTimes(1)
214
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 0 })
215
+ })
216
+ })
217
+
218
+ // ── Contract 4: hygiene filter drops the whole frame ─────────────────
219
+ // Bug it catches: a NaN/Infinity extent (degenerate layout, detached node)
220
+ // published as-is corrupts the host's size; the contract is to drop the
221
+ // frame entirely, not clamp/coerce it to some number.
222
+
223
+ describe('createSizeAdvertiser — non-finite values drop the frame', () => {
224
+ it('does not publish when the raw extent is NaN', () => {
225
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
226
+ const { el } = buildElement()
227
+ createSizeAdvertiser(el, { axis: 'block', publish })
228
+ publish.mockClear()
229
+
230
+ firstObserver().fire(NaN, 100)
231
+ flushRafs()
232
+
233
+ expect(publish).not.toHaveBeenCalled()
234
+ })
235
+
236
+ it('does not publish when the raw extent is Infinity', () => {
237
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
238
+ const { el } = buildElement()
239
+ createSizeAdvertiser(el, { axis: 'block', publish })
240
+ publish.mockClear()
241
+
242
+ firstObserver().fire(Infinity, 100)
243
+ flushRafs()
244
+
245
+ expect(publish).not.toHaveBeenCalled()
246
+ })
247
+ })
248
+
249
+ // ── Contract 5: RAF coalescing ───────────────────────────────────────
250
+ // Bug it catches: a synchronous publish per RO tick floods IPC; the
251
+ // contract is to coalesce N triggers in one frame into a single RAF and a
252
+ // single publish.
253
+
254
+ describe('createSizeAdvertiser — RAF coalescing', () => {
255
+ it('multiple RO ticks in one frame schedule one RAF and publish once', () => {
256
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
257
+ const { el } = buildElement()
258
+ createSizeAdvertiser(el, { axis: 'block', publish })
259
+ publish.mockClear()
260
+
261
+ const ro = firstObserver()
262
+ // Distinct extents per tick — only the last, coalesced value should emit.
263
+ ro.fire(100, 0)
264
+ ro.fire(150, 0)
265
+ ro.fire(200, 0)
266
+
267
+ // Nothing synchronous; exactly one RAF queued for the whole burst.
268
+ expect(publish).not.toHaveBeenCalled()
269
+ expect(rafQueue).toHaveLength(1)
270
+
271
+ flushRafs()
272
+ expect(publish).toHaveBeenCalledTimes(1)
273
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 200 })
274
+ })
275
+ })
276
+
277
+ // ── Contract 6: last-extent dedupe on the RO→RAF stream ──────────────
278
+ // Bug it catches: a path that re-advertises a byte-for-byte identical extent
279
+ // every frame floods the host with redundant resizes; or a dedupe that
280
+ // compares the wrong baseline so a real change is dropped.
281
+
282
+ describe('createSizeAdvertiser — last-extent dedupe', () => {
283
+ it('an unchanged extent on a follow-up RO tick does not re-publish', () => {
284
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
285
+ const { el } = buildElement()
286
+ createSizeAdvertiser(el, { axis: 'block', publish })
287
+ publish.mockClear()
288
+
289
+ firstObserver().fire(120, 0)
290
+ flushRafs()
291
+ expect(publish).toHaveBeenCalledTimes(1)
292
+
293
+ // Same extent again → silent.
294
+ firstObserver().fire(120, 0)
295
+ flushRafs()
296
+ expect(publish).toHaveBeenCalledTimes(1)
297
+ })
298
+
299
+ it('publishes again once the extent changes', () => {
300
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
301
+ const { el } = buildElement()
302
+ createSizeAdvertiser(el, { axis: 'block', publish })
303
+ publish.mockClear()
304
+
305
+ firstObserver().fire(120, 0)
306
+ flushRafs()
307
+ firstObserver().fire(140, 0)
308
+ flushRafs()
309
+
310
+ expect(publish).toHaveBeenCalledTimes(2)
311
+ expect(publish).toHaveBeenLastCalledWith({ axis: 'block', extent: 140 })
312
+ })
313
+
314
+ it('dedupe is post-quantization: 120.4 then 120 (both round to 120) emits once', () => {
315
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
316
+ const { el } = buildElement()
317
+ createSizeAdvertiser(el, { axis: 'block', publish })
318
+ publish.mockClear()
319
+
320
+ firstObserver().fire(120.4, 0)
321
+ flushRafs()
322
+ firstObserver().fire(120, 0)
323
+ flushRafs()
324
+
325
+ expect(publish).toHaveBeenCalledTimes(1)
326
+ })
327
+ })
328
+
329
+ // ── Contract 7: handle shape has no `present` ────────────────────────
330
+ // Bug it catches: a handle that exposes a `present`/attach toggle would
331
+ // imply a detach path the reverse primitive deliberately does not have.
332
+
333
+ describe('createSizeAdvertiser — handle shape', () => {
334
+ it('exposes only update and dispose (no present)', () => {
335
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
336
+ const { el } = buildElement()
337
+ const handle = createSizeAdvertiser(el, { axis: 'block', publish })
338
+
339
+ expect(typeof handle.update).toBe('function')
340
+ expect(typeof handle.dispose).toBe('function')
341
+ expect('present' in (handle as unknown as Record<string, unknown>)).toBe(
342
+ false,
343
+ )
344
+ })
345
+ })
346
+
347
+ // ── Contract 8: update() swaps the publish sink ──────────────────────
348
+ // Bug it catches: an update() that ignores the new sink keeps emitting to a
349
+ // dead channel.
350
+ //
351
+ // NOTE: `update` now takes ONLY the new publish — axis is immutable by
352
+ // construction and is no longer expressible in `update` (you cannot attempt to
353
+ // change it), so the previous "ignores a changed axis" test is gone: the
354
+ // mistake is now a compile error, not a runtime no-op.
355
+
356
+ describe('createSizeAdvertiser — update() swaps publish', () => {
357
+ it('routes subsequent emits to the new publish', () => {
358
+ const first = vi.fn<(s: AdvertisedSize) => void>()
359
+ const second = vi.fn<(s: AdvertisedSize) => void>()
360
+ const { el } = buildElement()
361
+ const handle = createSizeAdvertiser(el, { axis: 'block', publish: first })
362
+ first.mockClear()
363
+
364
+ handle.update(second)
365
+
366
+ firstObserver().fire(170, 0)
367
+ flushRafs()
368
+ expect(second).toHaveBeenCalledWith({ axis: 'block', extent: 170 })
369
+ expect(first).not.toHaveBeenCalled()
370
+ })
371
+ })
372
+
373
+ // ── Contract 9: dispose() tears down and silences ────────────────────
374
+ // Bug it catches: a dispose that forgets to disconnect the RO / cancel the
375
+ // in-flight RAF leaks observers and can advertise after teardown (the IPC
376
+ // target may already be gone → throw).
377
+
378
+ describe('createSizeAdvertiser — dispose()', () => {
379
+ it('disconnects the observer and cancels a pending RAF', () => {
380
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
381
+ const { el } = buildElement()
382
+ const handle = createSizeAdvertiser(el, { axis: 'block', publish })
383
+
384
+ // Queue a RAF so dispose has something to cancel.
385
+ firstObserver().fire(160, 0)
386
+ expect(rafQueue).toHaveLength(1)
387
+
388
+ handle.dispose()
389
+
390
+ expect(firstObserver().disconnected).toBe(true)
391
+ expect(cancelSpy).toHaveBeenCalledTimes(1)
392
+ })
393
+
394
+ it('never advertises again after dispose (later RO ticks are inert)', () => {
395
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
396
+ const { el } = buildElement()
397
+ const handle = createSizeAdvertiser(el, { axis: 'block', publish })
398
+ const ro = firstObserver()
399
+ handle.dispose()
400
+ publish.mockClear()
401
+
402
+ ro.fire(220, 0)
403
+ flushRafs()
404
+ expect(publish).not.toHaveBeenCalled()
405
+ })
406
+
407
+ it('a RAF queued before dispose() does not advertise even if flushed', () => {
408
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
409
+ const { el } = buildElement()
410
+ const handle = createSizeAdvertiser(el, { axis: 'block', publish })
411
+
412
+ firstObserver().fire(240, 0)
413
+ expect(rafQueue).toHaveLength(1)
414
+ publish.mockClear()
415
+
416
+ handle.dispose()
417
+ flushRafs() // queue empty after cancel, OR the cb bails on the guard
418
+
419
+ expect(publish).not.toHaveBeenCalled()
420
+ })
421
+ })
422
+
423
+ // ── Contract 10: initial sync emit when a size is available ──────────
424
+ // Bug it catches: an advertiser that only emits on *change* never advertises
425
+ // its first measurable size, so the host starts with no extent and the
426
+ // placeholder is mis-sized until the content next happens to resize.
427
+ //
428
+ // Per the harness note we express "can obtain an initial value" as the
429
+ // observe→first-RO-frame→flush path rather than assuming the constructor
430
+ // emits synchronously.
431
+
432
+ describe('createSizeAdvertiser — initial value', () => {
433
+ it('advertises the first measurable size once on the first RO frame', () => {
434
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
435
+ const { el } = buildElement()
436
+ createSizeAdvertiser(el, { axis: 'block', publish })
437
+
438
+ // The first frame the observer delivers carries the initial size.
439
+ firstObserver().fire(300, 0)
440
+ flushRafs()
441
+
442
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 300 })
443
+ })
444
+ })
@@ -0,0 +1,94 @@
1
+ import type {
2
+ AdvertisedSize,
3
+ SizeAdvertiserOptions,
4
+ SizeAdvertiserHandle,
5
+ } from './types.js'
6
+ import { createMeasureLoop } from './measure-loop.js'
7
+
8
+ /**
9
+ * Reverse of `createViewAnchor`: runs in a downstream WebContentsView's own
10
+ * renderer, measures the content's own size on ONE owned axis (from the
11
+ * `ResizeObserver` border-box — no `getBoundingClientRect`, no forced reflow),
12
+ * and advertises it via the injected `publish`. Shares the forward primitive's
13
+ * measure/coalesce/dedupe/dispose engine (`createMeasureLoop`).
14
+ *
15
+ * The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
16
+ * drop the frame.
17
+ *
18
+ * FOOTGUN — `target` must be shrink-to-fit on the owned axis: its owned-axis
19
+ * size must NOT be driven by the host-applied view size, or the cross-process
20
+ * loop (advertise → host resizes view → remeasure) never converges (it
21
+ * oscillates or stays "stable but wrong"). Measuring `<body>`/`<html>` is the
22
+ * classic mistake — their size *is* the view size. See
23
+ * `docs/bidirectional-design.md` §4/§5.
24
+ */
25
+ export function createSizeAdvertiser(
26
+ target: HTMLElement,
27
+ opts: SizeAdvertiserOptions,
28
+ ): SizeAdvertiserHandle {
29
+ const axis = opts.axis // immutable for the advertiser's life
30
+ let publish = opts.publish
31
+ let observer: ResizeObserver | null = null
32
+ let disposed = false
33
+ // Latest border-box, stashed by the RO callback and read by `produce` in the
34
+ // RAF body (keep the entry out of the shared, DOM-agnostic loop).
35
+ let latest: ResizeObserverSize | null = null
36
+
37
+ const produce = (): AdvertisedSize | null => {
38
+ if (!latest) return null
39
+ const raw = axis === 'block' ? latest.blockSize : latest.inlineSize
40
+ if (!Number.isFinite(raw)) return null
41
+ return { axis, extent: Math.max(0, Math.round(raw)) }
42
+ }
43
+
44
+ const loop = createMeasureLoop<AdvertisedSize>({
45
+ produce,
46
+ same: (a, b) => a.extent === b.extent, // axis is constant
47
+ sink: (size) => publish(size),
48
+ })
49
+
50
+ const onResize: ResizeObserverCallback = (entries) => {
51
+ const entry = entries[entries.length - 1]
52
+ if (entry) {
53
+ latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest
54
+ }
55
+ loop.schedule()
56
+ }
57
+
58
+ // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
59
+ const doc = target.ownerDocument
60
+ if (target === doc.body || target === doc.documentElement) {
61
+ console.warn(
62
+ `[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
63
+ `${axis} size is the host-given view size, not the content size — the ` +
64
+ `advertiser will never shrink to content. Measure a shrink-to-fit wrapper. ` +
65
+ `See bidirectional-design.md §4.`,
66
+ )
67
+ }
68
+
69
+ loop.setActive(true)
70
+ observer = new ResizeObserver(onResize)
71
+ observer.observe(target)
72
+
73
+ return {
74
+ update(nextPublish: (size: AdvertisedSize) => void): void {
75
+ if (disposed) return
76
+ publish = nextPublish
77
+ // Re-advertise the current size to the new sink immediately (mirrors the
78
+ // forward anchor's re-publish on update) so the new channel is not left
79
+ // sizeless until the next ResizeObserver tick.
80
+ const cur = produce()
81
+ if (cur) loop.emitNow(cur)
82
+ },
83
+ dispose(): void {
84
+ if (disposed) return
85
+ disposed = true
86
+ loop.cancel()
87
+ if (observer) {
88
+ observer.disconnect()
89
+ observer = null
90
+ }
91
+ loop.dispose()
92
+ },
93
+ }
94
+ }
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * view-anchor — sync a main-process native view's bounds to a DOM element.
3
+ *
4
+ * Self-contained, engine-agnostic primitive. It knows nothing about
5
+ * Electron (the `publish` callback owns the IPC → `setBounds`), nothing
6
+ * about React (the core is imperative; see `react.ts` for the adapter),
7
+ * and nothing about the host layout engine (the `target` element may come
8
+ * from our own `compile`/`FrameTree`, or — later — a dockview panel's
9
+ * `content.element`; the mechanism is identical).
10
+ *
11
+ * It is the modern, alive replacement for the archived
12
+ * `react-electron-browser-view`: the cross-process bridge that DOM layout
13
+ * libraries (dockview included) deliberately do not provide. dockview's
14
+ * internal `OverlayRenderContainer` does the same getBoundingClientRect →
15
+ * RAF → reposition dance, but its follower is a DOM node; ours is a native
16
+ * `WebContentsView` positioned via the injected `publish`.
17
+ */
18
+
19
+ /** A screen-space rectangle, in CSS pixels. Structurally compatible with
20
+ * the host's `ViewBounds` so a publisher typed against either works. */
21
+ export interface Bounds {
22
+ x: number
23
+ y: number
24
+ width: number
25
+ height: number
26
+ }
27
+
28
+ /**
29
+ * Explicit visibility + geometry for a native view, replacing the legacy
30
+ * magic-`{0,0,0,0}` "hidden" convention (`present:false → ZERO bounds`).
31
+ *
32
+ * Visibility is a DISCRIMINANT, never inferred from geometry. The whole
33
+ * reason this type exists: a genuinely zero-SIZED but on-screen view
34
+ * (`{ visible:true, bounds:{...,width:0,height:0} }`) is now distinct from a
35
+ * detached/hidden one (`{ visible:false }`, which carries no `bounds` at
36
+ * all). Under the old ZERO convention both collapsed to the same value and
37
+ * were indistinguishable.
38
+ */
39
+ export type Placement =
40
+ | { visible: true; bounds: Bounds }
41
+ | { visible: false }
42
+
43
+ export interface ViewAnchorOptions {
44
+ /**
45
+ * Whether the native view should be attached. When `false`, the anchor
46
+ * publishes zero bounds (`{0,0,0,0}`) — the host treats `width === 0 ||
47
+ * height === 0` as "detach the child view but keep its WebContents
48
+ * alive" (detach-but-keep-alive). No DOM measurement is needed in this
49
+ * state.
50
+ */
51
+ present: boolean
52
+ /** Receives the live rect, or `{0,0,0,0}` when detached. Owns IPC. */
53
+ publish: (bounds: Bounds) => void
54
+ }
55
+
56
+ export interface ViewAnchorHandle {
57
+ /**
58
+ * Apply new options. Re-publishes immediately to reflect the new state
59
+ * (present=true → measure + observe; present=false → zero bounds).
60
+ */
61
+ update(opts: ViewAnchorOptions): void
62
+ /** Stop observing and remove listeners. After dispose the anchor never
63
+ * publishes again (every emit reads `disposed` synchronously, so there is
64
+ * no queued frame that could fire late). */
65
+ dispose(): void
66
+ }
67
+
68
+ // ── Reverse direction: size advertiser ───────────────────────────────
69
+ //
70
+ // The mirror of the forward anchor. Runs in a DOWNSTREAM WebContentsView's own
71
+ // renderer: it measures the content's own size and advertises it to the host,
72
+ // which sizes the placeholder accordingly (and the forward anchor then keeps the
73
+ // view positioned). One advertiser owns exactly ONE axis — the other axis is a
74
+ // host-driven, read-only input — so the cross-process loop stays a one-way DAG.
75
+
76
+ /** Which axis this advertiser owns. `block` = height, `inline` = width
77
+ * (logical-property naming, axis-agnostic to writing mode). */
78
+ export type AdvertisedAxis = 'block' | 'inline'
79
+
80
+ /**
81
+ * One frame of advertised size. A pure scalar plus the owning axis — there is
82
+ * deliberately no field for the *other* axis, so "advertise two axes" is not
83
+ * expressible (single-axis ownership is enforced in the type, not at runtime).
84
+ */
85
+ export interface AdvertisedSize {
86
+ /** Mirrors the factory's `axis`; constant across frames. Lets the host
87
+ * whitelist-check the axis it is willing to accept. */
88
+ readonly axis: AdvertisedAxis
89
+ /** The owned axis's content extent, in CSS px — already rounded and clamped
90
+ * to `>= 0`. */
91
+ readonly extent: number
92
+ }
93
+
94
+ export interface SizeAdvertiserOptions {
95
+ /** The single axis this advertiser owns. Fixed for the advertiser's life. */
96
+ axis: AdvertisedAxis
97
+ /** Receives each advertised size. Owns the IPC/postMessage → host. Mirrors
98
+ * the forward `publish` (same role: the injected, transport-owning sink). */
99
+ publish: (size: AdvertisedSize) => void
100
+ }
101
+
102
+ export interface SizeAdvertiserHandle {
103
+ /**
104
+ * Swap the `publish` sink (e.g. a new IPC channel) and immediately
105
+ * re-advertise the current size to it (mirrors the forward anchor's
106
+ * re-publish on update), so the new channel is not left sizeless until the
107
+ * next `ResizeObserver` tick.
108
+ *
109
+ * Takes only the new sink — `axis` is immutable by construction, so it is
110
+ * deliberately not expressible here (you cannot attempt to change it). To
111
+ * advertise a different axis, dispose and create a new advertiser.
112
+ */
113
+ update(publish: (size: AdvertisedSize) => void): void
114
+ /**
115
+ * Stop observing, cancel any pending RAF. After dispose nothing is
116
+ * advertised again. (There is no ZERO/terminal value — collapsing is the
117
+ * host's policy, unlike the forward anchor's `present:false`.)
118
+ *
119
+ * The first advertised value is asynchronous: it awaits the observer's first
120
+ * frame, and a `display:none` target advertises nothing until shown.
121
+ */
122
+ dispose(): void
123
+ }