@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/src/react.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+ import { createViewAnchor } from './view-anchor.js'
3
+ import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
4
+
5
+ /**
6
+ * React adapter over the imperative `createViewAnchor` core.
7
+ *
8
+ * (React lint forces the `use` prefix on any hook returning a ref
9
+ * callback; the library's identity is still the `ViewAnchor` core — this is
10
+ * just the React binding.)
11
+ */
12
+ export interface UseViewAnchorOptions extends ViewAnchorOptions {
13
+ /**
14
+ * Non-DOM dependencies that move the target's rect and must force a
15
+ * re-publish (layout signature, project path, a tab toggle's
16
+ * `display:none`, …). A `ResizeObserver` covers pure geometry; `deps`
17
+ * covers state it cannot see. Keep the array length stable across
18
+ * renders (React effect-deps rule).
19
+ */
20
+ deps?: ReadonlyArray<unknown>
21
+ }
22
+
23
+ export type ViewAnchorRef = (el: HTMLElement | null) => void
24
+
25
+ /**
26
+ * Bind a native view's bounds to whichever DOM element the returned ref
27
+ * callback is attached to. On attach → `createViewAnchor(el, opts)`; on
28
+ * detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
29
+ * `update`; on unmount → publish ZERO then `dispose`.
30
+ *
31
+ * Why ZERO on disappearance (P1 fix): the anchor's follower is a *main-process*
32
+ * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
33
+ * `dispose()` only stops observing — it deliberately never publishes again
34
+ * (its Contract 6/7). But the host only collapses the native view when it
35
+ * receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
36
+ * (not `display:none`) when hidden, so the ref goes to `null` and the native
37
+ * DevTools view would otherwise stay frozen at its last bounds, floating on
38
+ * top and occluding content. So the adapter (not core) must emit one ZERO via
39
+ * the already-tested `update({ present:false })` path before disposing.
40
+ */
41
+ export function useViewAnchor(opts: UseViewAnchorOptions): ViewAnchorRef {
42
+ const handleRef = useRef<ViewAnchorHandle | null>(null)
43
+ const elRef = useRef<HTMLElement | null>(null)
44
+ // Latest opts, read by the stable ref callback when it creates the anchor.
45
+ // Synced render-synchronously (NOT in an effect): the ref callback reads
46
+ // `optsRef.current` during *commit* (when the element attaches), which runs
47
+ // before passive effects. An effect-synced ref would be one render stale at
48
+ // that point, so a hidden→shown remount (`present` flips false→true together
49
+ // with the element re-mounting, exactly what the debug cell does) would
50
+ // create the anchor with the old `present:false` and emit a spurious ZERO
51
+ // before the real rect. A render write keeps it current at commit, and is
52
+ // idempotent under StrictMode's double render.
53
+ const optsRef = useRef(opts)
54
+ // eslint-disable-next-line react-hooks/refs -- see above: must be current at commit, before effects run
55
+ optsRef.current = opts
56
+
57
+ // Baseline for the re-apply effect's change detection. Declared here (before
58
+ // the ref callback) so the callback can re-seed it on (re)create.
59
+ const appliedRef = useRef<ReadonlyArray<unknown>>([
60
+ opts.present,
61
+ opts.publish,
62
+ ...(opts.deps ?? []),
63
+ ])
64
+
65
+ // Collapse the native view (publish ZERO) and tear the anchor down. Reuse
66
+ // the existing, tested `update({ present:false })` path: it synchronously
67
+ // publishes `{0,0,0,0}` and stops observing (core Contract 5), then
68
+ // `dispose()` makes the anchor inert. Idempotent via the `handleRef.current`
69
+ // null-check so the two callers below can never double-emit ZERO.
70
+ const collapseAndDispose = useRef((): void => {
71
+ const handle = handleRef.current
72
+ if (!handle) return
73
+ handle.update({ present: false, publish: optsRef.current.publish })
74
+ handle.dispose()
75
+ handleRef.current = null
76
+ })
77
+
78
+ const ref = useCallback<ViewAnchorRef>((el) => {
79
+ if (el === elRef.current) return
80
+ elRef.current = el
81
+ if (handleRef.current) {
82
+ if (el) {
83
+ // Swapping to *another* live element: dispose the old anchor without a
84
+ // ZERO. The new element publishes its real rect immediately below, so
85
+ // a transient ZERO between the two would only cause a needless
86
+ // detach/re-attach flicker of the native view.
87
+ handleRef.current.dispose()
88
+ handleRef.current = null
89
+ } else {
90
+ // Element detached (ref → null): the anchor point is gone, so collapse
91
+ // the native view (one ZERO) before disposing.
92
+ collapseAndDispose.current()
93
+ }
94
+ }
95
+ if (el) {
96
+ handleRef.current = createViewAnchor(el, {
97
+ present: optsRef.current.present,
98
+ publish: optsRef.current.publish,
99
+ })
100
+ // The anchor was just created at the current (present, publish, deps), so
101
+ // seed the re-apply baseline to match. Otherwise the post-commit re-apply
102
+ // effect would see this fresh state as a change and publish a second time
103
+ // — on a remount with a changed `present` that is a double-emit.
104
+ appliedRef.current = [
105
+ optsRef.current.present,
106
+ optsRef.current.publish,
107
+ ...(optsRef.current.deps ?? []),
108
+ ]
109
+ }
110
+ }, [])
111
+
112
+ // Re-apply on opts/deps change. We must `update` whenever the
113
+ // (present, publish, …deps) tuple actually changes, but NOT on the mount run
114
+ // (the ref callback already created the anchor and published once) and NOT on
115
+ // a StrictMode replay (dev double-fires this effect's setup with the *same*
116
+ // tuple — a blind `update` then re-publishes the mount rect a second time).
117
+ // So instead of guessing "is this the first run?", compare against the
118
+ // last-applied tuple and apply only on a genuine change. The tuple is seeded
119
+ // with the mount opts, so the mount run and its StrictMode replay both see
120
+ // "unchanged" and skip — idempotent by construction. `deps` keeps a stable
121
+ // length across renders (documented above), so positional compare is sound.
122
+ useEffect(() => {
123
+ const next: ReadonlyArray<unknown> = [
124
+ opts.present,
125
+ opts.publish,
126
+ ...(opts.deps ?? []),
127
+ ]
128
+ const prev = appliedRef.current
129
+ const changed =
130
+ next.length !== prev.length || next.some((v, i) => !Object.is(v, prev[i]))
131
+ if (!changed) return
132
+ appliedRef.current = next
133
+ handleRef.current?.update({ present: opts.present, publish: opts.publish })
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [opts.present, opts.publish, ...(opts.deps ?? [])])
136
+
137
+ // Collapse the native view + dispose on teardown.
138
+ //
139
+ // StrictMode-safe lifecycle: this effect's setup/cleanup is double-fired in
140
+ // dev (setup → cleanup → setup). The anchor itself is created/owned by the
141
+ // ref callback, which in React 18 fires exactly once on mount and once with
142
+ // `null` on a real detach — it is NOT replayed by StrictMode. So this effect
143
+ // must not destroy the ref-owned anchor on a *throwaway* unmount, or the
144
+ // re-setup would have nothing to restore.
145
+ //
146
+ // Discriminator: on a real teardown React detaches the element first
147
+ // (`ref(null)` → `elRef.current === null`, and that path already emitted the
148
+ // single ZERO + disposed); on a StrictMode throwaway unmount the element is
149
+ // still attached (`elRef.current !== null`, ref never fired `null`). So we
150
+ // only collapse here when the element is genuinely gone, and otherwise leave
151
+ // the live anchor intact for the immediate re-setup.
152
+ //
153
+ // The setup re-establishes the anchor if a prior cleanup ever tore it down
154
+ // while the element is still attached, keeping setup/cleanup symmetric.
155
+ useEffect(() => {
156
+ const collapse = collapseAndDispose.current
157
+ if (elRef.current && !handleRef.current) {
158
+ handleRef.current = createViewAnchor(elRef.current, {
159
+ present: optsRef.current.present,
160
+ publish: optsRef.current.publish,
161
+ })
162
+ }
163
+ return () => {
164
+ if (elRef.current === null) collapse()
165
+ }
166
+ }, [])
167
+
168
+ return ref
169
+ }
170
+
171
+ export type { Bounds }
@@ -0,0 +1,294 @@
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
+ // ── Extras: clipped/degenerate RO shapes, multi-instance isolation, and
6
+ // the new update() re-advertise behaviour ──────────────────────────────
7
+ //
8
+ // Same stub style as `size-advertiser.test.ts`. The one addition is
9
+ // `fireRaw(entry)`: the base `fire(block, inline)` always fills BOTH
10
+ // borderBoxSize and contentBoxSize with one box each, so it cannot model a
11
+ // degenerate entry (border-box absent, empty arrays). `fireRaw` posts an
12
+ // arbitrary entry verbatim, letting us exercise the
13
+ // `borderBoxSize?.[0] ?? contentBoxSize?.[0] ?? latest` fallback chain.
14
+
15
+ interface RoSize {
16
+ blockSize: number
17
+ inlineSize: number
18
+ }
19
+
20
+ class FakeResizeObserver {
21
+ static instances: FakeResizeObserver[] = []
22
+ observed: Element[] = []
23
+ disconnected = false
24
+ constructor(public cb: ResizeObserverCallback) {
25
+ FakeResizeObserver.instances.push(this)
26
+ }
27
+ observe(el: Element): void {
28
+ this.observed.push(el)
29
+ }
30
+ unobserve(): void {
31
+ /* unused */
32
+ }
33
+ disconnect(): void {
34
+ this.disconnected = true
35
+ }
36
+ /** Fire one standards-shaped entry (both boxes filled) for the target. */
37
+ fire(blockSize: number, inlineSize: number): void {
38
+ const target = this.observed[0] ?? document.createElement('div')
39
+ this.cb(
40
+ [
41
+ {
42
+ borderBoxSize: [{ blockSize, inlineSize }],
43
+ contentBoxSize: [{ blockSize, inlineSize }],
44
+ target,
45
+ },
46
+ ] as unknown as ResizeObserverEntry[],
47
+ this as unknown as ResizeObserver,
48
+ )
49
+ }
50
+ /** Post an arbitrary, possibly-degenerate entry verbatim (no box defaults).
51
+ * `target` defaults to the observed element when the caller omits it. */
52
+ fireRaw(entry: {
53
+ borderBoxSize?: RoSize[]
54
+ contentBoxSize?: RoSize[]
55
+ target?: Element
56
+ }): void {
57
+ const target = entry.target ?? this.observed[0] ?? document.createElement('div')
58
+ this.cb(
59
+ [{ ...entry, target } as unknown as ResizeObserverEntry],
60
+ this as unknown as ResizeObserver,
61
+ )
62
+ }
63
+ }
64
+
65
+ interface RafEntry {
66
+ id: number
67
+ cb: () => void
68
+ }
69
+ let rafQueue: RafEntry[] = []
70
+ let rafIdCounter = 0
71
+ function fakeRaf(cb: () => void): number {
72
+ rafIdCounter++
73
+ rafQueue.push({ id: rafIdCounter, cb })
74
+ return rafIdCounter
75
+ }
76
+ const cancelSpy = vi.fn()
77
+
78
+ beforeEach(() => {
79
+ FakeResizeObserver.instances = []
80
+ rafQueue = []
81
+ rafIdCounter = 0
82
+ cancelSpy.mockClear()
83
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
84
+ vi.stubGlobal(
85
+ 'requestAnimationFrame',
86
+ fakeRaf as unknown as typeof window.requestAnimationFrame,
87
+ )
88
+ vi.stubGlobal(
89
+ 'cancelAnimationFrame',
90
+ ((id: number) => {
91
+ cancelSpy(id)
92
+ rafQueue = rafQueue.filter((e) => e.id !== id)
93
+ }) as unknown as typeof window.cancelAnimationFrame,
94
+ )
95
+ })
96
+
97
+ afterEach(() => {
98
+ vi.restoreAllMocks()
99
+ vi.unstubAllGlobals()
100
+ })
101
+
102
+ function flushRafs(): void {
103
+ const q = rafQueue
104
+ rafQueue = []
105
+ q.forEach((e) => e.cb())
106
+ }
107
+
108
+ function obs(i = 0): FakeResizeObserver {
109
+ expect(FakeResizeObserver.instances.length).toBeGreaterThan(i)
110
+ return FakeResizeObserver.instances[i]!
111
+ }
112
+
113
+ function el(): HTMLElement {
114
+ return document.createElement('div')
115
+ }
116
+
117
+ // ── update() re-advertises the current value to the new sink ──────────────
118
+ // Bug it catches: an update() that only swaps the sink (without
119
+ // `emitNow(produce())`) leaves the new channel sizeless until the next RO
120
+ // tick — the documented mirror of the forward anchor's re-publish on update.
121
+
122
+ describe('createSizeAdvertiser — update() re-advertises current value', () => {
123
+ it('emits the current size to the new sink immediately, without a fresh RO tick', () => {
124
+ const first = vi.fn<(s: AdvertisedSize) => void>()
125
+ const second = vi.fn<(s: AdvertisedSize) => void>()
126
+ const handle = createSizeAdvertiser(el(), { axis: 'block', publish: first })
127
+
128
+ obs().fire(120, 0)
129
+ flushRafs()
130
+ expect(first).toHaveBeenCalledWith({ axis: 'block', extent: 120 })
131
+
132
+ // No fire() between swap and assertion — the value must arrive on update().
133
+ handle.update(second)
134
+ expect(second).toHaveBeenCalledTimes(1)
135
+ expect(second).toHaveBeenCalledWith({ axis: 'block', extent: 120 })
136
+ })
137
+ })
138
+
139
+ // ── update() with no size yet does not emit ───────────────────────────────
140
+ // Bug it catches: an update() that emits unconditionally (e.g. `emitNow` with a
141
+ // stale/zero default) sends a bogus frame before any real measurement exists.
142
+
143
+ describe('createSizeAdvertiser — update() before any size', () => {
144
+ it('does not call the new sink when no RO frame has produced a size yet', () => {
145
+ const first = vi.fn<(s: AdvertisedSize) => void>()
146
+ const second = vi.fn<(s: AdvertisedSize) => void>()
147
+ const handle = createSizeAdvertiser(el(), { axis: 'block', publish: first })
148
+
149
+ // latest is still null — produce() returns null.
150
+ handle.update(second)
151
+ expect(second).not.toHaveBeenCalled()
152
+ })
153
+ })
154
+
155
+ // ── border-box absent → fall back to content-box ──────────────────────────
156
+ // Bug it catches: a reader that hard-requires borderBoxSize drops every frame
157
+ // from a UA/path that only delivers contentBoxSize, so such targets never
158
+ // advertise.
159
+
160
+ describe('createSizeAdvertiser — content-box fallback', () => {
161
+ it('uses contentBoxSize when borderBoxSize is absent', () => {
162
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
163
+ createSizeAdvertiser(el(), { axis: 'block', publish })
164
+
165
+ obs().fireRaw({ contentBoxSize: [{ blockSize: 88, inlineSize: 0 }] })
166
+ flushRafs()
167
+
168
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 88 })
169
+ })
170
+ })
171
+
172
+ // ── empty box arrays don't lock the advertiser ────────────────────────────
173
+ // Bug it catches: an empty-array frame that writes `undefined`/0 into `latest`
174
+ // (instead of leaving it untouched) would either crash on `.blockSize` or
175
+ // poison the dedupe baseline, so a subsequent real size never publishes.
176
+
177
+ describe('createSizeAdvertiser — empty box arrays', () => {
178
+ it('an empty-array entry publishes nothing yet does not break a later real frame', () => {
179
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
180
+ createSizeAdvertiser(el(), { axis: 'block', publish })
181
+
182
+ obs().fireRaw({ borderBoxSize: [], contentBoxSize: [] })
183
+ flushRafs()
184
+ expect(publish).not.toHaveBeenCalled()
185
+
186
+ obs().fire(90, 0)
187
+ flushRafs()
188
+ expect(publish).toHaveBeenCalledTimes(1)
189
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 90 })
190
+ })
191
+ })
192
+
193
+ // ── extent 0 is a real frame (collapse), distinct from NaN (drop) ─────────
194
+ // Bug it catches: a producer that treats a falsy 0 like a missing value would
195
+ // silently swallow a genuine content-collapse, leaving the host oversized.
196
+
197
+ describe('createSizeAdvertiser — zero extent', () => {
198
+ it('publishes extent 0 (content collapsed) rather than dropping the frame', () => {
199
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
200
+ createSizeAdvertiser(el(), { axis: 'block', publish })
201
+
202
+ obs().fire(0, 0)
203
+ flushRafs()
204
+
205
+ expect(publish).toHaveBeenCalledTimes(1)
206
+ expect(publish).toHaveBeenCalledWith({ axis: 'block', extent: 0 })
207
+ })
208
+ })
209
+
210
+ // ── two advertisers are fully independent ─────────────────────────────────
211
+ // Bug it catches: shared module-level `latest`/dedupe state (a singleton) would
212
+ // cross-publish between advertisers, or one's baseline would suppress the
213
+ // other's first emit.
214
+
215
+ describe('createSizeAdvertiser — independent instances', () => {
216
+ it('each advertiser owns its own sink and dedupe baseline', () => {
217
+ const publishA = vi.fn<(s: AdvertisedSize) => void>()
218
+ const publishB = vi.fn<(s: AdvertisedSize) => void>()
219
+ createSizeAdvertiser(el(), { axis: 'block', publish: publishA })
220
+ createSizeAdvertiser(el(), { axis: 'block', publish: publishB })
221
+
222
+ // instances[0] drives A only.
223
+ obs(0).fire(120, 0)
224
+ flushRafs()
225
+ expect(publishA).toHaveBeenCalledTimes(1)
226
+ expect(publishA).toHaveBeenCalledWith({ axis: 'block', extent: 120 })
227
+ expect(publishB).not.toHaveBeenCalled()
228
+
229
+ // instances[1] drives B only — same extent A already used must still emit
230
+ // (independent baselines, not a shared dedupe).
231
+ obs(1).fire(120, 0)
232
+ flushRafs()
233
+ expect(publishB).toHaveBeenCalledTimes(1)
234
+ expect(publishB).toHaveBeenCalledWith({ axis: 'block', extent: 120 })
235
+ expect(publishA).toHaveBeenCalledTimes(1)
236
+ })
237
+ })
238
+
239
+ // ── a fresh advertiser starts with a clean baseline ───────────────────────
240
+ // Bug it catches: a leaked/static dedupe baseline would let a disposed
241
+ // advertiser's last extent swallow a new advertiser's identical first frame.
242
+
243
+ describe('createSizeAdvertiser — baseline resets per instance', () => {
244
+ it('a new advertiser publishes its first frame even if it equals a disposed one', () => {
245
+ const publishA = vi.fn<(s: AdvertisedSize) => void>()
246
+ const handleA = createSizeAdvertiser(el(), { axis: 'block', publish: publishA })
247
+ obs(0).fire(120, 0)
248
+ flushRafs()
249
+ handleA.dispose()
250
+
251
+ const publishB = vi.fn<(s: AdvertisedSize) => void>()
252
+ createSizeAdvertiser(el(), { axis: 'block', publish: publishB })
253
+ obs(1).fire(120, 0)
254
+ flushRafs()
255
+
256
+ expect(publishB).toHaveBeenCalledTimes(1)
257
+ expect(publishB).toHaveBeenCalledWith({ axis: 'block', extent: 120 })
258
+ })
259
+ })
260
+
261
+ // ── construction-time body/html footgun guard ─────────────────────────────
262
+ // Bug it catches: a missing (or always-on) <body>/<html> warning means the
263
+ // classic "measuring the host-driven view size → loop never converges" mistake
264
+ // ships silently, or every ordinary element spams a false warning.
265
+
266
+ describe('createSizeAdvertiser — body/html guard', () => {
267
+ it('warns once when target is document.body and not for a normal element', () => {
268
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
269
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
270
+
271
+ createSizeAdvertiser(document.body, { axis: 'block', publish })
272
+ expect(warn).toHaveBeenCalledTimes(1)
273
+
274
+ warn.mockClear()
275
+ createSizeAdvertiser(el(), { axis: 'block', publish })
276
+ expect(warn).not.toHaveBeenCalled()
277
+ })
278
+ })
279
+
280
+ // ── dispose() is idempotent ───────────────────────────────────────────────
281
+ // Bug it catches: a dispose missing its `disposed` re-entry guard would
282
+ // double-disconnect / double-cancel on a second call (or throw).
283
+
284
+ describe('createSizeAdvertiser — idempotent dispose', () => {
285
+ it('a second dispose() is a no-op (no throw, no extra disconnect)', () => {
286
+ const publish = vi.fn<(s: AdvertisedSize) => void>()
287
+ const handle = createSizeAdvertiser(el(), { axis: 'block', publish })
288
+
289
+ handle.dispose()
290
+ expect(() => handle.dispose()).not.toThrow()
291
+ // disconnect ran exactly once: the observer is nulled after the first.
292
+ expect(obs().disconnected).toBe(true)
293
+ })
294
+ })