@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/measure-loop.d.ts +43 -0
- package/dist/measure-loop.d.ts.map +1 -0
- package/dist/measure-loop.js +49 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +145 -0
- package/dist/size-advertiser.d.ts +20 -0
- package/dist/size-advertiser.d.ts.map +1 -0
- package/dist/size-advertiser.js +82 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/view-anchor.d.ts +111 -0
- package/dist/view-anchor.d.ts.map +1 -0
- package/dist/view-anchor.js +396 -0
- package/docs/anchor-3d.html +602 -0
- package/docs/bidirectional-design.md +151 -0
- package/docs/mechanism.mdx +96 -0
- package/package.json +76 -0
- package/src/follow-geometry-press-drag.fix.test.ts +255 -0
- package/src/index.ts +39 -0
- package/src/measure-loop.ts +87 -0
- package/src/placement.test.ts +338 -0
- package/src/react.test.tsx +566 -0
- package/src/react.ts +171 -0
- package/src/size-advertiser.extras.test.ts +294 -0
- package/src/size-advertiser.test.ts +444 -0
- package/src/size-advertiser.ts +94 -0
- package/src/types.ts +123 -0
- package/src/view-anchor.test.ts +1184 -0
- package/src/view-anchor.ts +486 -0
|
@@ -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
|
+
}
|