@dimina-kit/view-anchor 0.1.0-dev.20260701083540 → 0.1.0-dev.20260701155140

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.
@@ -1 +1 @@
1
- {"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CAyOvB"}
1
+ {"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CAuPvB"}
@@ -182,6 +182,8 @@ export function createPlacementAnchor(target, opts) {
182
182
  let rafId = null;
183
183
  let steadyFrames = 0;
184
184
  const STEADY_CLOSE_FRAMES = 2;
185
+ // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
186
+ const MAX_HIDDEN_FOLLOW_FRAMES = 30;
185
187
  // True while a [role="separator"] splitter drag is in progress: set on the
186
188
  // capture-phase pointerdown that opened the window, cleared on pointerup.
187
189
  // A held pointer means the drag may still resume after a static pause, so a
@@ -216,11 +218,16 @@ export function createPlacementAnchor(target, opts) {
216
218
  lastPublished = p;
217
219
  publish(p);
218
220
  };
221
+ // Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
222
+ // bounded follow run elapsed, else keep following (false); never publishes.
223
+ const shouldCloseOnHiddenPoll = () => {
224
+ if (lastPublished?.visible === false)
225
+ return true;
226
+ return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
227
+ };
219
228
  // One sentinel frame: measure, publish-in-frame if changed, else count toward
220
- // the steady-close threshold. A changed frame re-arms; a steady frame re-arms
221
- // until STEADY_CLOSE_FRAMES consecutive unchanged frames, then closes (no
222
- // re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
223
- // inert.
229
+ // the steady-close threshold. Reads `disposed`/`visible` live so a frame
230
+ // outliving teardown is inert.
224
231
  const sentinelFrame = () => {
225
232
  rafId = null;
226
233
  if (disposed || !visible) {
@@ -234,6 +241,16 @@ export function createPlacementAnchor(target, opts) {
234
241
  return; // duration elapsed → close (no re-arm)
235
242
  }
236
243
  const p = computePlacement();
244
+ // The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
245
+ // hidden poll is a relayout transient to follow until restore (the fix).
246
+ if (!p.visible) {
247
+ if (shouldCloseOnHiddenPoll()) {
248
+ sentinelDeadline = null;
249
+ return;
250
+ }
251
+ rafId = requestAnimationFrame(sentinelFrame);
252
+ return;
253
+ }
237
254
  if (lastPublished && samePlacement(lastPublished, p)) {
238
255
  steadyFrames++;
239
256
  // Steady-close only fires once the pointer is RELEASED: while a
@@ -378,6 +378,7 @@ var __viewAnchorCore = (() => {
378
378
  let rafId = null;
379
379
  let steadyFrames = 0;
380
380
  const STEADY_CLOSE_FRAMES = 2;
381
+ const MAX_HIDDEN_FOLLOW_FRAMES = 30;
381
382
  let pointerHeld = false;
382
383
  let sentinelDeadline = null;
383
384
  const computePlacement = () => {
@@ -394,6 +395,10 @@ var __viewAnchorCore = (() => {
394
395
  lastPublished = p;
395
396
  publish(p);
396
397
  };
398
+ const shouldCloseOnHiddenPoll = () => {
399
+ if (lastPublished?.visible === false) return true;
400
+ return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
401
+ };
397
402
  const sentinelFrame = () => {
398
403
  rafId = null;
399
404
  if (disposed || !visible) {
@@ -405,6 +410,14 @@ var __viewAnchorCore = (() => {
405
410
  return;
406
411
  }
407
412
  const p = computePlacement();
413
+ if (!p.visible) {
414
+ if (shouldCloseOnHiddenPoll()) {
415
+ sentinelDeadline = null;
416
+ return;
417
+ }
418
+ rafId = requestAnimationFrame(sentinelFrame);
419
+ return;
420
+ }
408
421
  if (lastPublished && samePlacement(lastPublished, p)) {
409
422
  steadyFrames++;
410
423
  if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/view-anchor",
3
- "version": "0.1.0-dev.20260701083540",
3
+ "version": "0.1.0-dev.20260701155140",
4
4
  "description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
5
5
  "keywords": [
6
6
  "dimina",
@@ -0,0 +1,261 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createPlacementAnchor } from './view-anchor.js'
3
+ import type { Placement } from './types.js'
4
+
5
+ // Locks the correct behaviour of the followGeometry sentinel when a
6
+ // guardDisplayNone anchor's slot momentarily measures 0×0 during a
7
+ // relayout (e.g. device/orientation switch).
8
+ //
9
+ // On a slot transition the dock reflows; for one or more animation frames
10
+ // the slot rect collapses to 0×0 before being restored to its real non-zero
11
+ // size. ResizeObserver coalesces the net B→0→B change away and never fires,
12
+ // so the ONLY observer of the transient zero is the RAF sentinel.
13
+ //
14
+ // Correct behaviour: the sentinel MUST NOT publish {visible:false} from a
15
+ // transient zero poll. Detaching is owned by ResizeObserver / IntersectionObserver,
16
+ // not the sentinel. After a transient-zero-then-restore sequence driven
17
+ // purely through the sentinel (no RO fire), the final placement must be
18
+ // {visible:true, bounds:<restored rect>}.
19
+
20
+ // ── Controllable fake requestAnimationFrame ──────────────────────────────
21
+ class FakeRaf {
22
+ private cbs = new Map<number, FrameRequestCallback>()
23
+ private nextId = 1
24
+ request = vi.fn((cb: FrameRequestCallback): number => {
25
+ const id = this.nextId++
26
+ this.cbs.set(id, cb)
27
+ return id
28
+ })
29
+ cancel = vi.fn((id: number): void => {
30
+ this.cbs.delete(id)
31
+ })
32
+ /** Drain exactly the callbacks pending at call-time; a re-request lands in
33
+ * the next flush, so flushFrame() advances exactly one logical frame. */
34
+ flushFrame(ts = 0): void {
35
+ const pending = [...this.cbs.entries()]
36
+ this.cbs.clear()
37
+ for (const [, cb] of pending) cb(ts)
38
+ }
39
+ get pending(): number {
40
+ return this.cbs.size
41
+ }
42
+ }
43
+
44
+ // ── Minimal ResizeObserver stub ──────────────────────────────────────────
45
+ class FakeResizeObserver {
46
+ static instances: FakeResizeObserver[] = []
47
+ observed: Element[] = []
48
+ disconnected = false
49
+ constructor(public cb: ResizeObserverCallback) {
50
+ FakeResizeObserver.instances.push(this)
51
+ }
52
+ observe(el: Element): void {
53
+ this.observed.push(el)
54
+ }
55
+ unobserve(): void {
56
+ /* unused */
57
+ }
58
+ disconnect(): void {
59
+ this.disconnected = true
60
+ }
61
+ }
62
+
63
+ let raf: FakeRaf
64
+
65
+ beforeEach(() => {
66
+ FakeResizeObserver.instances = []
67
+ raf = new FakeRaf()
68
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
69
+ vi.stubGlobal(
70
+ 'requestAnimationFrame',
71
+ raf.request as unknown as typeof window.requestAnimationFrame,
72
+ )
73
+ vi.stubGlobal(
74
+ 'cancelAnimationFrame',
75
+ raf.cancel as unknown as typeof window.cancelAnimationFrame,
76
+ )
77
+ })
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks()
81
+ vi.unstubAllGlobals()
82
+ })
83
+
84
+ function buildElement(rect: { x: number; y: number; w: number; h: number }): {
85
+ el: HTMLElement
86
+ setRect: (next: { x: number; y: number; w: number; h: number }) => void
87
+ } {
88
+ const el = document.createElement('div')
89
+ let current = rect
90
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
91
+ () =>
92
+ ({
93
+ x: current.x,
94
+ y: current.y,
95
+ left: current.x,
96
+ top: current.y,
97
+ right: current.x + current.w,
98
+ bottom: current.y + current.h,
99
+ width: current.w,
100
+ height: current.h,
101
+ toJSON: () => ({}),
102
+ }) as DOMRect,
103
+ )
104
+ return {
105
+ el,
106
+ setRect(next) {
107
+ current = next
108
+ },
109
+ }
110
+ }
111
+
112
+ // Options cast: followGeometry / guardDisplayNone not on the exported TS
113
+ // types yet — cast through so tests compile without touching the types file.
114
+ type PlacementOpts = Parameters<typeof createPlacementAnchor>[1] & {
115
+ followGeometry?: boolean
116
+ guardDisplayNone?: boolean
117
+ }
118
+ const mk = (
119
+ el: HTMLElement,
120
+ o: {
121
+ visible: boolean
122
+ publish: (p: Placement) => void
123
+ followGeometry?: boolean
124
+ guardDisplayNone?: boolean
125
+ },
126
+ ): ReturnType<typeof createPlacementAnchor> =>
127
+ createPlacementAnchor(el, o as PlacementOpts)
128
+
129
+ describe('createPlacementAnchor — followGeometry sentinel must not detach on a transient zero-area poll', () => {
130
+ // Core bug: slot goes 0×0 transiently during a dock relayout; RO never
131
+ // fires (net change is zero); the sentinel is the only observer. The sentinel
132
+ // must NOT publish {visible:false} and must not close on the zero frames so
133
+ // it can follow the restored rect.
134
+ it('transient 0×0 poll → restore: final placement is visible:true (not a detach)', () => {
135
+ const publishes: Placement[] = []
136
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
137
+
138
+ mk(el, {
139
+ visible: true,
140
+ guardDisplayNone: true,
141
+ followGeometry: true,
142
+ publish: (p) => publishes.push(p),
143
+ })
144
+
145
+ // Synchronous initial publish on creation.
146
+ expect(publishes).toHaveLength(1)
147
+ expect(publishes[0]).toEqual({
148
+ visible: true,
149
+ bounds: { x: 0, y: 0, width: 400, height: 800 },
150
+ })
151
+
152
+ // Open the sentinel (imperative pulse, no duration → steady-close only,
153
+ // no time-stubbing needed).
154
+ const anchor = mk(el, {
155
+ visible: true,
156
+ guardDisplayNone: true,
157
+ followGeometry: true,
158
+ publish: (p) => publishes.push(p),
159
+ })
160
+ // Reset to track only the sentinel's publishes from here on.
161
+ publishes.length = 0
162
+ anchor.pulse()
163
+
164
+ // Simulate the slot collapsing to 0×0 (transient relayout).
165
+ // Do NOT fire ResizeObserver (it coalesces the net B→0→B away).
166
+ setRect({ x: 0, y: 0, w: 0, h: 0 })
167
+
168
+ // Flush enough frames to exceed the steady-close threshold (STEADY_CLOSE_FRAMES=2)
169
+ // so the buggy sentinel closes on the zero.
170
+ raf.flushFrame() // frame 1 at zero
171
+ raf.flushFrame() // frame 2 at zero
172
+ raf.flushFrame() // frame 3 at zero — buggy path closes here
173
+
174
+ // Now restore the slot to its real (moved) non-zero rect.
175
+ setRect({ x: 120, y: 0, w: 400, h: 800 })
176
+
177
+ // Drive additional frames; with the fix the sentinel is still open and
178
+ // picks up the restored rect. With the bug the sentinel already closed —
179
+ // these flushes are no-ops but that's fine: the assertion is on the LAST
180
+ // published placement regardless.
181
+ raf.flushFrame()
182
+ raf.flushFrame()
183
+
184
+ // The sentinel must NEVER have emitted {visible:false}.
185
+ const detaches = publishes.filter((p) => p.visible === false)
186
+ expect(detaches).toHaveLength(0)
187
+
188
+ // And the view must not be left detached: the last placement is visible:true.
189
+ expect(publishes.length).toBeGreaterThan(0)
190
+ const last = publishes[publishes.length - 1]
191
+ expect(last).toMatchObject({ visible: true })
192
+ })
193
+
194
+ // No-regression: the sentinel must still follow a normal visible move
195
+ // (pulse → setRect to moved non-zero rect → flushFrame → published).
196
+ // Guards against a "fix" that simply disables sentinel publishing.
197
+ it('sentinel follows a normal visible-rect move after pulse()', () => {
198
+ const publishes: Placement[] = []
199
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
200
+
201
+ const anchor = mk(el, {
202
+ visible: true,
203
+ guardDisplayNone: true,
204
+ followGeometry: true,
205
+ publish: (p) => publishes.push(p),
206
+ })
207
+ publishes.length = 0 // clear the initial synchronous publish
208
+
209
+ anchor.pulse()
210
+
211
+ // Move the slot to a new non-zero position (e.g. after a panel resize).
212
+ setRect({ x: 200, y: 50, w: 400, h: 800 })
213
+ raf.flushFrame()
214
+
215
+ expect(publishes).toContainEqual({
216
+ visible: true,
217
+ bounds: { x: 200, y: 50, width: 400, height: 800 },
218
+ })
219
+ })
220
+
221
+ // Defensive bound: while the sentinel keeps FOLLOWING a hidden poll (a
222
+ // relayout transient where last-published is still visible and RO/IO never
223
+ // fire), it must not spin forever. After a finite run of consecutive hidden
224
+ // polls it stops scheduling new frames (window closes) — and it must STILL
225
+ // never publish a {visible:false} detach during that bounded run.
226
+ it('sentinel stops polling after a bounded run of hidden frames — no infinite rAF, no detach', () => {
227
+ const publishes: Placement[] = []
228
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 400, h: 800 })
229
+
230
+ const anchor = mk(el, {
231
+ visible: true,
232
+ guardDisplayNone: true,
233
+ followGeometry: true,
234
+ publish: (p) => publishes.push(p),
235
+ })
236
+ publishes.length = 0 // clear the initial synchronous publish
237
+
238
+ anchor.pulse()
239
+
240
+ // Slot collapses to 0×0 and stays hidden. RO/IO never fire.
241
+ setRect({ x: 0, y: 0, w: 0, h: 0 })
242
+
243
+ // Drive frames until the sentinel closes on its own (raf.pending === 0),
244
+ // with a hard safety break so a genuinely unbounded spin still terminates
245
+ // the test instead of hanging.
246
+ let n = 0
247
+ while (raf.pending) {
248
+ raf.flushFrame()
249
+ if (++n > 200) break
250
+ }
251
+
252
+ // The loop terminated because the sentinel CLOSED (pending → 0), not
253
+ // because the safety break fired, and it converged quickly.
254
+ expect(raf.pending).toBe(0)
255
+ expect(n).toBeLessThan(100)
256
+
257
+ // The bounded close must still never emit a detach.
258
+ const detaches = publishes.filter((p) => p.visible === false)
259
+ expect(detaches).toHaveLength(0)
260
+ })
261
+ })
@@ -271,6 +271,8 @@ export function createPlacementAnchor(
271
271
  let rafId: number | null = null
272
272
  let steadyFrames = 0
273
273
  const STEADY_CLOSE_FRAMES = 2
274
+ // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
275
+ const MAX_HIDDEN_FOLLOW_FRAMES = 30
274
276
  // True while a [role="separator"] splitter drag is in progress: set on the
275
277
  // capture-phase pointerdown that opened the window, cleared on pointerup.
276
278
  // A held pointer means the drag may still resume after a static pause, so a
@@ -308,11 +310,16 @@ export function createPlacementAnchor(
308
310
  publish(p)
309
311
  }
310
312
 
313
+ // Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
314
+ // bounded follow run elapsed, else keep following (false); never publishes.
315
+ const shouldCloseOnHiddenPoll = (): boolean => {
316
+ if (lastPublished?.visible === false) return true
317
+ return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
318
+ }
319
+
311
320
  // One sentinel frame: measure, publish-in-frame if changed, else count toward
312
- // the steady-close threshold. A changed frame re-arms; a steady frame re-arms
313
- // until STEADY_CLOSE_FRAMES consecutive unchanged frames, then closes (no
314
- // re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
315
- // inert.
321
+ // the steady-close threshold. Reads `disposed`/`visible` live so a frame
322
+ // outliving teardown is inert.
316
323
  const sentinelFrame = (): void => {
317
324
  rafId = null
318
325
  if (disposed || !visible) {
@@ -326,6 +333,13 @@ export function createPlacementAnchor(
326
333
  return // duration elapsed → close (no re-arm)
327
334
  }
328
335
  const p = computePlacement()
336
+ // The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
337
+ // hidden poll is a relayout transient to follow until restore (the fix).
338
+ if (!p.visible) {
339
+ if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
340
+ rafId = requestAnimationFrame(sentinelFrame)
341
+ return
342
+ }
329
343
  if (lastPublished && samePlacement(lastPublished, p)) {
330
344
  steadyFrames++
331
345
  // Steady-close only fires once the pointer is RELEASED: while a