@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,87 @@
1
+ /**
2
+ * Internal — the RAF-coalesced measure/dedupe/dispose engine behind the REVERSE
3
+ * primitive `createSizeAdvertiser`.
4
+ *
5
+ * The forward `createViewAnchor` deliberately does NOT use this: it publishes
6
+ * SYNCHRONOUSLY (a native overlay's `setBounds` already lands a cross-process
7
+ * frame late, and a RAF stacked a second frame of visible trailing). The
8
+ * reverse direction is different — it is a cross-process FEEDBACK loop
9
+ * (advertise → host resizes the view → content re-measures → re-advertise), so
10
+ * the RAF's one-publish-per-frame coalescing is a useful damper. The two
11
+ * directions thus have different optimal emit timing; this engine serves only
12
+ * the reverse.
13
+ *
14
+ * NOT exported from the package: it is pure mechanism with no knowledge of
15
+ * direction, the DOM, `ResizeObserver`, or the structure of the value `T` it
16
+ * carries. The wrapping primitive injects `produce` / `same` / `sink` and
17
+ * drives the lifecycle.
18
+ *
19
+ * - `schedule()` — coalesce a burst of triggers into ONE RAF; the frame
20
+ * body re-`produce()`s, dedupes against the last emit (`same`), and `sink`s.
21
+ * Bails if inactive or disposed (stale-RAF safe).
22
+ * - `emitNow(v)` — explicit synchronous emit (create / update path). Always
23
+ * fires, bypassing the dedupe check, and refreshes the dedupe baseline.
24
+ * - `setActive` — gate the observer stream; a queued frame bails on `!active`.
25
+ * - `cancel` — drop any in-flight RAF.
26
+ * - `dispose` — cancel + go inert; after dispose nothing emits again.
27
+ */
28
+ export interface MeasureLoop<T> {
29
+ schedule(): void
30
+ emitNow(value: T): void
31
+ setActive(on: boolean): void
32
+ cancel(): void
33
+ dispose(): void
34
+ }
35
+
36
+ export function createMeasureLoop<T>(cfg: {
37
+ /** Produce the value to emit in the RAF body. Return `null` to decline the
38
+ * frame entirely (no dedupe, no sink, baseline untouched) — e.g. a
39
+ * non-finite or unavailable measurement. */
40
+ produce: () => T | null
41
+ same: (a: T, b: T) => boolean
42
+ sink: (value: T) => void
43
+ }): MeasureLoop<T> {
44
+ const { produce, same, sink } = cfg
45
+ let rafId: number | null = null
46
+ let active = false
47
+ let disposed = false
48
+ let last: T | null = null
49
+
50
+ const cancel = (): void => {
51
+ if (rafId !== null) {
52
+ cancelAnimationFrame(rafId)
53
+ rafId = null
54
+ }
55
+ }
56
+
57
+ return {
58
+ schedule(): void {
59
+ if (disposed || !active || rafId !== null) return
60
+ rafId = requestAnimationFrame(() => {
61
+ rafId = null
62
+ if (disposed || !active) return
63
+ const value = produce()
64
+ if (value === null) return // producer declined this frame
65
+ // last-value dedupe: a frame whose produced value equals the last one
66
+ // we emitted costs nothing (no IPC / setBounds).
67
+ if (last !== null && same(value, last)) return
68
+ last = value
69
+ sink(value)
70
+ })
71
+ },
72
+ emitNow(value: T): void {
73
+ if (disposed) return
74
+ last = value
75
+ sink(value)
76
+ },
77
+ setActive(on: boolean): void {
78
+ active = on
79
+ },
80
+ cancel,
81
+ dispose(): void {
82
+ if (disposed) return
83
+ disposed = true
84
+ cancel()
85
+ },
86
+ }
87
+ }
@@ -0,0 +1,338 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ // ─────────────────────────────────────────────────────────────────────
4
+ // TDD — FAILING tests for the *not-yet-implemented* explicit `Placement`
5
+ // API. The current package encodes "hidden" as the magic geometric value
6
+ // `{x:0,y:0,width:0,height:0}` (the `present:false → ZERO bounds`
7
+ // convention in types.ts/view-anchor.ts). That leaks a compositor/host
8
+ // concept ("detach the view") into the geometry layer, and is ambiguous:
9
+ // a view that is genuinely 0-wide is indistinguishable from a hidden one.
10
+ //
11
+ // This file pins the REPLACEMENT contract:
12
+ //
13
+ // type Placement =
14
+ // | { visible: true; bounds: Bounds }
15
+ // | { visible: false }
16
+ //
17
+ // measure produces a `Placement`; the sink consumes a `Placement`.
18
+ // Visibility is EXPLICIT (a discriminant), never inferred from a 0.
19
+ //
20
+ // These tests must be RED today: the symbols they import/exercise
21
+ // (`measurePlacement`, `createPlacementAnchor`, the `Placement` type) do
22
+ // not exist yet. We import them dynamically so the failure surfaces as a
23
+ // runtime/assertion failure (a missing export is `undefined`, and calling
24
+ // it throws) rather than a whole-file compile error that vitest would skip.
25
+ // The implementer makes them GREEN by adding the explicit API — WITHOUT
26
+ // reintroducing any ZERO-bounds path for "hidden".
27
+ // ─────────────────────────────────────────────────────────────────────
28
+
29
+ import type { Bounds } from './types.js'
30
+
31
+ // The explicit API is imported through this indirection so that a not-yet-
32
+ // existing export is observable as `undefined` at runtime (→ a failing
33
+ // assertion / TypeError), not a static "module has no exported member"
34
+ // compile error that would prevent the whole suite from running.
35
+ import * as viewAnchorModule from './view-anchor.js'
36
+
37
+ // The shape the explicit API must produce/consume. Mirrored locally (not
38
+ // imported) so these tests describe the *target* contract independently of
39
+ // whether the `Placement` type already exists in types.ts.
40
+ type ExpectedPlacement =
41
+ | { visible: true; bounds: Bounds }
42
+ | { visible: false }
43
+
44
+ // ── ResizeObserver stub (same style as view-anchor.test.ts) ──────────
45
+
46
+ class FakeResizeObserver {
47
+ static instances: FakeResizeObserver[] = []
48
+ observed: Element[] = []
49
+ disconnected = false
50
+ constructor(public cb: ResizeObserverCallback) {
51
+ FakeResizeObserver.instances.push(this)
52
+ }
53
+ observe(el: Element): void {
54
+ this.observed.push(el)
55
+ }
56
+ unobserve(): void {
57
+ /* unused */
58
+ }
59
+ disconnect(): void {
60
+ this.disconnected = true
61
+ }
62
+ fire(): void {
63
+ this.cb([], this)
64
+ }
65
+ }
66
+
67
+ let leakedResize: unknown[] = []
68
+
69
+ beforeEach(() => {
70
+ FakeResizeObserver.instances = []
71
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
72
+
73
+ const realAdd = window.addEventListener.bind(window)
74
+ const realRemove = window.removeEventListener.bind(window)
75
+ vi.spyOn(window, 'addEventListener').mockImplementation(
76
+ (type: string, ...rest: unknown[]) => {
77
+ if (type === 'resize') leakedResize.push(rest[0])
78
+ return (realAdd as unknown as (...a: unknown[]) => void)(type, ...rest)
79
+ },
80
+ )
81
+ vi.spyOn(window, 'removeEventListener').mockImplementation(
82
+ (type: string, ...rest: unknown[]) => {
83
+ if (type === 'resize')
84
+ leakedResize = leakedResize.filter((h) => h !== rest[0])
85
+ return (realRemove as unknown as (...a: unknown[]) => void)(type, ...rest)
86
+ },
87
+ )
88
+ })
89
+
90
+ afterEach(() => {
91
+ leakedResize.forEach((h) =>
92
+ window.removeEventListener('resize', h as EventListener),
93
+ )
94
+ leakedResize = []
95
+ vi.restoreAllMocks()
96
+ vi.unstubAllGlobals()
97
+ })
98
+
99
+ // ── Element fixture (jsdom getBoundingClientRect returns zeros) ───────
100
+
101
+ function buildElement(rect: {
102
+ x: number
103
+ y: number
104
+ w: number
105
+ h: number
106
+ }): { el: HTMLElement; setRect: (next: typeof rect) => void } {
107
+ const el = document.createElement('div')
108
+ let current = rect
109
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
110
+ () =>
111
+ ({
112
+ x: current.x,
113
+ y: current.y,
114
+ left: current.x,
115
+ top: current.y,
116
+ right: current.x + current.w,
117
+ bottom: current.y + current.h,
118
+ width: current.w,
119
+ height: current.h,
120
+ toJSON: () => ({}),
121
+ }) as DOMRect,
122
+ )
123
+ return {
124
+ el,
125
+ setRect(next) {
126
+ current = next
127
+ },
128
+ }
129
+ }
130
+
131
+ // ── Resolve the (not-yet-existing) explicit API at call time ─────────
132
+ //
133
+ // Each returns `undefined` until the implementer adds the export. The
134
+ // tests below call them, so an absent export throws ("not a function") —
135
+ // a RED behavioural failure, which is what TDD wants here.
136
+
137
+ interface MaybeModule {
138
+ // measurePlacement(target): Placement — pure measure, no IPC.
139
+ measurePlacement?: (target: HTMLElement) => ExpectedPlacement
140
+ // createPlacementAnchor(target, { visible, publish }): handle whose
141
+ // `publish` sink receives an explicit Placement (not bare Bounds).
142
+ createPlacementAnchor?: (
143
+ target: HTMLElement,
144
+ opts: {
145
+ visible: boolean
146
+ publish: (placement: ExpectedPlacement) => void
147
+ },
148
+ ) => { update(opts: unknown): void; dispose(): void }
149
+ }
150
+
151
+ const mod = viewAnchorModule as MaybeModule
152
+
153
+ function measurePlacement(target: HTMLElement): ExpectedPlacement {
154
+ const fn = mod.measurePlacement
155
+ expect(
156
+ typeof fn,
157
+ 'explicit API `measurePlacement` is not implemented yet (expected a function export from view-anchor)',
158
+ ).toBe('function')
159
+ return fn!(target)
160
+ }
161
+
162
+ function createPlacementAnchor(
163
+ target: HTMLElement,
164
+ opts: {
165
+ visible: boolean
166
+ publish: (placement: ExpectedPlacement) => void
167
+ },
168
+ ): { update(opts: unknown): void; dispose(): void } {
169
+ const fn = mod.createPlacementAnchor
170
+ expect(
171
+ typeof fn,
172
+ 'explicit API `createPlacementAnchor` is not implemented yet (expected a function export from view-anchor)',
173
+ ).toBe('function')
174
+ return fn!(target, opts)
175
+ }
176
+
177
+ // A bare ZERO bounds — the magic value the OLD API uses for "hidden". The
178
+ // whole point of the explicit Placement is that "hidden" is NEVER this.
179
+ const ZERO_BOUNDS: Bounds = { x: 0, y: 0, width: 0, height: 0 }
180
+
181
+ // ─────────────────────────────────────────────────────────────────────
182
+ // Behaviour 1 — a present/visible anchor measures to a visible Placement
183
+ // carrying the ACTUAL rect.
184
+ // ─────────────────────────────────────────────────────────────────────
185
+
186
+ describe('Placement — visible anchor measures to { visible:true, bounds }', () => {
187
+ it('measurePlacement returns visible:true with the real (rounded/clamped) rect', () => {
188
+ const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
189
+
190
+ const p = measurePlacement(el)
191
+
192
+ expect(p.visible).toBe(true)
193
+ // Discriminated union: bounds only exists on the visible branch.
194
+ expect((p as { bounds: Bounds }).bounds).toEqual({
195
+ x: 10,
196
+ y: 20,
197
+ width: 300,
198
+ height: 400,
199
+ })
200
+ })
201
+ })
202
+
203
+ // ─────────────────────────────────────────────────────────────────────
204
+ // Behaviour 2 — a hidden/absent anchor measures to { visible:false } and
205
+ // carries NO bounds at all. It must NOT be `{visible:true, bounds:ZERO}`
206
+ // and must NOT be a bare ZERO bounds.
207
+ // ─────────────────────────────────────────────────────────────────────
208
+
209
+ describe('Placement — hidden anchor measures to { visible:false } (no bounds)', () => {
210
+ it('a hidden measure is { visible:false } and never a zero-bounds rect', () => {
211
+ // A "hidden" target. The explicit API decides hiddenness from the
212
+ // `visible:false` request, NOT from a measured 0 — so we pass the
213
+ // request through the anchor and capture what the sink receives.
214
+ const { el } = buildElement({ x: 0, y: 0, w: 0, h: 0 })
215
+ const publish = vi.fn<(p: ExpectedPlacement) => void>()
216
+
217
+ createPlacementAnchor(el, { visible: false, publish })
218
+
219
+ expect(publish).toHaveBeenCalledTimes(1)
220
+ const p = publish.mock.calls[0]![0]
221
+
222
+ expect(p.visible).toBe(false)
223
+ // The hidden Placement must NOT carry a bounds field at all…
224
+ expect('bounds' in p).toBe(false)
225
+ // …and must NOT be (structurally) the magic ZERO bounds the old API used.
226
+ expect(p).not.toEqual({ visible: true, bounds: ZERO_BOUNDS })
227
+ expect(p).not.toEqual(ZERO_BOUNDS)
228
+ })
229
+ })
230
+
231
+ // ─────────────────────────────────────────────────────────────────────
232
+ // Behaviour 3 — the SINK receives an explicit Placement: a consumer must
233
+ // be able to decide "hidden" purely from `placement.visible`, WITHOUT
234
+ // ever inspecting `width === 0`.
235
+ // ─────────────────────────────────────────────────────────────────────
236
+
237
+ describe('Placement — sink consumes explicit visibility, not width===0', () => {
238
+ it('the hidden Placement is recognisable via .visible with no geometry inspection', () => {
239
+ const { el } = buildElement({ x: 5, y: 6, w: 100, h: 100 })
240
+ const received: ExpectedPlacement[] = []
241
+ const handle = createPlacementAnchor(el, {
242
+ visible: true,
243
+ publish: (p) => received.push(p),
244
+ })
245
+
246
+ // Flip to hidden via the documented update path.
247
+ handle.update({ visible: false, publish: (p: ExpectedPlacement) => received.push(p) })
248
+
249
+ const last = received.at(-1)!
250
+ // A consumer's hidden-check: discriminant only. This is the contract —
251
+ // no `width === 0` anywhere.
252
+ const isHidden = (p: ExpectedPlacement): boolean => p.visible === false
253
+ expect(isHidden(last)).toBe(true)
254
+ // Prove the consumer did NOT need geometry: the hidden Placement has no
255
+ // `bounds`, so any width-based check would throw / be undefined.
256
+ expect((last as { bounds?: Bounds }).bounds).toBeUndefined()
257
+ })
258
+ })
259
+
260
+ // ─────────────────────────────────────────────────────────────────────
261
+ // Behaviour 4 — THE CORE BUG. A genuinely zero-SIZED but VISIBLE anchor
262
+ // (width===0, height===0, yet on-screen) must be DISTINGUISHABLE from a
263
+ // hidden one. Under the old ZERO-bounds convention both collapse to
264
+ // {0,0,0,0} and are indistinguishable. The explicit Placement resolves
265
+ // the ambiguity: real-but-zero → { visible:true, bounds:{...,width:0} };
266
+ // hidden → { visible:false }.
267
+ // ─────────────────────────────────────────────────────────────────────
268
+
269
+ describe('Placement — real 0-size is distinguishable from hidden (kills the magic 0)', () => {
270
+ it('a visible 0×0 rect is { visible:true, bounds: zeros } — NOT { visible:false }', () => {
271
+ // A legitimately collapsed-but-present element (e.g. a panel mid-drag
272
+ // that measured to 0 width this frame). It is VISIBLE, just zero-sized.
273
+ const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
274
+
275
+ const p = measurePlacement(el)
276
+
277
+ // It is VISIBLE — the geometry happens to be zero, but presence is not
278
+ // inferred from the geometry.
279
+ expect(p.visible).toBe(true)
280
+ expect((p as { bounds: Bounds }).bounds).toEqual({
281
+ x: 42,
282
+ y: 99,
283
+ width: 0,
284
+ height: 0,
285
+ })
286
+ })
287
+
288
+ it('visible-0x0 and hidden are NOT structurally equal (the disambiguation)', () => {
289
+ const { el: visibleZeroEl } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
290
+ const { el: hiddenEl } = buildElement({ x: 0, y: 0, w: 0, h: 0 })
291
+
292
+ const visibleZero = measurePlacement(visibleZeroEl)
293
+
294
+ const hiddenReceived = vi.fn<(p: ExpectedPlacement) => void>()
295
+ createPlacementAnchor(hiddenEl, { visible: false, publish: hiddenReceived })
296
+ const hidden = hiddenReceived.mock.calls[0]![0]
297
+
298
+ // The whole reason this refactor exists: these two states were the SAME
299
+ // value ({0,0,0,0}) under the old API. Now they must differ.
300
+ expect(visibleZero).not.toEqual(hidden)
301
+ expect(visibleZero.visible).toBe(true)
302
+ expect(hidden.visible).toBe(false)
303
+ })
304
+ })
305
+
306
+ // ─────────────────────────────────────────────────────────────────────
307
+ // Behaviour 5 — REGRESSION FENCE: under the explicit API, "hidden" is
308
+ // NEVER expressed as a ZERO bounds. No code path that publishes a hidden
309
+ // Placement may ever hand the sink a `{x:0,y:0,width:0,height:0}` (the
310
+ // old magic value). This is the guard that the migration didn't leave a
311
+ // ZERO-based shortcut behind.
312
+ // ─────────────────────────────────────────────────────────────────────
313
+
314
+ describe('Placement — hidden is NEVER a ZERO bounds (regression fence)', () => {
315
+ it('every hidden publish is { visible:false }, never a ZERO rect, across update flips', () => {
316
+ const { el, setRect } = buildElement({ x: 1, y: 2, w: 50, h: 60 })
317
+ const received: ExpectedPlacement[] = []
318
+ const publish = (p: ExpectedPlacement): void => {
319
+ received.push(p)
320
+ }
321
+
322
+ const handle = createPlacementAnchor(el, { visible: true, publish })
323
+ // visible → hidden → visible → hidden, exercising both directions.
324
+ handle.update({ visible: false, publish })
325
+ setRect({ x: 9, y: 9, w: 70, h: 80 })
326
+ handle.update({ visible: true, publish })
327
+ handle.update({ visible: false, publish })
328
+
329
+ const hiddenPublishes = received.filter((p) => p.visible === false)
330
+ expect(hiddenPublishes.length).toBeGreaterThanOrEqual(2)
331
+ for (const p of hiddenPublishes) {
332
+ // No hidden Placement may be (or contain) the magic ZERO bounds.
333
+ expect(p).not.toEqual(ZERO_BOUNDS)
334
+ expect(p).not.toEqual({ visible: true, bounds: ZERO_BOUNDS })
335
+ expect('bounds' in p).toBe(false)
336
+ }
337
+ })
338
+ })