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