@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,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
|
+
})
|