@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,566 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, act } from '@testing-library/react'
3
+ import { StrictMode, useCallback, useEffect, useRef } from 'react'
4
+ import { useViewAnchor, type UseViewAnchorOptions } from './react.js'
5
+
6
+ // ── ResizeObserver stub ──────────────────────────────────────────────
7
+ // The React adapter is a thin wrapper over `createViewAnchor`, so behaviour
8
+ // is observed through the injected `publish` spy + the FakeResizeObserver.
9
+ // The core publishes SYNCHRONOUSLY (no RAF defer), so a fired observer tick
10
+ // publishes immediately — there is nothing to flush.
11
+
12
+ class FakeResizeObserver {
13
+ static instances: FakeResizeObserver[] = []
14
+ observed: Element[] = []
15
+ disconnected = false
16
+ constructor(public cb: ResizeObserverCallback) {
17
+ FakeResizeObserver.instances.push(this)
18
+ }
19
+ observe(el: Element): void {
20
+ this.observed.push(el)
21
+ }
22
+ unobserve(): void {
23
+ /* unused */
24
+ }
25
+ disconnect(): void {
26
+ this.disconnected = true
27
+ }
28
+ fire(): void {
29
+ this.cb([], this)
30
+ }
31
+ }
32
+
33
+ beforeEach(() => {
34
+ FakeResizeObserver.instances = []
35
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
36
+ })
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks()
40
+ vi.unstubAllGlobals()
41
+ })
42
+
43
+ /** Assert an observer exists, then return it — so a skeleton no-op (no
44
+ * observer installed) fails as a clear behavioural assertion rather than
45
+ * a TypeError on a later `.fire()`. */
46
+ function firstObserver(): FakeResizeObserver {
47
+ expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
48
+ return FakeResizeObserver.instances[0]!
49
+ }
50
+
51
+ function lastObserver(): FakeResizeObserver {
52
+ expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
53
+ return FakeResizeObserver.instances.at(-1)!
54
+ }
55
+
56
+ // A real <div> rendered by React, but with a stubbed getBoundingClientRect.
57
+ // We stub on the element React hands us via a ref so the rect is
58
+ // deterministic in jsdom (which always returns zeros otherwise).
59
+ function stubRect(
60
+ el: HTMLElement,
61
+ rect: { x: number; y: number; w: number; h: number },
62
+ ): void {
63
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
64
+ x: rect.x,
65
+ y: rect.y,
66
+ left: rect.x,
67
+ top: rect.y,
68
+ right: rect.x + rect.w,
69
+ bottom: rect.y + rect.h,
70
+ width: rect.w,
71
+ height: rect.h,
72
+ toJSON: () => ({}),
73
+ } as DOMRect)
74
+ }
75
+
76
+ // Test harness component: renders a <div> wired to useViewAnchor, and
77
+ // stubs its rect *before* the anchor measures it. We do the stubbing in a
78
+ // callback ref that runs before useViewAnchor's ref callback fires.
79
+ function Anchored(props: {
80
+ options: UseViewAnchorOptions
81
+ rect: { x: number; y: number; w: number; h: number }
82
+ mounted?: boolean
83
+ }): React.JSX.Element | null {
84
+ const { options, rect, mounted = true } = props
85
+ const anchorRef = useViewAnchor(options)
86
+ const elRef = useRef<HTMLDivElement | null>(null)
87
+ const rectRef = useRef(rect)
88
+ // Sync the latest rect post-commit instead of writing the ref during render
89
+ // (react-hooks/refs). The stub is only read inside `setRef` when React hands
90
+ // us a *new* element, which always happens after commit, so the stub timing
91
+ // is identical to the render-time write.
92
+ useEffect(() => {
93
+ rectRef.current = rect
94
+ })
95
+
96
+ // Stable ref so a rerender doesn't tear down + re-create the anchor. An
97
+ // unstable ref function makes React call ref(null)→ref(el) every render,
98
+ // which would re-create the anchor and double-publish. Real consumers pass
99
+ // the stable `anchorRef` directly; this harness only wraps it to stub the
100
+ // element's rect for jsdom.
101
+ const setRef = useCallback(
102
+ (el: HTMLDivElement | null): void => {
103
+ if (el && elRef.current !== el) {
104
+ stubRect(el, rectRef.current)
105
+ }
106
+ elRef.current = el
107
+ anchorRef(el)
108
+ },
109
+ [anchorRef],
110
+ )
111
+
112
+ if (!mounted) return null
113
+ return <div ref={setRef} data-testid="anchored" />
114
+ }
115
+
116
+ // ── Contract 8: ref attach ⇒ createViewAnchor(el, opts) ──────────────
117
+ // Bug it catches: the adapter never wiring the ref to the core means the
118
+ // native view never gets initial bounds → never attaches.
119
+
120
+ describe('useViewAnchor — ref attach', () => {
121
+ it('present=true: publishes the element rect once on mount', () => {
122
+ const publish = vi.fn()
123
+ act(() => {
124
+ render(
125
+ <Anchored
126
+ options={{ present: true, publish }}
127
+ rect={{ x: 11, y: 22, w: 333, h: 444 }}
128
+ />,
129
+ )
130
+ })
131
+
132
+ expect(publish).toHaveBeenCalledTimes(1)
133
+ expect(publish).toHaveBeenCalledWith({
134
+ x: 11,
135
+ y: 22,
136
+ width: 333,
137
+ height: 444,
138
+ })
139
+ expect(FakeResizeObserver.instances).toHaveLength(1)
140
+ })
141
+
142
+ it('present=false: publishes zero on mount and does not observe', () => {
143
+ const publish = vi.fn()
144
+ act(() => {
145
+ render(
146
+ <Anchored
147
+ options={{ present: false, publish }}
148
+ rect={{ x: 11, y: 22, w: 333, h: 444 }}
149
+ />,
150
+ )
151
+ })
152
+
153
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
154
+ expect(FakeResizeObserver.instances).toHaveLength(0)
155
+ })
156
+ })
157
+
158
+ // ── Contract 9: ref null ⇒ publish ZERO, then dispose ───────────────
159
+ // Bug it catches: when the DOM node unmounts but the hook lives on (e.g.
160
+ // the leaf div conditionally rendered), failing to dispose leaks the RO
161
+ // and keeps publishing against a detached element.
162
+ //
163
+ // P1 fix: this contract ALSO requires emitting one ZERO on detach. The
164
+ // anchor's follower is a main-process WebContentsView; the host only
165
+ // collapses it on `{0,0,0,0}`. Without a ZERO the native view stays frozen
166
+ // at its last bounds and occludes content. See `react.ts`.
167
+
168
+ describe('useViewAnchor — ref null disposes', () => {
169
+ it('detaching the DOM node publishes ZERO once, disconnects the observer, and stops publishing', () => {
170
+ const publish = vi.fn()
171
+
172
+ function Host(props: { mounted: boolean }): React.JSX.Element {
173
+ return (
174
+ <Anchored
175
+ options={{ present: true, publish }}
176
+ rect={{ x: 0, y: 0, w: 100, h: 100 }}
177
+ mounted={props.mounted}
178
+ />
179
+ )
180
+ }
181
+
182
+ let rerender!: (ui: React.ReactElement) => void
183
+ act(() => {
184
+ ;({ rerender } = render(<Host mounted={true} />))
185
+ })
186
+ expect(FakeResizeObserver.instances).toHaveLength(1)
187
+ const ro = FakeResizeObserver.instances[0]!
188
+ publish.mockClear()
189
+
190
+ // Unmount just the inner div (ref → null) via a prop-driven rerender; the
191
+ // hook stays alive (only the leaf <div> is gone, exercising the null-ref
192
+ // detach path).
193
+ act(() => {
194
+ rerender(<Host mounted={false} />)
195
+ })
196
+ expect(ro.disconnected).toBe(true)
197
+
198
+ // NOTE: this assertion previously encoded the P1 bug — it asserted
199
+ // `expect(publish).not.toHaveBeenCalled()`, i.e. detach published nothing,
200
+ // which left the native WebContentsView stranded at its old bounds. The
201
+ // correct behaviour is: a vanished anchor MUST publish exactly one ZERO so
202
+ // the host collapses the native view.
203
+ expect(publish).toHaveBeenCalledTimes(1)
204
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
205
+ publish.mockClear()
206
+
207
+ // After the node is gone the anchor is inert: no further publishes.
208
+ ro.fire()
209
+ window.dispatchEvent(new Event('resize'))
210
+ expect(publish).not.toHaveBeenCalled()
211
+ })
212
+ })
213
+
214
+ // ── Contract 10: opts/deps change ⇒ re-publish at current rect ───────
215
+ // Bug it catches: a tab switch toggles `display:none` (a `deps` entry) and
216
+ // the rect changes, but without re-emit the native view stays at the stale
217
+ // position → DevTools lands in the wrong place after the tab switch.
218
+
219
+ describe('useViewAnchor — opts/deps change re-publishes', () => {
220
+ it('present change false → true re-publishes the current rect', () => {
221
+ const publish = vi.fn()
222
+ const { rerender } = render(
223
+ <Anchored
224
+ options={{ present: false, publish }}
225
+ rect={{ x: 3, y: 4, w: 60, h: 70 }}
226
+ />,
227
+ )
228
+ publish.mockClear()
229
+
230
+ act(() => {
231
+ rerender(
232
+ <Anchored
233
+ options={{ present: true, publish }}
234
+ rect={{ x: 3, y: 4, w: 60, h: 70 }}
235
+ />,
236
+ )
237
+ })
238
+
239
+ expect(publish).toHaveBeenCalledWith({ x: 3, y: 4, width: 60, height: 70 })
240
+ })
241
+
242
+ it('deps change re-publishes even though present/publish are unchanged', () => {
243
+ const publish = vi.fn()
244
+ const base = { present: true, publish }
245
+ const { rerender } = render(
246
+ <Anchored
247
+ options={{ ...base, deps: ['tab-a'] }}
248
+ rect={{ x: 1, y: 1, w: 200, h: 200 }}
249
+ />,
250
+ )
251
+ publish.mockClear()
252
+
253
+ act(() => {
254
+ rerender(
255
+ <Anchored
256
+ options={{ ...base, deps: ['tab-b'] }}
257
+ rect={{ x: 1, y: 1, w: 200, h: 200 }}
258
+ />,
259
+ )
260
+ })
261
+
262
+ expect(publish).toHaveBeenCalledTimes(1)
263
+ expect(publish).toHaveBeenLastCalledWith({ x: 1, y: 1, width: 200, height: 200 })
264
+ })
265
+
266
+ it('publish identity change routes the re-apply emit to the new callback', () => {
267
+ const first = vi.fn()
268
+ const second = vi.fn()
269
+ const { rerender } = render(
270
+ <Anchored
271
+ options={{ present: true, publish: first }}
272
+ rect={{ x: 0, y: 0, w: 100, h: 100 }}
273
+ />,
274
+ )
275
+ first.mockClear()
276
+
277
+ // A publish-identity change re-applies through the core's `update`, which
278
+ // resets `lastPublished` and re-emits even on unchanged geometry — so the
279
+ // new callback (carrying e.g. a new zoom closure) is guaranteed to fire.
280
+ act(() => {
281
+ rerender(
282
+ <Anchored
283
+ options={{ present: true, publish: second }}
284
+ rect={{ x: 0, y: 0, w: 100, h: 100 }}
285
+ />,
286
+ )
287
+ })
288
+
289
+ expect(second).toHaveBeenCalledTimes(1)
290
+ expect(second).toHaveBeenCalledWith({ x: 0, y: 0, width: 100, height: 100 })
291
+ expect(first).not.toHaveBeenCalled()
292
+ })
293
+ })
294
+
295
+ // ── Contract 11: unmount ⇒ publish ZERO, then dispose ───────────────
296
+ // Bug it catches: a hook that does not dispose on unmount leaks the RO and
297
+ // can throw when a queued RAF fires against a torn-down IPC channel.
298
+ //
299
+ // P1 fix: unmount must ALSO collapse the native view with one ZERO (same
300
+ // reasoning as Contract 9 — the follower is a main-process WebContentsView
301
+ // the host only collapses on `{0,0,0,0}`).
302
+
303
+ describe('useViewAnchor — unmount disposes', () => {
304
+ it('unmounting the component publishes ZERO once, disconnects the observer, and never publishes after', () => {
305
+ const publish = vi.fn()
306
+ const { unmount } = render(
307
+ <Anchored
308
+ options={{ present: true, publish }}
309
+ rect={{ x: 0, y: 0, w: 100, h: 100 }}
310
+ />,
311
+ )
312
+ const ro = firstObserver()
313
+ publish.mockClear()
314
+
315
+ act(() => {
316
+ unmount()
317
+ })
318
+
319
+ expect(ro.disconnected).toBe(true)
320
+
321
+ // NOTE: this assertion previously encoded the P1 bug — it asserted
322
+ // `expect(publish).not.toHaveBeenCalled()` after unmount, leaving the
323
+ // native view stranded. Correct behaviour: unmount publishes exactly one
324
+ // ZERO to collapse the native view.
325
+ expect(publish).toHaveBeenCalledTimes(1)
326
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
327
+ publish.mockClear()
328
+
329
+ // After unmount the anchor is inert: a later observer/resize tick (read
330
+ // synchronously against `disposed`) publishes nothing — no queued frame.
331
+ ro.fire()
332
+ window.dispatchEvent(new Event('resize'))
333
+ expect(publish).not.toHaveBeenCalled()
334
+ })
335
+ })
336
+
337
+ // ── Contract 12: independent instances don't interfere ──────────────
338
+ // Bug it catches: shared module-level state (single RO / single publish
339
+ // target) would cross-wire two anchors so one's resize moves the other.
340
+
341
+ describe('useViewAnchor — independent instances', () => {
342
+ it('two anchors observe their own element and publish independently', () => {
343
+ const publishA = vi.fn()
344
+ const publishB = vi.fn()
345
+
346
+ function Pair(): React.JSX.Element {
347
+ return (
348
+ <>
349
+ <Anchored
350
+ options={{ present: true, publish: publishA }}
351
+ rect={{ x: 0, y: 0, w: 10, h: 10 }}
352
+ />
353
+ <Anchored
354
+ options={{ present: true, publish: publishB }}
355
+ rect={{ x: 100, y: 100, w: 20, h: 20 }}
356
+ />
357
+ </>
358
+ )
359
+ }
360
+
361
+ act(() => {
362
+ render(<Pair />)
363
+ })
364
+
365
+ expect(publishA).toHaveBeenCalledWith({ x: 0, y: 0, width: 10, height: 10 })
366
+ expect(publishB).toHaveBeenCalledWith({ x: 100, y: 100, width: 20, height: 20 })
367
+ expect(FakeResizeObserver.instances).toHaveLength(2)
368
+
369
+ publishA.mockClear()
370
+ publishB.mockClear()
371
+
372
+ // Move A's element to a NEW rect (so its tick isn't deduped), then fire
373
+ // only A's observer → only A republishes; B is untouched (no cross-wiring).
374
+ const aEl = FakeResizeObserver.instances[0]!.observed[0] as HTMLElement
375
+ stubRect(aEl, { x: 1, y: 1, w: 30, h: 30 })
376
+ FakeResizeObserver.instances[0]!.fire()
377
+ expect(publishA).toHaveBeenCalledTimes(1)
378
+ expect(publishA).toHaveBeenCalledWith({ x: 1, y: 1, width: 30, height: 30 })
379
+ expect(publishB).not.toHaveBeenCalled()
380
+ })
381
+ })
382
+
383
+ // ── Remount with present transition (codex regression) ──────────────
384
+ // Production coupling: the debug cell is *unmounted* when hidden and *remounted*
385
+ // when shown, so the element's mount/unmount and `options.present` flip together
386
+ // (present=false ⟺ unmounted, present=true ⟺ mounted). On "show", React commits
387
+ // the remounted element and fires the stable ref callback during the *commit*
388
+ // phase — before the `useEffect` that syncs `optsRef.current = opts` has run.
389
+ //
390
+ // Regression this guards: when the adapter syncs `optsRef.current = opts` in a
391
+ // post-commit `useEffect` (instead of during render), the ref callback that
392
+ // re-creates the anchor on remount reads a STALE `optsRef.current.present`
393
+ // (still `false` from the hidden round). It therefore calls
394
+ // `createViewAnchor(el, { present:false })`, which publishes a spurious ZERO;
395
+ // and the re-apply effect then sees the (present,publish) tuple as "unchanged"
396
+ // (true→true vs the seeded mount value) and skips, so the real rect is never
397
+ // emitted. Correct behaviour on show is: publish the real rect EXACTLY once —
398
+ // no leading ZERO, no second publish.
399
+
400
+ describe('useViewAnchor — remount with present transition', () => {
401
+ it('show (remount + present false→true) publishes the real rect once, no ZERO, not twice', () => {
402
+ const publish = vi.fn()
403
+
404
+ // Host drives BOTH `mounted` and the `present` option from a single
405
+ // `shown` flag, mirroring the production coupling (hidden ⟺ unmounted).
406
+ // Both are passed as props and flipped via rerender inside `act` — never
407
+ // by reassigning an outer variable during render (react-hooks/globals).
408
+ function Host(props: { shown: boolean }): React.JSX.Element {
409
+ return (
410
+ <Anchored
411
+ options={{ present: props.shown, publish }}
412
+ rect={{ x: 17, y: 29, w: 321, h: 654 }}
413
+ mounted={props.shown}
414
+ />
415
+ )
416
+ }
417
+
418
+ let rerender!: (ui: React.ReactElement) => void
419
+ act(() => {
420
+ ;({ rerender } = render(<Host shown={true} />))
421
+ })
422
+ // Initial mount published the real rect once; settle and clear so the
423
+ // assertions below measure only the show transition.
424
+ publish.mockClear()
425
+
426
+ // Hide: element unmounts AND present flips to false. This is expected to
427
+ // emit one collapse ZERO (detach path) — not the focus of this test.
428
+ act(() => {
429
+ rerender(<Host shown={false} />)
430
+ })
431
+ publish.mockClear()
432
+
433
+ // Show: element remounts AND present flips back to true. The remounted
434
+ // element's stubbed rect must be published exactly once, with no spurious
435
+ // ZERO and no duplicate publish.
436
+ act(() => {
437
+ rerender(<Host shown={true} />)
438
+ })
439
+
440
+ expect(publish).not.toHaveBeenCalledWith({
441
+ x: 0,
442
+ y: 0,
443
+ width: 0,
444
+ height: 0,
445
+ })
446
+ expect(publish).toHaveBeenCalledTimes(1)
447
+ expect(publish).toHaveBeenCalledWith({
448
+ x: 17,
449
+ y: 29,
450
+ width: 321,
451
+ height: 654,
452
+ })
453
+ })
454
+ })
455
+
456
+ // ── StrictMode resilience (regression lock) ─────────────────────────
457
+ // Not a new contract — these lock the *intended* behaviour against React's
458
+ // StrictMode, which in dev double-fires every effect's setup/cleanup
459
+ // (mount → setup → cleanup → setup) to surface unsafe lifecycle code. The
460
+ // invariant these guard is the only one a consumer can see: after a real or
461
+ // StrictMode-simulated mount, there is exactly one live anchor that (a) has
462
+ // published its rect once, (b) still follows resizes, and (c) emits exactly
463
+ // one ZERO when its element detaches.
464
+
465
+ describe('useViewAnchor — StrictMode resilience', () => {
466
+ it('mount under StrictMode publishes the real rect exactly once with one live observer', () => {
467
+ // Regression: a missing ref-guard / non-idempotent setup would let
468
+ // StrictMode's attach→detach→re-attach double-publish the mount rect and
469
+ // leave two live FakeResizeObserver connections (the first one leaked).
470
+ const publish = vi.fn()
471
+ act(() => {
472
+ render(
473
+ <StrictMode>
474
+ <Anchored
475
+ options={{ present: true, publish }}
476
+ rect={{ x: 11, y: 22, w: 333, h: 444 }}
477
+ />
478
+ </StrictMode>,
479
+ )
480
+ })
481
+
482
+ expect(publish).toHaveBeenCalledTimes(1)
483
+ expect(publish).toHaveBeenCalledWith({
484
+ x: 11,
485
+ y: 22,
486
+ width: 333,
487
+ height: 444,
488
+ })
489
+ // StrictMode may *create* extra observers during its throwaway pass, but
490
+ // only one may remain connected; the rest must be disconnected.
491
+ const live = FakeResizeObserver.instances.filter((o) => !o.disconnected)
492
+ expect(live).toHaveLength(1)
493
+ })
494
+
495
+ it('after StrictMode mount settles, a resize still publishes once (anchor survived remount)', () => {
496
+ // Regression: StrictMode's simulated unmount/remount must leave a *working*
497
+ // anchor. If the surviving handle pointed at a disposed core (or a stale
498
+ // observer), the post-mount resize would publish zero times — the native
499
+ // view would freeze and never follow layout after StrictMode's remount.
500
+ const publish = vi.fn()
501
+ act(() => {
502
+ render(
503
+ <StrictMode>
504
+ <Anchored
505
+ options={{ present: true, publish }}
506
+ rect={{ x: 5, y: 6, w: 70, h: 80 }}
507
+ />
508
+ </StrictMode>,
509
+ )
510
+ })
511
+ publish.mockClear()
512
+
513
+ // The live anchor is wired to the *last* observer created during mount.
514
+ // Move its element to a new rect so the tick isn't deduped, then fire it:
515
+ // a surviving, working anchor re-publishes the current rect synchronously.
516
+ act(() => {
517
+ const liveObserver = lastObserver()
518
+ const liveEl = liveObserver.observed[0] as HTMLElement
519
+ stubRect(liveEl, { x: 9, y: 9, w: 71, h: 81 })
520
+ liveObserver.fire()
521
+ })
522
+
523
+ expect(publish).toHaveBeenCalledTimes(1)
524
+ expect(publish).toHaveBeenCalledWith({ x: 9, y: 9, width: 71, height: 81 })
525
+ })
526
+
527
+ it('detach under StrictMode publishes ZERO exactly once (contract 9 not amplified)', () => {
528
+ // Regression: StrictMode's extra detach/reattach must NOT multiply the
529
+ // single collapse ZERO. A non-idempotent collapse path would emit ZERO
530
+ // twice (once per simulated unmount), making the host collapse/flicker the
531
+ // native WebContentsView more than once on a single real detach.
532
+ const publish = vi.fn()
533
+
534
+ function Host(props: { mounted: boolean }): React.JSX.Element {
535
+ return (
536
+ <Anchored
537
+ options={{ present: true, publish }}
538
+ rect={{ x: 0, y: 0, w: 100, h: 100 }}
539
+ mounted={props.mounted}
540
+ />
541
+ )
542
+ }
543
+
544
+ let rerender!: (ui: React.ReactElement) => void
545
+ act(() => {
546
+ ;({ rerender } = render(
547
+ <StrictMode>
548
+ <Host mounted={true} />
549
+ </StrictMode>,
550
+ ))
551
+ })
552
+ publish.mockClear()
553
+
554
+ // Detach just the inner <div> (ref → null) while the hook stays mounted.
555
+ act(() => {
556
+ rerender(
557
+ <StrictMode>
558
+ <Host mounted={false} />
559
+ </StrictMode>,
560
+ )
561
+ })
562
+
563
+ expect(publish).toHaveBeenCalledTimes(1)
564
+ expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
565
+ })
566
+ })