@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,1184 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { createViewAnchor, createPlacementAnchor } from './view-anchor.js'
|
|
3
|
+
import type { Bounds, Placement, ViewAnchorOptions } from './types.js'
|
|
4
|
+
|
|
5
|
+
// ── ResizeObserver / RAF stubs ───────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// - `FakeResizeObserver` records observed elements + disconnect, and
|
|
8
|
+
// exposes `fire()` to synchronously invoke its callback.
|
|
9
|
+
// - The anchor publishes SYNCHRONOUSLY (no RAF defer) — see the module
|
|
10
|
+
// header. We install `requestAnimationFrame`/`cancelAnimationFrame`
|
|
11
|
+
// spies NOT to drive a fake queue but as a regression guard: the anchor
|
|
12
|
+
// must NEVER call either (asserted in the "never schedules a RAF" test
|
|
13
|
+
// and implicitly available everywhere via `rafSpy`/`cancelSpy`).
|
|
14
|
+
|
|
15
|
+
class FakeResizeObserver {
|
|
16
|
+
static instances: FakeResizeObserver[] = []
|
|
17
|
+
observed: Element[] = []
|
|
18
|
+
disconnected = false
|
|
19
|
+
constructor(public cb: ResizeObserverCallback) {
|
|
20
|
+
FakeResizeObserver.instances.push(this)
|
|
21
|
+
}
|
|
22
|
+
observe(el: Element): void {
|
|
23
|
+
this.observed.push(el)
|
|
24
|
+
}
|
|
25
|
+
unobserve(): void {
|
|
26
|
+
/* unused */
|
|
27
|
+
}
|
|
28
|
+
disconnect(): void {
|
|
29
|
+
this.disconnected = true
|
|
30
|
+
}
|
|
31
|
+
fire(): void {
|
|
32
|
+
this.cb([], this)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// RAF regression guards: if the anchor ever re-introduces a RAF defer these
|
|
37
|
+
// spies will record a call, and the dedicated "never schedules a RAF" test
|
|
38
|
+
// fails. They do NOT queue anything — publishes are synchronous.
|
|
39
|
+
const rafSpy = vi.fn(() => 0)
|
|
40
|
+
const cancelSpy = vi.fn()
|
|
41
|
+
const resizeAddSpy = vi.fn()
|
|
42
|
+
const resizeRemoveSpy = vi.fn()
|
|
43
|
+
// Handlers added to the real window via the addEventListener spy, tracked so
|
|
44
|
+
// afterEach can remove leaked 'resize' listeners — tests create anchors
|
|
45
|
+
// directly and don't dispose them, so their listeners would otherwise
|
|
46
|
+
// accumulate across tests and fire in later tests.
|
|
47
|
+
let leakedResize: unknown[] = []
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
FakeResizeObserver.instances = []
|
|
51
|
+
rafSpy.mockClear()
|
|
52
|
+
cancelSpy.mockClear()
|
|
53
|
+
resizeAddSpy.mockClear()
|
|
54
|
+
resizeRemoveSpy.mockClear()
|
|
55
|
+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
|
56
|
+
vi.stubGlobal(
|
|
57
|
+
'requestAnimationFrame',
|
|
58
|
+
rafSpy as unknown as typeof window.requestAnimationFrame,
|
|
59
|
+
)
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
'cancelAnimationFrame',
|
|
62
|
+
cancelSpy as unknown as typeof window.cancelAnimationFrame,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Spy on window resize listener add/remove so we can assert the anchor
|
|
66
|
+
// installs exactly one resize listener when present, and removes it on
|
|
67
|
+
// dispose / present=false. We forward to the real implementation so
|
|
68
|
+
// dispatching a real 'resize' event still works.
|
|
69
|
+
const realAdd = window.addEventListener.bind(window)
|
|
70
|
+
const realRemove = window.removeEventListener.bind(window)
|
|
71
|
+
vi.spyOn(window, 'addEventListener').mockImplementation(
|
|
72
|
+
(type: string, ...rest: unknown[]) => {
|
|
73
|
+
if (type === 'resize') {
|
|
74
|
+
resizeAddSpy(...rest)
|
|
75
|
+
leakedResize.push(rest[0])
|
|
76
|
+
}
|
|
77
|
+
return (realAdd as unknown as (...a: unknown[]) => void)(type, ...rest)
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
|
81
|
+
(type: string, ...rest: unknown[]) => {
|
|
82
|
+
if (type === 'resize') {
|
|
83
|
+
resizeRemoveSpy(...rest)
|
|
84
|
+
leakedResize = leakedResize.filter((h) => h !== rest[0])
|
|
85
|
+
}
|
|
86
|
+
return (realRemove as unknown as (...a: unknown[]) => void)(type, ...rest)
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
// Remove resize listeners leaked by undisposed anchors before restoring
|
|
93
|
+
// the spies, so they can't fire in (and pollute) a later test.
|
|
94
|
+
leakedResize.forEach((h) =>
|
|
95
|
+
window.removeEventListener('resize', h as EventListener),
|
|
96
|
+
)
|
|
97
|
+
leakedResize = []
|
|
98
|
+
vi.restoreAllMocks()
|
|
99
|
+
vi.unstubAllGlobals()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** Assert an observer was installed, then return it. Used so a missing
|
|
103
|
+
* observer (skeleton no-op) surfaces as a clear "expected 1, got 0"
|
|
104
|
+
* behavioural assertion instead of a TypeError on a later `.fire()`. */
|
|
105
|
+
function firstObserver(): FakeResizeObserver {
|
|
106
|
+
expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
|
|
107
|
+
return FakeResizeObserver.instances[0]!
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Same, for the most-recently created observer (post-update). */
|
|
111
|
+
function lastObserver(): FakeResizeObserver {
|
|
112
|
+
expect(FakeResizeObserver.instances.length).toBeGreaterThanOrEqual(1)
|
|
113
|
+
return FakeResizeObserver.instances.at(-1)!
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Element fixture ──────────────────────────────────────────────────
|
|
117
|
+
//
|
|
118
|
+
// jsdom's `getBoundingClientRect` always returns zeros, so we stub it.
|
|
119
|
+
// `setRect` lets a test move the element after the anchor was created (to
|
|
120
|
+
// prove a tick reads the *current* rect, not a captured one).
|
|
121
|
+
|
|
122
|
+
function buildElement(rect: {
|
|
123
|
+
x: number
|
|
124
|
+
y: number
|
|
125
|
+
w: number
|
|
126
|
+
h: number
|
|
127
|
+
}): { el: HTMLElement; setRect: (next: typeof rect) => void } {
|
|
128
|
+
const el = document.createElement('div')
|
|
129
|
+
let current = rect
|
|
130
|
+
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
|
|
131
|
+
() =>
|
|
132
|
+
({
|
|
133
|
+
x: current.x,
|
|
134
|
+
y: current.y,
|
|
135
|
+
left: current.x,
|
|
136
|
+
top: current.y,
|
|
137
|
+
right: current.x + current.w,
|
|
138
|
+
bottom: current.y + current.h,
|
|
139
|
+
width: current.w,
|
|
140
|
+
height: current.h,
|
|
141
|
+
toJSON: () => ({}),
|
|
142
|
+
}) as DOMRect,
|
|
143
|
+
)
|
|
144
|
+
return {
|
|
145
|
+
el,
|
|
146
|
+
setRect(next) {
|
|
147
|
+
current = next
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const opts = (o: ViewAnchorOptions): ViewAnchorOptions => o
|
|
153
|
+
|
|
154
|
+
// ── Contract 1: present=true with geometry → immediate sync publish ──
|
|
155
|
+
// Bug it catches: a missing initial emit means the main process never
|
|
156
|
+
// learns where the view is — the native view never attaches.
|
|
157
|
+
|
|
158
|
+
describe('createViewAnchor — present=true initial sync', () => {
|
|
159
|
+
it('publishes the rounded/clamped rect once, synchronously, on create', () => {
|
|
160
|
+
const publish = vi.fn()
|
|
161
|
+
const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
|
|
162
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
163
|
+
|
|
164
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
165
|
+
expect(publish).toHaveBeenCalledWith({
|
|
166
|
+
x: 10,
|
|
167
|
+
y: 20,
|
|
168
|
+
width: 300,
|
|
169
|
+
height: 400,
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('rounds x/y (negatives allowed) and clamps width/height to ≥0', () => {
|
|
174
|
+
const publish = vi.fn()
|
|
175
|
+
// Fractional + negative left/top: x/y are ROUNDED (not clamped) — an
|
|
176
|
+
// element scrolled off the top/left edge has a legitimately negative
|
|
177
|
+
// origin and the native view must track it there. width/height are
|
|
178
|
+
// clamped to ≥0 (0 = the canonical hidden signal).
|
|
179
|
+
const { el } = buildElement({ x: -3.4, y: 12.6, w: 100.49, h: 0.5 })
|
|
180
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
181
|
+
|
|
182
|
+
expect(publish).toHaveBeenCalledWith({
|
|
183
|
+
x: -3, // Math.round(-3.4) — NOT clamped to 0
|
|
184
|
+
y: 13, // Math.round(12.6)
|
|
185
|
+
width: 100, // Math.max(0, Math.round(100.49))
|
|
186
|
+
height: 1, // Math.max(0, Math.round(0.5))
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ── Contract 2: present=false → immediate zero, no observer ──────────
|
|
192
|
+
// Bug it catches: if present=false does NOT publish zero, the native view
|
|
193
|
+
// never detaches and its old frame stays painted over the content.
|
|
194
|
+
// Bug it also catches: installing a ResizeObserver while detached wastes
|
|
195
|
+
// work and can re-publish a non-zero rect, re-attaching the view.
|
|
196
|
+
|
|
197
|
+
describe('createViewAnchor — present=false', () => {
|
|
198
|
+
it('publishes {0,0,0,0} immediately and does NOT observe', () => {
|
|
199
|
+
const publish = vi.fn()
|
|
200
|
+
const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
|
|
201
|
+
createViewAnchor(el, opts({ present: false, publish }))
|
|
202
|
+
|
|
203
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
205
|
+
// No ResizeObserver and no resize listener while detached.
|
|
206
|
+
expect(FakeResizeObserver.instances).toHaveLength(0)
|
|
207
|
+
expect(resizeAddSpy).not.toHaveBeenCalled()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ── Contract 3: present=true installs observers; ticks publish SYNC ──
|
|
212
|
+
// Bug it catches: a tick that defers to a RAF stacks a second compositor
|
|
213
|
+
// frame on top of the unavoidable cross-process frame — the overlay
|
|
214
|
+
// visibly trails the region edge during a drag. The new contract is to
|
|
215
|
+
// publish in the triggering tick itself, no RAF.
|
|
216
|
+
|
|
217
|
+
describe('createViewAnchor — present=true observation', () => {
|
|
218
|
+
it('observes the target and adds a window resize listener', () => {
|
|
219
|
+
const publish = vi.fn()
|
|
220
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
221
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
222
|
+
|
|
223
|
+
expect(FakeResizeObserver.instances).toHaveLength(1)
|
|
224
|
+
expect(FakeResizeObserver.instances[0]!.observed).toContain(el)
|
|
225
|
+
expect(resizeAddSpy).toHaveBeenCalledTimes(1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('a ResizeObserver tick re-publishes SYNCHRONOUSLY (no RAF) with the current rect', () => {
|
|
229
|
+
const publish = vi.fn()
|
|
230
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
231
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
232
|
+
publish.mockClear()
|
|
233
|
+
|
|
234
|
+
// Move the element, then fire the observer — the publish lands in the
|
|
235
|
+
// same synchronous tick, reading the *current* rect.
|
|
236
|
+
setRect({ x: 5, y: 6, w: 120, h: 130 })
|
|
237
|
+
firstObserver().fire()
|
|
238
|
+
|
|
239
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
240
|
+
expect(publish).toHaveBeenCalledWith({ x: 5, y: 6, width: 120, height: 130 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('a window resize tick re-publishes synchronously', () => {
|
|
244
|
+
const publish = vi.fn()
|
|
245
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
246
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
247
|
+
publish.mockClear()
|
|
248
|
+
|
|
249
|
+
setRect({ x: 1, y: 2, w: 50, h: 60 })
|
|
250
|
+
window.dispatchEvent(new Event('resize'))
|
|
251
|
+
|
|
252
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
253
|
+
expect(publish).toHaveBeenCalledWith({ x: 1, y: 2, width: 50, height: 60 })
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('never schedules a requestAnimationFrame (RAF defer must not creep back)', () => {
|
|
257
|
+
const publish = vi.fn()
|
|
258
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
259
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
260
|
+
|
|
261
|
+
// Exercise every code path that used to schedule a RAF.
|
|
262
|
+
setRect({ x: 5, y: 6, w: 120, h: 130 })
|
|
263
|
+
firstObserver().fire()
|
|
264
|
+
window.dispatchEvent(new Event('resize'))
|
|
265
|
+
handle.update({ present: false, publish })
|
|
266
|
+
|
|
267
|
+
expect(rafSpy).not.toHaveBeenCalled()
|
|
268
|
+
expect(cancelSpy).not.toHaveBeenCalled()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// ── Contract 4: dedup-coalescing ─────────────────────────────────────
|
|
273
|
+
// Bug it catches: without dedup, a burst of RO+resize ticks in one frame —
|
|
274
|
+
// or a continuous drag that keeps re-firing the same final rect — produces N
|
|
275
|
+
// publishes (and N native-view setBounds calls) → IPC flood / jitter. The
|
|
276
|
+
// contract: a tick whose measured rect is byte-identical to the last published
|
|
277
|
+
// one is dropped; a distinct rect always publishes.
|
|
278
|
+
|
|
279
|
+
describe('createViewAnchor — dedup coalescing', () => {
|
|
280
|
+
it('N ticks measuring the SAME rect → exactly ONE publish', () => {
|
|
281
|
+
const publish = vi.fn()
|
|
282
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
283
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
284
|
+
publish.mockClear()
|
|
285
|
+
|
|
286
|
+
// A same-frame burst of RO + resize, all measuring the same (unchanged)
|
|
287
|
+
// rect: only the first emits, the rest dedup away.
|
|
288
|
+
const ro = firstObserver()
|
|
289
|
+
ro.fire()
|
|
290
|
+
ro.fire()
|
|
291
|
+
window.dispatchEvent(new Event('resize'))
|
|
292
|
+
ro.fire()
|
|
293
|
+
|
|
294
|
+
expect(publish).not.toHaveBeenCalled() // rect unchanged since create
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('a tick whose rect differs from the create-time rect publishes once', () => {
|
|
298
|
+
const publish = vi.fn()
|
|
299
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
300
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
301
|
+
publish.mockClear()
|
|
302
|
+
|
|
303
|
+
setRect({ x: 7, y: 8, w: 110, h: 120 })
|
|
304
|
+
firstObserver().fire()
|
|
305
|
+
firstObserver().fire() // same rect again → deduped
|
|
306
|
+
|
|
307
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
308
|
+
expect(publish).toHaveBeenCalledWith({ x: 7, y: 8, width: 110, height: 120 })
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('ticks measuring DIFFERENT rects each publish; identical rects dedup', () => {
|
|
312
|
+
const publish = vi.fn()
|
|
313
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
314
|
+
createViewAnchor(el, opts({ present: true, publish }))
|
|
315
|
+
publish.mockClear()
|
|
316
|
+
const ro = firstObserver()
|
|
317
|
+
|
|
318
|
+
// Move → publish.
|
|
319
|
+
setRect({ x: 5, y: 5, w: 100, h: 100 })
|
|
320
|
+
ro.fire()
|
|
321
|
+
// Same rect twice more → deduped.
|
|
322
|
+
ro.fire()
|
|
323
|
+
ro.fire()
|
|
324
|
+
// Move again → publish.
|
|
325
|
+
setRect({ x: 5, y: 5, w: 200, h: 100 })
|
|
326
|
+
ro.fire()
|
|
327
|
+
// Move back to the first moved rect → distinct from last published → publish.
|
|
328
|
+
setRect({ x: 5, y: 5, w: 100, h: 100 })
|
|
329
|
+
ro.fire()
|
|
330
|
+
|
|
331
|
+
expect(publish).toHaveBeenCalledTimes(3)
|
|
332
|
+
expect(publish.mock.calls.map((c) => c[0])).toEqual([
|
|
333
|
+
{ x: 5, y: 5, width: 100, height: 100 },
|
|
334
|
+
{ x: 5, y: 5, width: 200, height: 100 },
|
|
335
|
+
{ x: 5, y: 5, width: 100, height: 100 },
|
|
336
|
+
])
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// ── Contract 5: update() re-publishes immediately per new state ──────
|
|
341
|
+
// Bug it catches: update() that defers (or no-ops) leaves the native view
|
|
342
|
+
// at its old bounds after a present flip — e.g. a panel that hid stays
|
|
343
|
+
// painted, or a panel that showed never re-measures.
|
|
344
|
+
|
|
345
|
+
describe('createViewAnchor — update()', () => {
|
|
346
|
+
it('true → false: publishes zero immediately and stops observing', () => {
|
|
347
|
+
const publish = vi.fn()
|
|
348
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
349
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
350
|
+
expect(firstObserver().disconnected).toBe(false)
|
|
351
|
+
publish.mockClear()
|
|
352
|
+
|
|
353
|
+
handle.update({ present: false, publish })
|
|
354
|
+
|
|
355
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
356
|
+
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
357
|
+
// Observer disconnected and resize listener removed.
|
|
358
|
+
expect(firstObserver().disconnected).toBe(true)
|
|
359
|
+
expect(resizeRemoveSpy).toHaveBeenCalledTimes(1)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('false → true: measures the current rect and starts observing', () => {
|
|
363
|
+
const publish = vi.fn()
|
|
364
|
+
const { el } = buildElement({ x: 7, y: 8, w: 90, h: 110 })
|
|
365
|
+
const handle = createViewAnchor(el, opts({ present: false, publish }))
|
|
366
|
+
// present=false start: zero, no observer.
|
|
367
|
+
expect(FakeResizeObserver.instances).toHaveLength(0)
|
|
368
|
+
publish.mockClear()
|
|
369
|
+
|
|
370
|
+
handle.update({ present: true, publish })
|
|
371
|
+
|
|
372
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
373
|
+
expect(publish).toHaveBeenCalledWith({ x: 7, y: 8, width: 90, height: 110 })
|
|
374
|
+
expect(FakeResizeObserver.instances).toHaveLength(1)
|
|
375
|
+
expect(FakeResizeObserver.instances[0]!.observed).toContain(el)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('swapping publish while present routes new emits to the new callback', () => {
|
|
379
|
+
const first = vi.fn()
|
|
380
|
+
const second = vi.fn()
|
|
381
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
382
|
+
const handle = createViewAnchor(el, opts({ present: true, publish: first }))
|
|
383
|
+
first.mockClear()
|
|
384
|
+
|
|
385
|
+
handle.update({ present: true, publish: second })
|
|
386
|
+
// Immediate re-publish goes to the new callback (apply() resets
|
|
387
|
+
// lastPublished, so an unchanged rect still emits — see next test).
|
|
388
|
+
expect(second).toHaveBeenCalledWith({ x: 0, y: 0, width: 100, height: 100 })
|
|
389
|
+
|
|
390
|
+
second.mockClear()
|
|
391
|
+
setRect({ x: 5, y: 5, w: 100, h: 100 })
|
|
392
|
+
lastObserver().fire()
|
|
393
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
394
|
+
expect(first).not.toHaveBeenCalled()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('update() with an unchanged rect still forces one publish (lastPublished reset)', () => {
|
|
398
|
+
// Why this matters: zoom rides in the `publish` closure, not in `Bounds`.
|
|
399
|
+
// A zoom change re-calls update() with the SAME geometry but a NEW publish
|
|
400
|
+
// closure — the anchor must re-emit so the new closure runs, even though
|
|
401
|
+
// the dedup would otherwise drop a byte-identical rect. `apply()` resets
|
|
402
|
+
// `lastPublished = null` to guarantee that one fresh publish.
|
|
403
|
+
const first = vi.fn()
|
|
404
|
+
const { el } = buildElement({ x: 12, y: 34, w: 56, h: 78 })
|
|
405
|
+
const handle = createViewAnchor(el, opts({ present: true, publish: first }))
|
|
406
|
+
expect(first).toHaveBeenCalledTimes(1)
|
|
407
|
+
|
|
408
|
+
// Same present/measure, unchanged geometry, but a brand-new publish spy.
|
|
409
|
+
const second = vi.fn()
|
|
410
|
+
handle.update({ present: true, publish: second })
|
|
411
|
+
|
|
412
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
413
|
+
expect(second).toHaveBeenCalledWith({ x: 12, y: 34, width: 56, height: 78 })
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// ── Contract 6: dispose() tears everything down ─────────────────────
|
|
418
|
+
// Bug it catches: a dispose that forgets to disconnect the RO / remove the
|
|
419
|
+
// resize listener leaks observers and can publish after teardown (the IPC
|
|
420
|
+
// target may already be gone → throw).
|
|
421
|
+
|
|
422
|
+
describe('createViewAnchor — dispose()', () => {
|
|
423
|
+
it('disconnects the observer and removes the resize listener', () => {
|
|
424
|
+
const publish = vi.fn()
|
|
425
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
426
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
427
|
+
|
|
428
|
+
handle.dispose()
|
|
429
|
+
|
|
430
|
+
expect(firstObserver().disconnected).toBe(true)
|
|
431
|
+
expect(resizeRemoveSpy).toHaveBeenCalledTimes(1)
|
|
432
|
+
// Teardown is synchronous and uses no RAF — nothing to cancel.
|
|
433
|
+
expect(cancelSpy).not.toHaveBeenCalled()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('never publishes again after dispose (later RO/resize events are ignored)', () => {
|
|
437
|
+
const publish = vi.fn()
|
|
438
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
439
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
440
|
+
const ro = firstObserver()
|
|
441
|
+
handle.dispose()
|
|
442
|
+
publish.mockClear()
|
|
443
|
+
|
|
444
|
+
// Move the element so a non-deduped rect WOULD publish if `disposed`
|
|
445
|
+
// weren't read synchronously in every emit. Any event after dispose
|
|
446
|
+
// must be inert — there is no queued frame to outrun the flag.
|
|
447
|
+
setRect({ x: 999, y: 999, w: 999, h: 999 })
|
|
448
|
+
ro.fire()
|
|
449
|
+
window.dispatchEvent(new Event('resize'))
|
|
450
|
+
expect(publish).not.toHaveBeenCalled()
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// ── Contract 7: teardown safety (no stale tick can overwrite live) ───
|
|
455
|
+
// There is no queued frame anymore, so the old "stale-RAF guard" is now a
|
|
456
|
+
// pure synchronous-read guard: every emit reads `disposed`/`present` live,
|
|
457
|
+
// so a tick after dispose()/update(present=false) can never write a stale
|
|
458
|
+
// rect over the live one.
|
|
459
|
+
|
|
460
|
+
describe('createViewAnchor — teardown safety', () => {
|
|
461
|
+
it('a tick after dispose() does not publish (disposed read synchronously)', () => {
|
|
462
|
+
const publish = vi.fn()
|
|
463
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
464
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
465
|
+
const ro = firstObserver()
|
|
466
|
+
publish.mockClear()
|
|
467
|
+
|
|
468
|
+
handle.dispose()
|
|
469
|
+
setRect({ x: 999, y: 999, w: 999, h: 999 })
|
|
470
|
+
ro.fire()
|
|
471
|
+
|
|
472
|
+
expect(publish).not.toHaveBeenCalled()
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('after update(present=false), a later tick does not publish a non-zero rect', () => {
|
|
476
|
+
const publish = vi.fn()
|
|
477
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
478
|
+
const handle = createViewAnchor(el, opts({ present: true, publish }))
|
|
479
|
+
const ro = firstObserver()
|
|
480
|
+
|
|
481
|
+
// Flip to present=false → synchronous zero, observers stopped.
|
|
482
|
+
handle.update({ present: false, publish })
|
|
483
|
+
expect(publish).toHaveBeenLastCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
484
|
+
publish.mockClear()
|
|
485
|
+
|
|
486
|
+
// A late tick (e.g. a detached-but-not-yet-GC'd observer reference) must
|
|
487
|
+
// not republish a non-zero rect over the zero — `present` is read live.
|
|
488
|
+
setRect({ x: 999, y: 999, w: 999, h: 999 })
|
|
489
|
+
ro.fire()
|
|
490
|
+
window.dispatchEvent(new Event('resize'))
|
|
491
|
+
expect(publish).not.toHaveBeenCalled()
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// Local type assertion so the file fails loudly if the Bounds shape drifts
|
|
496
|
+
// from what these tests assert against.
|
|
497
|
+
const _boundsShape: Bounds = { x: 0, y: 0, width: 0, height: 0 }
|
|
498
|
+
void _boundsShape
|
|
499
|
+
|
|
500
|
+
// ── display:none / first-frame guard (opt-in) ───────────────────────
|
|
501
|
+
//
|
|
502
|
+
// TDD — FAILING-FIRST tests for an ADDITIVE, OPT-IN hardening of
|
|
503
|
+
// `createPlacementAnchor`, gated behind a new `guardDisplayNone?: boolean`
|
|
504
|
+
// option (default false = today's behaviour, unchanged).
|
|
505
|
+
//
|
|
506
|
+
// Two new behaviours, ONLY when `guardDisplayNone: true`:
|
|
507
|
+
// §4 First-frame guard — `visible:true` but the measured rect has
|
|
508
|
+
// `width === 0 || height === 0` (no geometry box: unmounted /
|
|
509
|
+
// display:none / unstable first layout) → publish `{ visible:false }`
|
|
510
|
+
// (a detach, NO bounds) instead of `{ visible:true, bounds:{...,0,0} }`.
|
|
511
|
+
// A 0-area target has no geometry to anchor; emitting a detach avoids
|
|
512
|
+
// the native view flashing at (0,0). NOTE: only ZERO WIDTH OR HEIGHT
|
|
513
|
+
// triggers this — a NON-zero box at position (0,0) is still a normal
|
|
514
|
+
// `{ visible:true }`.
|
|
515
|
+
// §3/§2.E display:none guard — the anchor attaches an
|
|
516
|
+
// `IntersectionObserver` on the target. When the target goes
|
|
517
|
+
// display:none (IO reports `isIntersecting:false` with a zero-area
|
|
518
|
+
// `boundingClientRect`) → publish `{ visible:false }`. When it comes
|
|
519
|
+
// back with a non-zero box (IO intersecting again / a subsequent
|
|
520
|
+
// measure yields non-zero width&height) → re-measure → publish
|
|
521
|
+
// `{ visible:true, bounds }`.
|
|
522
|
+
//
|
|
523
|
+
// Caller intent still wins (§3.1): a caller-`visible:false` anchor is
|
|
524
|
+
// detached regardless — the guard NEVER flips a caller's `visible:false`
|
|
525
|
+
// to true, and NEVER detaches purely because a NON-zero box scrolled
|
|
526
|
+
// off-screen (a non-zero box at a negative/off-screen origin stays
|
|
527
|
+
// `visible:true` with its real, possibly negative-origin, bounds).
|
|
528
|
+
//
|
|
529
|
+
// This increment adds an IntersectionObserver, NOT a RAF — so the existing
|
|
530
|
+
// "never schedules a requestAnimationFrame" guard must stay valid. We assert
|
|
531
|
+
// it here too (`rafSpy`/`cancelSpy` from the shared harness).
|
|
532
|
+
//
|
|
533
|
+
// These tests must be RED today: `guardDisplayNone` is not implemented, so
|
|
534
|
+
// no IntersectionObserver is wired and a 0×0 visible measure still publishes
|
|
535
|
+
// `{ visible:true, bounds:0×0 }`.
|
|
536
|
+
|
|
537
|
+
// A controllable fake IntersectionObserver (jsdom has none). Mirrors the
|
|
538
|
+
// FakeResizeObserver style: records observed elements + disconnect, captures
|
|
539
|
+
// every instance, and exposes `trigger(entries)` to synchronously invoke its
|
|
540
|
+
// callback with a partial IntersectionObserverEntry list (only the fields the
|
|
541
|
+
// guard reads: `isIntersecting` + `boundingClientRect`).
|
|
542
|
+
class FakeIntersectionObserver {
|
|
543
|
+
static instances: FakeIntersectionObserver[] = []
|
|
544
|
+
observed: Element[] = []
|
|
545
|
+
disconnected = false
|
|
546
|
+
constructor(public cb: IntersectionObserverCallback) {
|
|
547
|
+
FakeIntersectionObserver.instances.push(this)
|
|
548
|
+
}
|
|
549
|
+
observe(el: Element): void {
|
|
550
|
+
this.observed.push(el)
|
|
551
|
+
}
|
|
552
|
+
unobserve(): void {
|
|
553
|
+
/* unused */
|
|
554
|
+
}
|
|
555
|
+
disconnect(): void {
|
|
556
|
+
this.disconnected = true
|
|
557
|
+
}
|
|
558
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
559
|
+
return []
|
|
560
|
+
}
|
|
561
|
+
// Drive the callback synchronously. Tests pass the minimal entry shape the
|
|
562
|
+
// guard inspects; we widen to the full entry type so `cb` typechecks.
|
|
563
|
+
trigger(
|
|
564
|
+
entries: Array<{
|
|
565
|
+
isIntersecting: boolean
|
|
566
|
+
boundingClientRect: { width: number; height: number }
|
|
567
|
+
}>,
|
|
568
|
+
): void {
|
|
569
|
+
this.cb(
|
|
570
|
+
entries as unknown as IntersectionObserverEntry[],
|
|
571
|
+
this as unknown as IntersectionObserver,
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
describe('createPlacementAnchor — display:none / first-frame guard (opt-in)', () => {
|
|
577
|
+
beforeEach(() => {
|
|
578
|
+
FakeIntersectionObserver.instances = []
|
|
579
|
+
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
|
580
|
+
})
|
|
581
|
+
// (The shared afterEach calls vi.unstubAllGlobals(), which removes the
|
|
582
|
+
// IntersectionObserver stub too — no extra teardown needed here.)
|
|
583
|
+
|
|
584
|
+
/** Assert an IntersectionObserver was installed, then return it (a missing
|
|
585
|
+
* one surfaces as a clear "expected ≥1, got 0" instead of a later
|
|
586
|
+
* TypeError on `.trigger()`). */
|
|
587
|
+
function firstIO(): FakeIntersectionObserver {
|
|
588
|
+
expect(FakeIntersectionObserver.instances.length).toBeGreaterThanOrEqual(1)
|
|
589
|
+
return FakeIntersectionObserver.instances[0]!
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const HIDDEN: Placement = { visible: false }
|
|
593
|
+
|
|
594
|
+
// a) first measure 0×0 with the guard ON → detach, not visible+0×0.
|
|
595
|
+
it('a) guardDisplayNone:true + first measure 0×0 → publishes { visible:false } (not visible:true+0×0)', () => {
|
|
596
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
597
|
+
const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
|
|
598
|
+
|
|
599
|
+
createPlacementAnchor(el, {
|
|
600
|
+
visible: true,
|
|
601
|
+
guardDisplayNone: true,
|
|
602
|
+
publish,
|
|
603
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
604
|
+
|
|
605
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
606
|
+
const p = publish.mock.calls[0]![0]
|
|
607
|
+
expect(p).toEqual(HIDDEN)
|
|
608
|
+
expect(p.visible).toBe(false)
|
|
609
|
+
expect('bounds' in p).toBe(false)
|
|
610
|
+
// Must NOT be the legitimate-0×0-visible value the default path emits.
|
|
611
|
+
expect(p).not.toEqual({ visible: true, bounds: { x: 42, y: 99, width: 0, height: 0 } })
|
|
612
|
+
// No RAF introduced by the guard.
|
|
613
|
+
expect(rafSpy).not.toHaveBeenCalled()
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// b) first measure non-zero box with the guard ON → normal visible publish.
|
|
617
|
+
it('b) guardDisplayNone:true + first measure non-zero box → publishes { visible:true, bounds }', () => {
|
|
618
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
619
|
+
const { el } = buildElement({ x: 10, y: 20, w: 300, h: 400 })
|
|
620
|
+
|
|
621
|
+
createPlacementAnchor(el, {
|
|
622
|
+
visible: true,
|
|
623
|
+
guardDisplayNone: true,
|
|
624
|
+
publish,
|
|
625
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
626
|
+
|
|
627
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
628
|
+
expect(publish).toHaveBeenCalledWith({
|
|
629
|
+
visible: true,
|
|
630
|
+
bounds: { x: 10, y: 20, width: 300, height: 400 },
|
|
631
|
+
})
|
|
632
|
+
// Guard installs an IntersectionObserver on the target.
|
|
633
|
+
expect(firstIO().observed).toContain(el)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
// c) display:none round-trip: visible → IO display:none → detach → restore → visible.
|
|
637
|
+
it('c) IO display:none transition publishes { visible:false }; a restored non-zero box re-publishes { visible:true, bounds }', () => {
|
|
638
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
639
|
+
const { el, setRect } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
|
|
640
|
+
|
|
641
|
+
createPlacementAnchor(el, {
|
|
642
|
+
visible: true,
|
|
643
|
+
guardDisplayNone: true,
|
|
644
|
+
publish,
|
|
645
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
646
|
+
// Initial: normal visible publish.
|
|
647
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
648
|
+
visible: true,
|
|
649
|
+
bounds: { x: 5, y: 6, width: 100, height: 120 },
|
|
650
|
+
})
|
|
651
|
+
publish.mockClear()
|
|
652
|
+
|
|
653
|
+
// Target goes display:none: IO reports not-intersecting + a zero-area box.
|
|
654
|
+
setRect({ x: 0, y: 0, w: 0, h: 0 })
|
|
655
|
+
firstIO().trigger([
|
|
656
|
+
{ isIntersecting: false, boundingClientRect: { width: 0, height: 0 } },
|
|
657
|
+
])
|
|
658
|
+
|
|
659
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
660
|
+
expect(publish).toHaveBeenLastCalledWith(HIDDEN)
|
|
661
|
+
publish.mockClear()
|
|
662
|
+
|
|
663
|
+
// Target comes back with a real box. A subsequent tick (ResizeObserver or
|
|
664
|
+
// IO intersecting) re-measures a non-zero box → visible again.
|
|
665
|
+
setRect({ x: 5, y: 6, w: 100, h: 120 })
|
|
666
|
+
firstIO().trigger([
|
|
667
|
+
{ isIntersecting: true, boundingClientRect: { width: 100, height: 120 } },
|
|
668
|
+
])
|
|
669
|
+
|
|
670
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
671
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
672
|
+
visible: true,
|
|
673
|
+
bounds: { x: 5, y: 6, width: 100, height: 120 },
|
|
674
|
+
})
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// d) caller visible:false wins — IO intersecting must NOT flip it to visible.
|
|
678
|
+
it('d) caller visible:false → detached; an IO "intersecting" tick does NOT flip to visible:true', () => {
|
|
679
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
680
|
+
const { el } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
|
|
681
|
+
|
|
682
|
+
createPlacementAnchor(el, {
|
|
683
|
+
visible: false,
|
|
684
|
+
guardDisplayNone: true,
|
|
685
|
+
publish,
|
|
686
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
687
|
+
|
|
688
|
+
// Detached on create.
|
|
689
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
690
|
+
expect(publish).toHaveBeenLastCalledWith(HIDDEN)
|
|
691
|
+
publish.mockClear()
|
|
692
|
+
|
|
693
|
+
// Even if the guard's IO fires "intersecting" with a real box, caller
|
|
694
|
+
// intent (visible:false) wins — no visible:true is published.
|
|
695
|
+
if (FakeIntersectionObserver.instances.length > 0) {
|
|
696
|
+
FakeIntersectionObserver.instances[0]!.trigger([
|
|
697
|
+
{ isIntersecting: true, boundingClientRect: { width: 100, height: 120 } },
|
|
698
|
+
])
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
expect(publish).not.toHaveBeenCalled()
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// e) non-zero box at a negative/off-screen origin → visible:true, negative origin preserved.
|
|
705
|
+
it('e) non-zero box at negative origin → { visible:true, bounds } (off-screen ≠ detach; negative origin kept)', () => {
|
|
706
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
707
|
+
const { el } = buildElement({ x: -50, y: -20, w: 300, h: 200 })
|
|
708
|
+
|
|
709
|
+
createPlacementAnchor(el, {
|
|
710
|
+
visible: true,
|
|
711
|
+
guardDisplayNone: true,
|
|
712
|
+
publish,
|
|
713
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
714
|
+
|
|
715
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
716
|
+
expect(publish).toHaveBeenCalledWith({
|
|
717
|
+
visible: true,
|
|
718
|
+
bounds: { x: -50, y: -20, width: 300, height: 200 },
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// f) default-off regression: WITHOUT the option, 0×0 stays visible:true+0×0.
|
|
723
|
+
it('f) default (no guardDisplayNone): 0×0 measure → { visible:true, bounds:0×0 } unchanged', () => {
|
|
724
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
725
|
+
const { el } = buildElement({ x: 42, y: 99, w: 0, h: 0 })
|
|
726
|
+
|
|
727
|
+
createPlacementAnchor(el, { visible: true, publish })
|
|
728
|
+
|
|
729
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
730
|
+
expect(publish).toHaveBeenCalledWith({
|
|
731
|
+
visible: true,
|
|
732
|
+
bounds: { x: 42, y: 99, width: 0, height: 0 },
|
|
733
|
+
})
|
|
734
|
+
// Default off must NOT install an IntersectionObserver.
|
|
735
|
+
expect(FakeIntersectionObserver.instances).toHaveLength(0)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
// g) dispose disconnects the IO and no late IO callback publishes.
|
|
739
|
+
it('g) dispose() disconnects the IntersectionObserver and a late IO trigger publishes nothing', () => {
|
|
740
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
741
|
+
const { el, setRect } = buildElement({ x: 5, y: 6, w: 100, h: 120 })
|
|
742
|
+
|
|
743
|
+
const handle = createPlacementAnchor(el, {
|
|
744
|
+
visible: true,
|
|
745
|
+
guardDisplayNone: true,
|
|
746
|
+
publish,
|
|
747
|
+
} as Parameters<typeof createPlacementAnchor>[1])
|
|
748
|
+
const io = firstIO()
|
|
749
|
+
expect(io.disconnected).toBe(false)
|
|
750
|
+
|
|
751
|
+
handle.dispose()
|
|
752
|
+
expect(io.disconnected).toBe(true)
|
|
753
|
+
publish.mockClear()
|
|
754
|
+
|
|
755
|
+
// A late IO callback after dispose must be inert (disposed read live).
|
|
756
|
+
setRect({ x: 0, y: 0, w: 0, h: 0 })
|
|
757
|
+
io.trigger([
|
|
758
|
+
{ isIntersecting: false, boundingClientRect: { width: 0, height: 0 } },
|
|
759
|
+
])
|
|
760
|
+
|
|
761
|
+
expect(publish).not.toHaveBeenCalled()
|
|
762
|
+
})
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
// ── scroll + windowed RAF geometry sentinel (opt-in, increment 2) ────
|
|
766
|
+
//
|
|
767
|
+
// TDD — FAILING-FIRST tests for ADDITIVE, OPT-IN follow options on
|
|
768
|
+
// `createPlacementAnchor`, derived from view-anchor-following.md
|
|
769
|
+
// §2.C / §2.D / §2.F / §6 / §7. Both default OFF (= today's behaviour).
|
|
770
|
+
//
|
|
771
|
+
// followScroll?: boolean — §2.C ancestor-scroll capture listener.
|
|
772
|
+
// followGeometry?: boolean — §2.D windowed RAF geometry sentinel.
|
|
773
|
+
// pulse(durationMs?) — §2.F imperative "open the sentinel window".
|
|
774
|
+
//
|
|
775
|
+
// ────────────────────────────────────────────────────────────────────
|
|
776
|
+
// GOALPOST NOTE (§7): the package-wide "never RAF" invariant is
|
|
777
|
+
// INTENTIONALLY NARROWED by `followGeometry` (opt-in) to "EVENT-driven
|
|
778
|
+
// publishes (ResizeObserver / window resize / scroll) are never
|
|
779
|
+
// RAF-deferred". The sentinel rAF is a POLLING mechanism that publishes
|
|
780
|
+
// SYNCHRONOUSLY within its own frame — it is NOT a deferral of an
|
|
781
|
+
// event-driven publish (see view-anchor-following.md §7). The existing
|
|
782
|
+
// `createViewAnchor` "never schedules a requestAnimationFrame" test
|
|
783
|
+
// (above) REMAINS VALID and is left UNCHANGED, because `followGeometry`
|
|
784
|
+
// defaults off and the forward `createViewAnchor` core never opts in. The
|
|
785
|
+
// tests below re-pin that the DEFAULT / event-driven paths still schedule
|
|
786
|
+
// NO rAF, so the original guarantee holds wherever the sentinel is off.
|
|
787
|
+
// ────────────────────────────────────────────────────────────────────
|
|
788
|
+
//
|
|
789
|
+
// These tests must be RED today: `followScroll` / `followGeometry` /
|
|
790
|
+
// `pulse` are unimplemented, so no capture scroll listener / pointerdown
|
|
791
|
+
// listener / RAF sentinel exists.
|
|
792
|
+
|
|
793
|
+
// A CONTROLLABLE fake requestAnimationFrame. The shared `rafSpy` only
|
|
794
|
+
// returns 0 and stores nothing (it's a regression guard, not a driver), so
|
|
795
|
+
// the sentinel — which actually schedules frames — needs a queue we can
|
|
796
|
+
// flush one frame at a time. This block installs its own rAF/cancel stubs
|
|
797
|
+
// over the shared ones (the outer afterEach's vi.unstubAllGlobals() clears
|
|
798
|
+
// them, mirroring how the display:none block stubs IntersectionObserver).
|
|
799
|
+
class FakeRaf {
|
|
800
|
+
// Pending callbacks keyed by handle. A frame is "scheduled" while non-empty.
|
|
801
|
+
private cbs = new Map<number, FrameRequestCallback>()
|
|
802
|
+
private nextId = 1
|
|
803
|
+
request = vi.fn((cb: FrameRequestCallback): number => {
|
|
804
|
+
const id = this.nextId++
|
|
805
|
+
this.cbs.set(id, cb)
|
|
806
|
+
return id
|
|
807
|
+
})
|
|
808
|
+
cancel = vi.fn((id: number): void => {
|
|
809
|
+
this.cbs.delete(id)
|
|
810
|
+
})
|
|
811
|
+
/** Drain exactly the callbacks pending at call-time and run each once.
|
|
812
|
+
* A callback that re-requests another frame (the sentinel re-arming for
|
|
813
|
+
* the next poll) is NOT run again this flush — it lands in the next one,
|
|
814
|
+
* so `flushFrame()` advances exactly one frame. */
|
|
815
|
+
flushFrame(ts = 0): void {
|
|
816
|
+
const pending = [...this.cbs.entries()]
|
|
817
|
+
this.cbs.clear()
|
|
818
|
+
for (const [, cb] of pending) cb(ts)
|
|
819
|
+
}
|
|
820
|
+
/** Is a frame currently scheduled (sentinel window still open)? */
|
|
821
|
+
get pending(): number {
|
|
822
|
+
return this.cbs.size
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt-in, increment 2)', () => {
|
|
827
|
+
let raf: FakeRaf
|
|
828
|
+
|
|
829
|
+
beforeEach(() => {
|
|
830
|
+
raf = new FakeRaf()
|
|
831
|
+
// Install a controllable rAF over the shared regression-guard `rafSpy`.
|
|
832
|
+
// The outer afterEach's vi.unstubAllGlobals() restores everything.
|
|
833
|
+
vi.stubGlobal(
|
|
834
|
+
'requestAnimationFrame',
|
|
835
|
+
raf.request as unknown as typeof window.requestAnimationFrame,
|
|
836
|
+
)
|
|
837
|
+
vi.stubGlobal(
|
|
838
|
+
'cancelAnimationFrame',
|
|
839
|
+
raf.cancel as unknown as typeof window.cancelAnimationFrame,
|
|
840
|
+
)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
/** Build a `[role="separator"]` splitter element (the drag handle). A
|
|
844
|
+
* capture-phase pointerdown whose target matches `[role="separator"]`
|
|
845
|
+
* opens the sentinel window (§2.D). */
|
|
846
|
+
function buildSplitter(): HTMLElement {
|
|
847
|
+
const sep = document.createElement('div')
|
|
848
|
+
sep.setAttribute('role', 'separator')
|
|
849
|
+
document.body.appendChild(sep)
|
|
850
|
+
return sep
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Dispatch a capture-phase scroll on window (an ancestor scroll container
|
|
854
|
+
* scrolling — §2.C: scroll doesn't bubble, but reaches window in capture). */
|
|
855
|
+
function dispatchCaptureScroll(): void {
|
|
856
|
+
window.dispatchEvent(new Event('scroll'))
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Dispatch a real bubbling+capturable pointerdown originating at `target`
|
|
860
|
+
* (so a window capture-phase listener filtering on `[role="separator"]`
|
|
861
|
+
* sees it). */
|
|
862
|
+
function dispatchPointerdown(target: HTMLElement): void {
|
|
863
|
+
target.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Pointer release — ends a splitter drag so the sentinel may steady-close
|
|
867
|
+
* (§2.D: 关窗只发生在松手之后). Dispatched bubbling from the target and on
|
|
868
|
+
* window directly so a capture/bubble window listener sees it. */
|
|
869
|
+
function dispatchPointerup(target: HTMLElement): void {
|
|
870
|
+
target.dispatchEvent(new Event('pointerup', { bubbles: true }))
|
|
871
|
+
window.dispatchEvent(new Event('pointerup'))
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Cast helpers: the new options/method aren't on the public types yet.
|
|
875
|
+
type FollowOpts = Parameters<typeof createPlacementAnchor>[1] & {
|
|
876
|
+
followScroll?: boolean
|
|
877
|
+
followGeometry?: boolean
|
|
878
|
+
}
|
|
879
|
+
type PulseHandle = ReturnType<typeof createPlacementAnchor> & {
|
|
880
|
+
pulse: (durationMs?: number) => void
|
|
881
|
+
}
|
|
882
|
+
const mk = (
|
|
883
|
+
el: HTMLElement,
|
|
884
|
+
o: { visible: boolean; publish: (p: Placement) => void } & {
|
|
885
|
+
followScroll?: boolean
|
|
886
|
+
followGeometry?: boolean
|
|
887
|
+
},
|
|
888
|
+
): PulseHandle =>
|
|
889
|
+
createPlacementAnchor(el, o as FollowOpts) as PulseHandle
|
|
890
|
+
|
|
891
|
+
// ── A. followScroll — capture-phase ancestor scroll (§2.C) ──────────
|
|
892
|
+
|
|
893
|
+
// A1: capture scroll re-publishes the freshly-measured rect (basic scroll
|
|
894
|
+
// follow works WITHOUT the geometry sentinel — §2.C: "if followGeometry
|
|
895
|
+
// is false, the scroll callback should still at least do a synchronous
|
|
896
|
+
// emit()").
|
|
897
|
+
it('A1) followScroll (followGeometry off): a capture-phase scroll re-publishes the new measured rect synchronously', () => {
|
|
898
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
899
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
900
|
+
mk(el, { visible: true, followScroll: true, publish })
|
|
901
|
+
publish.mockClear()
|
|
902
|
+
|
|
903
|
+
// Ancestor scrolled → element's screen rect moved (y changed).
|
|
904
|
+
setRect({ x: 0, y: -40, w: 100, h: 100 })
|
|
905
|
+
dispatchCaptureScroll()
|
|
906
|
+
|
|
907
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
908
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
909
|
+
visible: true,
|
|
910
|
+
bounds: { x: 0, y: -40, width: 100, height: 100 },
|
|
911
|
+
})
|
|
912
|
+
// With followGeometry OFF, scroll-follow is purely synchronous: no rAF.
|
|
913
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
// A2: the scroll listener is registered with { capture:true } and removed
|
|
917
|
+
// on dispose. We assert via window.addEventListener / removeEventListener
|
|
918
|
+
// spies (the shared beforeEach already spies addEventListener; we read
|
|
919
|
+
// its recorded calls).
|
|
920
|
+
it('A2) followScroll registers a capture-phase window scroll listener and removes it on dispose', () => {
|
|
921
|
+
const addCalls: Array<[string, unknown, unknown]> = []
|
|
922
|
+
const removeCalls: Array<[string, unknown, unknown]> = []
|
|
923
|
+
const addSpy = vi
|
|
924
|
+
.spyOn(window, 'addEventListener')
|
|
925
|
+
.mockImplementation((type: string, cb: unknown, optsArg?: unknown) => {
|
|
926
|
+
addCalls.push([type, cb, optsArg])
|
|
927
|
+
})
|
|
928
|
+
const removeSpy = vi
|
|
929
|
+
.spyOn(window, 'removeEventListener')
|
|
930
|
+
.mockImplementation((type: string, cb: unknown, optsArg?: unknown) => {
|
|
931
|
+
removeCalls.push([type, cb, optsArg])
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
935
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
936
|
+
const handle = mk(el, { visible: true, followScroll: true, publish })
|
|
937
|
+
|
|
938
|
+
const scrollAdd = addCalls.find(([t]) => t === 'scroll')
|
|
939
|
+
expect(scrollAdd, 'a window scroll listener must be registered').toBeDefined()
|
|
940
|
+
// Capture phase — either `true` or `{ capture: true }`.
|
|
941
|
+
const optArg = scrollAdd![2]
|
|
942
|
+
const isCapture =
|
|
943
|
+
optArg === true ||
|
|
944
|
+
(typeof optArg === 'object' &&
|
|
945
|
+
optArg !== null &&
|
|
946
|
+
(optArg as { capture?: boolean }).capture === true)
|
|
947
|
+
expect(isCapture, 'scroll listener must be capture-phase').toBe(true)
|
|
948
|
+
|
|
949
|
+
const scrollCb = scrollAdd![1]
|
|
950
|
+
handle.dispose()
|
|
951
|
+
const scrollRemove = removeCalls.find(
|
|
952
|
+
([t, cb]) => t === 'scroll' && cb === scrollCb,
|
|
953
|
+
)
|
|
954
|
+
expect(
|
|
955
|
+
scrollRemove,
|
|
956
|
+
'the same scroll listener must be removed on dispose',
|
|
957
|
+
).toBeDefined()
|
|
958
|
+
|
|
959
|
+
addSpy.mockRestore()
|
|
960
|
+
removeSpy.mockRestore()
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
// A3: §2.C — a followScroll capture-scroll OPENS the RAF sentinel window
|
|
964
|
+
// when followGeometry is ALSO on (so it follows every frame of a scroll
|
|
965
|
+
// burst, not just the one synchronous emit).
|
|
966
|
+
it('A3) followScroll + followGeometry: a capture scroll opens the RAF sentinel (a frame becomes scheduled)', () => {
|
|
967
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
968
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
969
|
+
mk(el, {
|
|
970
|
+
visible: true,
|
|
971
|
+
followScroll: true,
|
|
972
|
+
followGeometry: true,
|
|
973
|
+
publish,
|
|
974
|
+
})
|
|
975
|
+
// Idle: no sentinel frame yet.
|
|
976
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
977
|
+
|
|
978
|
+
dispatchCaptureScroll()
|
|
979
|
+
|
|
980
|
+
// The scroll opened the windowed sentinel → a frame is scheduled.
|
|
981
|
+
expect(raf.request).toHaveBeenCalled()
|
|
982
|
+
expect(raf.pending).toBeGreaterThanOrEqual(1)
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
// ── B. followGeometry — windowed RAF geometry sentinel (§2.D/§6) ─────
|
|
986
|
+
|
|
987
|
+
// B-idle: §6 — IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
|
|
988
|
+
// The sentinel is windowed: static cost is exactly zero.
|
|
989
|
+
it('B-idle) followGeometry on but idle: NO rAF is ever scheduled (windowed = zero static cost)', () => {
|
|
990
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
991
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
992
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
993
|
+
|
|
994
|
+
// Even an ordinary ResizeObserver tick (event-driven) must not open the
|
|
995
|
+
// sentinel — it publishes synchronously instead (see B-sync below).
|
|
996
|
+
setRect({ x: 1, y: 1, w: 100, h: 100 })
|
|
997
|
+
firstObserver().fire()
|
|
998
|
+
|
|
999
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
// B-open: a splitter pointerdown (capture, matching [role="separator"])
|
|
1003
|
+
// opens the sentinel when followGeometry:true.
|
|
1004
|
+
it('B-open) a capture-phase pointerdown on a [role="separator"] opens the RAF sentinel', () => {
|
|
1005
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1006
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1007
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
1008
|
+
const splitter = buildSplitter()
|
|
1009
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1010
|
+
|
|
1011
|
+
dispatchPointerdown(splitter)
|
|
1012
|
+
|
|
1013
|
+
expect(raf.request).toHaveBeenCalled()
|
|
1014
|
+
expect(raf.pending).toBeGreaterThanOrEqual(1)
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
// B-open-nonseparator: a pointerdown NOT on a separator must NOT open it.
|
|
1018
|
+
it('B-open-nonseparator) a pointerdown on a non-separator element does NOT open the sentinel', () => {
|
|
1019
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1020
|
+
const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1021
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
1022
|
+
const plain = document.createElement('div')
|
|
1023
|
+
document.body.appendChild(plain)
|
|
1024
|
+
|
|
1025
|
+
dispatchPointerdown(plain)
|
|
1026
|
+
|
|
1027
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
// B-follow: once open, each frame whose measured rect CHANGED publishes the
|
|
1031
|
+
// new rect SYNCHRONOUSLY IN THAT rAF callback (not deferred to a nested
|
|
1032
|
+
// rAF). We assert via rect values + that publish happened during flushFrame
|
|
1033
|
+
// (i.e. exactly one publish per changed frame, not coalesced/deferred).
|
|
1034
|
+
it('B-follow) open sentinel + rect changes each frame → publishes the new rect in-frame, one per changed frame', () => {
|
|
1035
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1036
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1037
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
1038
|
+
const splitter = buildSplitter()
|
|
1039
|
+
|
|
1040
|
+
dispatchPointerdown(splitter) // open the window
|
|
1041
|
+
publish.mockClear()
|
|
1042
|
+
|
|
1043
|
+
// Frame 1: rect moved → publish in-frame.
|
|
1044
|
+
setRect({ x: 10, y: 0, w: 100, h: 100 })
|
|
1045
|
+
raf.flushFrame()
|
|
1046
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
1047
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
1048
|
+
visible: true,
|
|
1049
|
+
bounds: { x: 10, y: 0, width: 100, height: 100 },
|
|
1050
|
+
})
|
|
1051
|
+
// The publish happened DURING the rAF callback, not queued for another
|
|
1052
|
+
// frame: after the flush returned, the call count is already 1 (no nested
|
|
1053
|
+
// defer). The sentinel re-arms for the next frame (window still open).
|
|
1054
|
+
expect(raf.request.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
1055
|
+
|
|
1056
|
+
// Frame 2: moved again → another in-frame publish.
|
|
1057
|
+
setRect({ x: 20, y: 0, w: 100, h: 100 })
|
|
1058
|
+
raf.flushFrame()
|
|
1059
|
+
expect(publish).toHaveBeenCalledTimes(2)
|
|
1060
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
1061
|
+
visible: true,
|
|
1062
|
+
bounds: { x: 20, y: 0, width: 100, height: 100 },
|
|
1063
|
+
})
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
// B-close: after the pointer is RELEASED, N=2 consecutive UNCHANGED frames
|
|
1067
|
+
// cancel the rAF (steady = stop) — no further frame scheduled. (§2.D: a
|
|
1068
|
+
// steady run while the pointer is still HELD is a mid-drag pause and must
|
|
1069
|
+
// NOT close — see follow-geometry-press-drag.fix.test.ts; close is gated on
|
|
1070
|
+
// pointerup, so this test now releases before going steady.)
|
|
1071
|
+
it('B-close) pointerup then N=2 consecutive unchanged frames → sentinel stops (cancelAnimationFrame / no further frame scheduled)', () => {
|
|
1072
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1073
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1074
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
1075
|
+
const splitter = buildSplitter()
|
|
1076
|
+
|
|
1077
|
+
dispatchPointerdown(splitter) // open
|
|
1078
|
+
// One changing frame to prove it's live, then release and go steady.
|
|
1079
|
+
setRect({ x: 30, y: 0, w: 100, h: 100 })
|
|
1080
|
+
raf.flushFrame()
|
|
1081
|
+
expect(raf.pending).toBeGreaterThanOrEqual(1) // still polling
|
|
1082
|
+
dispatchPointerup(splitter) // drag over → steady-close now permitted
|
|
1083
|
+
|
|
1084
|
+
// Steady frame 1 (rect identical to last published) — not yet closed
|
|
1085
|
+
// (N=2 needs TWO consecutive identical frames).
|
|
1086
|
+
raf.flushFrame()
|
|
1087
|
+
// Steady frame 2 — now N=2 consecutive identical → close window.
|
|
1088
|
+
raf.flushFrame()
|
|
1089
|
+
|
|
1090
|
+
// The sentinel cancelled / stopped re-arming: no frame is pending and a
|
|
1091
|
+
// further flush runs nothing (no new publishes, no re-scheduled frame).
|
|
1092
|
+
const requestsBefore = raf.request.mock.calls.length
|
|
1093
|
+
raf.flushFrame()
|
|
1094
|
+
expect(raf.pending).toBe(0)
|
|
1095
|
+
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
// ── F. pulse() — explicit window open (§2.F) ────────────────────────
|
|
1099
|
+
|
|
1100
|
+
// F-open: pulse() opens the sentinel and it follows subsequent rect changes.
|
|
1101
|
+
it('F-open) pulse() opens the sentinel; it follows rect changes on subsequent frames', () => {
|
|
1102
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1103
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1104
|
+
const handle = mk(el, { visible: true, followGeometry: true, publish })
|
|
1105
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1106
|
+
|
|
1107
|
+
handle.pulse()
|
|
1108
|
+
expect(raf.request).toHaveBeenCalled()
|
|
1109
|
+
publish.mockClear()
|
|
1110
|
+
|
|
1111
|
+
// A transform/animation moved the rect (no DOM event) → sentinel catches it.
|
|
1112
|
+
setRect({ x: 7, y: 9, w: 100, h: 100 })
|
|
1113
|
+
raf.flushFrame()
|
|
1114
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
1115
|
+
visible: true,
|
|
1116
|
+
bounds: { x: 7, y: 9, width: 100, height: 100 },
|
|
1117
|
+
})
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
// F-close: pulse()'d window auto-closes ("durationMs 后或判静止后自动关")
|
|
1121
|
+
// — by steady frames (N=2 identical) here. After close, no frame pending.
|
|
1122
|
+
it('F-close) pulse() window auto-closes after it goes steady (N=2 identical frames)', () => {
|
|
1123
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1124
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1125
|
+
const handle = mk(el, { visible: true, followGeometry: true, publish })
|
|
1126
|
+
|
|
1127
|
+
handle.pulse()
|
|
1128
|
+
// One change, then steady.
|
|
1129
|
+
setRect({ x: 4, y: 4, w: 100, h: 100 })
|
|
1130
|
+
raf.flushFrame()
|
|
1131
|
+
raf.flushFrame() // steady 1
|
|
1132
|
+
raf.flushFrame() // steady 2 → close
|
|
1133
|
+
|
|
1134
|
+
const requestsBefore = raf.request.mock.calls.length
|
|
1135
|
+
raf.flushFrame()
|
|
1136
|
+
expect(raf.pending).toBe(0)
|
|
1137
|
+
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
// ── §7 goalpost: event-driven publishes stay SYNCHRONOUS even with the
|
|
1141
|
+
// sentinel enabled; the sentinel rAF publishes in-frame, not deferred. ──
|
|
1142
|
+
|
|
1143
|
+
// B-sync: a ResizeObserver tick still publishes SYNCHRONOUSLY (in the event
|
|
1144
|
+
// stack) WITHOUT scheduling a rAF, even with followGeometry enabled. This
|
|
1145
|
+
// is the §7 narrowing made concrete: event-driven ≠ RAF-deferred.
|
|
1146
|
+
it('§7) with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
|
|
1147
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1148
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1149
|
+
mk(el, { visible: true, followGeometry: true, publish })
|
|
1150
|
+
publish.mockClear()
|
|
1151
|
+
|
|
1152
|
+
setRect({ x: 3, y: 4, w: 100, h: 100 })
|
|
1153
|
+
firstObserver().fire()
|
|
1154
|
+
|
|
1155
|
+
// Published in the synchronous RO tick…
|
|
1156
|
+
expect(publish).toHaveBeenCalledTimes(1)
|
|
1157
|
+
expect(publish).toHaveBeenLastCalledWith({
|
|
1158
|
+
visible: true,
|
|
1159
|
+
bounds: { x: 3, y: 4, width: 100, height: 100 },
|
|
1160
|
+
})
|
|
1161
|
+
// …and the event-driven path did NOT route through a rAF (the §7 invariant
|
|
1162
|
+
// survives, narrowed: event-driven publishes are never RAF-deferred).
|
|
1163
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
// Default-path "still no rAF": with followGeometry OFF / unset, NOTHING in
|
|
1167
|
+
// the increment-2 surface schedules a rAF — the original package-wide
|
|
1168
|
+
// "never RAF" guarantee holds for the default + event-driven paths.
|
|
1169
|
+
it('§7-default) followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
|
|
1170
|
+
const publish = vi.fn<(p: Placement) => void>()
|
|
1171
|
+
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1172
|
+
const handle = mk(el, { visible: true, publish }) // followGeometry unset
|
|
1173
|
+
|
|
1174
|
+
setRect({ x: 5, y: 6, w: 120, h: 130 })
|
|
1175
|
+
firstObserver().fire()
|
|
1176
|
+
window.dispatchEvent(new Event('resize'))
|
|
1177
|
+
handle.update({ visible: false, publish } as Parameters<
|
|
1178
|
+
typeof createPlacementAnchor
|
|
1179
|
+
>[1])
|
|
1180
|
+
|
|
1181
|
+
expect(raf.request).not.toHaveBeenCalled()
|
|
1182
|
+
expect(raf.cancel).not.toHaveBeenCalled()
|
|
1183
|
+
})
|
|
1184
|
+
})
|