view-anchor 0.1.2 → 0.2.1

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.
Files changed (46) hide show
  1. package/README.md +111 -39
  2. package/README.zh-CN.md +119 -47
  3. package/dist/index.d.ts +6 -18
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -15
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +57 -17
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +207 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +128 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +29 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +230 -181
  30. package/docs/bidirectional-design.md +78 -106
  31. package/docs/index.html +772 -0
  32. package/docs/mechanism.md +116 -0
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +108 -0
  35. package/package.json +37 -14
  36. package/src/index.ts +8 -24
  37. package/src/measure-loop.ts +56 -42
  38. package/src/protocol-publisher.ts +254 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +181 -0
  41. package/src/react.ts +175 -139
  42. package/src/size-advertiser.ts +33 -31
  43. package/src/types.ts +35 -82
  44. package/src/view-anchor.ts +259 -236
  45. package/docs/anchor-3d.html +0 -615
  46. package/docs/mechanism.mdx +0 -119
@@ -1,26 +1,15 @@
1
- import type {
2
- Bounds,
3
- Placement,
4
- ViewAnchorOptions,
5
- ViewAnchorHandle,
6
- } from './types.js'
1
+ import type { Bounds, Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from './types.js'
7
2
 
8
3
  const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
9
4
 
10
- // Round to integers (setBounds rejects fractionals). Width/height are clamped
11
- // to ≥0 (negative area is meaningless and `0` is the canonical "hidden"
12
- // signal). x/y are NOT clamped: a position is a position — an anchored overlay
13
- // scrolled past the top/left edge has a legitimately NEGATIVE origin, and
14
- // flooring it to 0 would pin the native view at the edge instead of letting it
15
- // track its element off-screen. (Each consumer's IPC schema enforces its own
16
- // origin policy — some allow negatives; a placeholder always stays on-screen,
17
- // so its NonNegInt schema is unaffected.)
18
- const clampRect = (r: {
19
- x: number
20
- y: number
21
- width: number
22
- height: number
23
- }): Bounds => ({
5
+ // Replaces a disposed instance's publish callback so a retained handle does
6
+ // not keep the caller's original callback (and whatever it captured) alive.
7
+ const NOOP_PUBLISH = (): false => false
8
+
9
+ // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
10
+ // a collapsed rect). Coordinates (x, y) can be negative when an element is
11
+ // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
12
+ const clampRect = (r: { x: number; y: number; width: number; height: number }): Bounds => ({
24
13
  x: Math.round(r.x),
25
14
  y: Math.round(r.y),
26
15
  width: Math.max(0, Math.round(r.width)),
@@ -28,72 +17,79 @@ const clampRect = (r: {
28
17
  })
29
18
 
30
19
  /**
31
- * Create an anchor binding ONE native view's bounds to `target`'s geometry.
32
- *
33
- * Imperative core — no React, no Electron. Behaviour:
34
- * - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
35
- * width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
36
- * SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
37
- * - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
38
- * - `update(opts)`: re-apply synchronously.
39
- * - `dispose()`: stop observing, never publish again.
20
+ * Bind a native view or external surface to the geometry of `target`.
40
21
  *
41
- * Synchronous, NOT RAF-deferred: the native overlay is a cross-process
42
- * `WebContentsView` whose `setBounds` already lands ~1 compositor frame behind
43
- * the renderer's DOM paint (the two processes composite on different frames).
44
- * Deferring the measure+publish to a RAF stacked a SECOND frame on top — during
45
- * a height/splitter drag that read as the overlay visibly trailing the region
46
- * edge (worst when GROWING, where the not-yet-followed edge exposes background).
47
- * Publishing in the observer tick itself removes that self-inflicted frame and
48
- * leaves only the unavoidable cross-process frame (masked by matching the
49
- * placeholder/desk background colour). The anti-flood role the RAF used to play
50
- * — collapsing a burst of RO+resize ticks in one frame into one publish — is now
51
- * served by `lastPublished` dedup: a tick whose measured rect is byte-identical
52
- * to the last published one is dropped, so a continuous drag still emits at most
53
- * one publish per distinct rect.
22
+ * - `present === true`: measures `target.getBoundingClientRect()` and publishes
23
+ * immediately, then re-measures synchronously on ResizeObserver and window resize.
24
+ * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
25
+ * and stops observing.
26
+ * - `update(opts)`: re-applies options immediately.
27
+ * - `dispose()`: stops observing and prevents any further publishes.
54
28
  *
55
- * Teardown safety: there is no queued frame to outrun a state change — every
56
- * emit reads `disposed`/`present` synchronously, so a tick after
57
- * `update`/`dispose` can never write a stale rect over the live one.
29
+ * Synchronous publishing: measurement and publishing occur directly in the
30
+ * observer tick. Cross-process setBounds calls already have a compositor delay;
31
+ * adding requestAnimationFrame would add a second frame of visual lag during drag
32
+ * operations. High-frequency updates are deduplicated against the last accepted rect.
58
33
  */
59
- export function createViewAnchor(
60
- target: HTMLElement,
61
- opts: ViewAnchorOptions,
62
- ): ViewAnchorHandle {
34
+ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
63
35
  let present = opts.present
64
36
  let publish = opts.publish
65
37
  let observer: ResizeObserver | null = null
66
- // The last rect handed to `publish`, for dedup-coalescing (see header). Reset
67
- // to `null` on every `apply()` so a state change (e.g. zoom, which rides in
68
- // the `publish` closure, not in `Bounds`) always forces one fresh publish
69
- // even when the geometry is unchanged.
38
+ // Local, clearable alias for the `target` parameter so dispose() can drop
39
+ // the strong reference without widening the public parameter's type.
40
+ let targetRef: HTMLElement | null = target
41
+ // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
42
+ // force a re-publish even if the geometry did not change.
70
43
  let lastPublished: Bounds | null = null
44
+ let publicationRevision = 0
71
45
  let disposed = false
72
46
 
73
- const measure = (): Bounds => {
74
- const r = target.getBoundingClientRect()
47
+ const measure = (): Bounds | null => {
48
+ const r = targetRef!.getBoundingClientRect()
49
+ // Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
50
+ if (
51
+ !Number.isFinite(r.left) ||
52
+ !Number.isFinite(r.top) ||
53
+ !Number.isFinite(r.width) ||
54
+ !Number.isFinite(r.height)
55
+ )
56
+ return null
75
57
  return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
76
58
  }
77
59
 
78
60
  const sameRect = (a: Bounds, b: Bounds): boolean =>
79
61
  a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
80
62
 
81
- // Measure + publish SYNCHRONOUSLY on the triggering tick — no RAF defer (see
82
- // header for why). Bail if torn down or detached, and dedup a rect
83
- // byte-identical to the last published one (collapses a same-frame RO+resize
84
- // burst, and a steady drag that re-fires the same final rect, into one).
63
+ const publishCandidate = (candidate: Bounds): boolean => {
64
+ const previous = lastPublished
65
+ const attempt = ++publicationRevision
66
+ lastPublished = candidate
67
+ try {
68
+ const accepted = publish(candidate) !== false
69
+ // A reentrant dispose() during publish() already cleared lastPublished;
70
+ // do not resurrect the pre-dispose value over that terminal state.
71
+ if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
72
+ return accepted
73
+ } catch (error) {
74
+ if (publicationRevision === attempt && !disposed) lastPublished = previous
75
+ throw error
76
+ }
77
+ }
78
+
79
+ // Measure and publish synchronously on each observer tick.
80
+ // Drops duplicate rects to coalesce same-frame resize events.
85
81
  const emit = (): void => {
86
82
  if (disposed || !present) return
87
83
  const m = measure()
84
+ if (!m) return
88
85
  if (lastPublished && sameRect(lastPublished, m)) return
89
- lastPublished = m
90
- publish(m)
86
+ publishCandidate(m)
91
87
  }
92
88
 
93
89
  const startObserving = (): void => {
94
90
  if (observer) return
95
91
  observer = new ResizeObserver(emit)
96
- observer.observe(target)
92
+ observer.observe(targetRef!)
97
93
  window.addEventListener('resize', emit)
98
94
  }
99
95
 
@@ -105,19 +101,17 @@ export function createViewAnchor(
105
101
  window.removeEventListener('resize', emit)
106
102
  }
107
103
 
108
- // Apply the current (present, publish) synchronously. Reset `lastPublished`
109
- // first so the publish below is never dedup-skipped — a state change (zoom,
110
- // present flip, new publish target) must always re-emit even if the geometry
111
- // is byte-identical to the previous emit.
104
+ // Apply current options synchronously. Reset lastPublished so state changes
105
+ // always re-publish even if dimensions have not changed.
112
106
  const apply = (): void => {
113
107
  lastPublished = null
114
108
  if (present) {
115
109
  startObserving()
116
- lastPublished = measure()
117
- publish(lastPublished)
110
+ const measured = measure()
111
+ if (measured) publishCandidate(measured)
118
112
  } else {
119
113
  stopObserving()
120
- publish(ZERO)
114
+ publishCandidate(ZERO)
121
115
  }
122
116
  }
123
117
 
@@ -134,78 +128,63 @@ export function createViewAnchor(
134
128
  if (disposed) return
135
129
  disposed = true
136
130
  stopObserving()
131
+ targetRef = null
132
+ publish = NOOP_PUBLISH
133
+ lastPublished = null
137
134
  },
138
135
  }
139
136
  }
140
137
 
141
- // ── Explicit Placement API ────────────────────────────────────────────
142
- //
143
- // The modern surface that replaces the magic-`{0,0,0,0}` "hidden" value.
144
- // Visibility is an explicit discriminant, NEVER inferred from geometry — a
145
- // real 0×0-but-on-screen view is `{ visible:true, bounds:{...,width:0} }`
146
- // and a hidden one is `{ visible:false }` (no `bounds`). See `Placement`.
138
+ // --- Explicit Placement API ---
147
139
 
148
140
  export interface PlacementAnchorOptions {
149
141
  /**
150
- * Caller's INTENT: should the native view be on-screen? `true` →
151
- * publish the measured rect as `{ visible:true, bounds }`; `false` →
152
- * publish `{ visible:false }`. Crucially, hiddenness comes from this
153
- * flag, not from a measured zero size — so a legitimately 0-sized but
154
- * visible target still publishes `visible:true`.
142
+ * Whether the native view should be visible. When true, publishes
143
+ * { visible: true, bounds }; when false, publishes { visible: false }.
155
144
  */
156
145
  visible: boolean
157
- /** Receives each explicit Placement. Owns IPC → host. */
158
- publish: (placement: Placement) => void
146
+ /** Receives each explicit Placement. */
147
+ publish: Publisher<Placement>
159
148
  /**
160
- * Opt-in geometry detach. When true, a measured zero-area target (no
161
- * geometry box — display:none / unmounted / unstable first layout) publishes
162
- * `{ visible:false }` (detach-but-keep) instead of `{ visible:true,
163
- * bounds:0×0 }`, and an IntersectionObserver is attached so a display:none
164
- * transition (which ResizeObserver does not report) re-publishes. Default
165
- * false keeps the legitimate 0×0-visible semantics.
149
+ * When true, targets with zero area (such as display: none or unmounted elements)
150
+ * publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
151
+ * IntersectionObserver tracks display: none transitions. Default is false.
152
+ * Sticky across update(): omitting it preserves the current setting.
166
153
  */
167
154
  guardDisplayNone?: boolean
168
155
  /**
169
- * Opt-in capture-phase ancestor-scroll follow. When true, the anchor
170
- * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
171
- * bubble, but reach `window` while capturing), so an ancestor scroll
172
- * container scrolling the target re-measures and re-publishes. With
173
- * `followGeometry` off, the scroll callback does a single synchronous
174
- * `emit()`; with it on, the scroll OPENS the RAF sentinel window so the
175
- * follow tracks every frame of a scroll burst. Default false.
156
+ * When true, listens for capture-phase scroll events on window to re-measure
157
+ * when an ancestor container scrolls. Default is false.
158
+ * Sticky across update(): omitting it preserves the current setting.
176
159
  */
177
160
  followScroll?: boolean
178
161
  /**
179
- * Opt-in windowed RAF geometry sentinel. Catches ancestor
180
- * transform / reflow moves that no DOM event reports. The sentinel is
181
- * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
182
- * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
183
- * animation frame publishing IN-FRAME, and AUTO-CLOSES once the rect goes
184
- * steady (a few unchanged frames). While closed it schedules no frame, so the
185
- * static cost when idle is exactly zero. Default false.
162
+ * When true, polls geometry per animation frame during active motion (scrolls,
163
+ * splitter dragging, or pulse()) and auto-closes when steady. Zero idle overhead.
164
+ * Sticky across update(): omitting it preserves the current setting.
186
165
  */
187
166
  followGeometry?: boolean
188
167
  }
189
168
 
190
169
  export interface PlacementAnchorHandle {
191
- /** Apply new options; re-publishes immediately (mirrors `createViewAnchor`). */
170
+ /**
171
+ * Apply new options and re-publish immediately.
172
+ * guardDisplayNone, followScroll, and followGeometry are sticky: omitting a flag
173
+ * preserves its current value. Pass an explicit false to disable one.
174
+ */
192
175
  update(opts: PlacementAnchorOptions): void
193
176
  /** Stop observing; never publish again. */
194
177
  dispose(): void
195
178
  /**
196
- * Open the RAF sentinel window (animation follow); auto-closes after going
197
- * steady or after `durationMs`. No-op when `followGeometry` is false.
179
+ * Open the animation frame sentinel window. Auto-closes once stable
180
+ * or after durationMs. No-op if followGeometry is false.
198
181
  */
199
182
  pulse(durationMs?: number): void
200
183
  }
201
184
 
202
185
  /**
203
- * Pure measure: read `target`'s rect and wrap it as an explicit visible
204
- * Placement. Always `{ visible:true }` — hiddenness is a caller decision
205
- * (see `createPlacementAnchor`), so this never returns `{ visible:false }`
206
- * and never infers visibility from a 0 size. A collapsed (0×0) but present
207
- * element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
208
- * distinct from any hidden Placement.
186
+ * Read target's current rect and return { visible: true, bounds }.
187
+ * Does not infer visibility from dimensions.
209
188
  */
210
189
  export function measurePlacement(target: HTMLElement): Placement {
211
190
  const r = target.getBoundingClientRect()
@@ -216,8 +195,6 @@ export function measurePlacement(target: HTMLElement): Placement {
216
195
  }
217
196
 
218
197
  const samePlacement = (a: Placement, b: Placement): boolean => {
219
- // Discriminant-aware dedup: a visibility flip is always a change, even when
220
- // the geometry would otherwise look identical.
221
198
  if (a.visible !== b.visible) return false
222
199
  if (a.visible && b.visible) {
223
200
  return (
@@ -231,21 +208,7 @@ const samePlacement = (a: Placement, b: Placement): boolean => {
231
208
  }
232
209
 
233
210
  /**
234
- * The explicit-Placement mirror of `createViewAnchor`. Same observer/dedup/
235
- * teardown machinery, but the sink receives a `Placement`:
236
- * - `visible === true` → publish `measurePlacement(target)` and re-publish
237
- * SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
238
- * - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
239
- * do not observe.
240
- *
241
- * Dedup carries the discriminant (`samePlacement`), so a visibility flip is
242
- * never coalesced away.
243
- *
244
- * Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
245
- * target (display:none / unmounted / unstable first layout) publishes
246
- * `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
247
- * IntersectionObserver is attached so a display:none transition (which
248
- * ResizeObserver does not report) re-publishes.
211
+ * Explicit-visibility variant of createViewAnchor.
249
212
  */
250
213
  export function createPlacementAnchor(
251
214
  target: HTMLElement,
@@ -253,112 +216,129 @@ export function createPlacementAnchor(
253
216
  ): PlacementAnchorHandle {
254
217
  let visible = opts.visible
255
218
  let publish = opts.publish
256
- const guardDisplayNone = opts.guardDisplayNone ?? false
257
- // Captured at creation; the follow options are never re-set via update().
258
- const followScroll = opts.followScroll ?? false
259
- const followGeometry = opts.followGeometry ?? false
219
+ let guardDisplayNone = opts.guardDisplayNone ?? false
220
+ let followScroll = opts.followScroll ?? false
221
+ let followGeometry = opts.followGeometry ?? false
222
+ // Local, clearable alias for the `target` parameter so dispose() can drop
223
+ // the strong reference without widening the public parameter's type.
224
+ let targetRef: HTMLElement | null = target
260
225
  let observer: ResizeObserver | null = null
261
226
  let io: IntersectionObserver | null = null
227
+ let scrollListening = false
228
+ let geometryListening = false
229
+ const capture = { capture: true }
230
+ const passiveCapture = { capture: true, passive: true }
262
231
  let lastPublished: Placement | null = null
232
+ let publicationRevision = 0
263
233
  let disposed = false
264
234
 
265
- // ── Windowed RAF geometry sentinel state ──────────────────
266
- // The sentinel is a windowed poll, opened on demand and auto-closing once
267
- // the geometry goes steady. It publishes IN-FRAME (no nested defer): each
268
- // frame measures, and either publishes a changed rect synchronously or
269
- // counts toward the steady-close threshold. While closed `rafId` is null
270
- // and no frame is scheduled — zero static cost when idle.
235
+ // --- Windowed RAF geometry sentinel state ---
236
+ // The sentinel polls per frame during active movement and auto-closes once
237
+ // geometry settles. While closed, no frame is scheduled (zero idle overhead).
271
238
  let rafId: number | null = null
272
239
  let steadyFrames = 0
273
240
  const STEADY_CLOSE_FRAMES = 2
274
- // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
275
241
  const MAX_HIDDEN_FOLLOW_FRAMES = 30
276
- // True while a [role="separator"] splitter drag is in progress: set on the
277
- // capture-phase pointerdown that opened the window, cleared on pointerup.
278
- // A held pointer means the drag may still resume after a static pause, so a
279
- // steady run while held must NOT close the sentinel — it only closes once the
280
- // pointer is released. Without this gate a press that pauses a couple of
281
- // frames before the drag actually moves would close mid-press and drop the
282
- // entire subsequent drag.
242
+ const MAX_INVALID_FOLLOW_FRAMES = 30
243
+ let invalidFrames = 0
244
+ // True while a [role="separator"] splitter drag is held.
283
245
  let pointerHeld = false
284
- // Absolute time (performance.now()) past which a `pulse(durationMs)` window
285
- // force-closes even if the geometry is still changing — the upper bound that
286
- // prevents a perpetually-animating target from keeping the sentinel resident.
287
- // null = no time bound (scroll/splitter opens rely on steady-close instead).
246
+ let activePointerId: number | undefined | null = null
288
247
  let sentinelDeadline: number | null = null
289
248
 
290
- // Measure the target, applying the opt-in first-frame / display:none guard:
291
- // a zero-area box (no geometry to anchor) becomes a detach instead of a
292
- // 0×0-visible Placement. Default off → byte-for-byte the plain measure.
293
- const computePlacement = (): Placement => {
294
- const p = measurePlacement(target)
249
+ const computePlacement = (): Placement | null => {
250
+ const p = measurePlacement(targetRef!)
295
251
  if (
296
- guardDisplayNone &&
297
252
  p.visible &&
298
- (p.bounds.width === 0 || p.bounds.height === 0)
299
- ) {
253
+ (!Number.isFinite(p.bounds.x) ||
254
+ !Number.isFinite(p.bounds.y) ||
255
+ !Number.isFinite(p.bounds.width) ||
256
+ !Number.isFinite(p.bounds.height))
257
+ )
258
+ return null
259
+ if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
300
260
  return { visible: false }
301
261
  }
302
262
  return p
303
263
  }
304
264
 
265
+ const publishCandidate = (candidate: Placement): boolean => {
266
+ const previous = lastPublished
267
+ const attempt = ++publicationRevision
268
+ lastPublished = candidate
269
+ try {
270
+ const accepted = publish(candidate) !== false
271
+ // A reentrant dispose() during publish() already cleared lastPublished;
272
+ // do not resurrect the pre-dispose value over that terminal state.
273
+ if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
274
+ return accepted
275
+ } catch (error) {
276
+ if (publicationRevision === attempt && !disposed) lastPublished = previous
277
+ throw error
278
+ }
279
+ }
280
+
305
281
  const emit = (): void => {
306
282
  if (disposed || !visible) return
307
283
  const p = computePlacement()
284
+ if (!p) return
308
285
  if (lastPublished && samePlacement(lastPublished, p)) return
309
- lastPublished = p
310
- publish(p)
286
+ publishCandidate(p)
311
287
  }
312
288
 
313
- // Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
314
- // bounded follow run elapsed, else keep following (false); never publishes.
315
289
  const shouldCloseOnHiddenPoll = (): boolean => {
316
290
  if (lastPublished?.visible === false) return true
317
291
  return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
318
292
  }
319
293
 
320
- // One sentinel frame: measure, publish-in-frame if changed, else count toward
321
- // the steady-close threshold. Reads `disposed`/`visible` live so a frame
322
- // outliving teardown is inert.
323
294
  const sentinelFrame = (): void => {
324
295
  rafId = null
325
296
  if (disposed || !visible) {
326
297
  sentinelDeadline = null
327
298
  return
328
299
  }
329
- // Upper bound: a pulse window past its deadline closes regardless of
330
- // motion, so a target that changes every frame can't keep the sentinel alive.
331
300
  if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
332
301
  sentinelDeadline = null
333
- return // duration elapsed → close (no re-arm)
302
+ return
334
303
  }
335
304
  const p = computePlacement()
336
- // The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
337
- // hidden poll is a relayout transient to follow until restore (the fix).
305
+ if (!p) {
306
+ if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
307
+ sentinelDeadline = null
308
+ return
309
+ }
310
+ if (!disposed && visible && followGeometry) {
311
+ rafId = requestAnimationFrame(sentinelFrame)
312
+ } else {
313
+ sentinelDeadline = null
314
+ }
315
+ return
316
+ }
317
+ invalidFrames = 0
338
318
  if (!p.visible) {
339
- if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
340
- rafId = requestAnimationFrame(sentinelFrame)
319
+ if (shouldCloseOnHiddenPoll()) {
320
+ sentinelDeadline = null
321
+ return
322
+ }
323
+ if (!disposed && visible && followGeometry) {
324
+ rafId = requestAnimationFrame(sentinelFrame)
325
+ } else {
326
+ sentinelDeadline = null
327
+ }
341
328
  return
342
329
  }
343
330
  if (lastPublished && samePlacement(lastPublished, p)) {
344
331
  steadyFrames++
345
- // Steady-close only fires once the pointer is RELEASED: while a
346
- // splitter drag is held, a static pause is a hesitation, not the end of
347
- // the drag, so we keep polling (re-arm below) and let `steadyFrames`
348
- // accrue — it converges to a close within N frames after pointerup.
349
332
  if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
350
333
  sentinelDeadline = null
351
- return // steady (and released) → close
334
+ return
352
335
  }
353
336
  } else {
354
- lastPublished = p
355
- publish(p) // publish synchronously in THIS frame
337
+ publishCandidate(p)
356
338
  steadyFrames = 0
357
339
  }
358
- // `publish` may have synchronously disposed (or hidden) the anchor; re-read
359
- // live state so a re-entrant teardown leaves ZERO scheduled frames.
360
- if (!disposed && visible) {
361
- rafId = requestAnimationFrame(sentinelFrame) // keep polling
340
+ if (!disposed && visible && followGeometry) {
341
+ rafId = requestAnimationFrame(sentinelFrame)
362
342
  } else {
363
343
  sentinelDeadline = null
364
344
  }
@@ -367,7 +347,10 @@ export function createPlacementAnchor(
367
347
  const openSentinel = (): void => {
368
348
  if (!followGeometry || disposed) return
369
349
  steadyFrames = 0
370
- if (rafId === null) rafId = requestAnimationFrame(sentinelFrame)
350
+ if (rafId === null) {
351
+ invalidFrames = 0
352
+ rafId = requestAnimationFrame(sentinelFrame)
353
+ }
371
354
  }
372
355
 
373
356
  const closeSentinel = (): void => {
@@ -376,95 +359,129 @@ export function createPlacementAnchor(
376
359
  rafId = null
377
360
  }
378
361
  steadyFrames = 0
362
+ invalidFrames = 0
379
363
  sentinelDeadline = null
380
364
  pointerHeld = false
365
+ activePointerId = null
381
366
  }
382
367
 
383
- // An ancestor scroll moved the target's screen rect. With the sentinel on,
384
- // open the window so the whole scroll burst is followed frame-by-frame;
385
- // without it, a single synchronous emit() follows the new rect.
386
368
  const onScroll = (): void => {
387
369
  if (followGeometry) openSentinel()
388
370
  else emit()
389
371
  }
390
372
 
391
- // A capture-phase pointerdown on a [role="separator"] splitter handle marks
392
- // the start of a drag that moves the target via ancestor reflow (no RO tick)
393
- // → open the sentinel.
394
373
  const onPointerDown = (e: Event): void => {
395
374
  const t = e.target as Element | null
396
375
  if (t && t.closest && t.closest('[role="separator"]')) {
376
+ if (!pointerHeld) activePointerId = (e as PointerEvent).pointerId
397
377
  pointerHeld = true
398
378
  openSentinel()
399
379
  }
400
380
  }
401
381
 
402
- // Pointer released: the drag is over, so a steady run may now close the
403
- // sentinel. Re-open it (a no-op if already polling) so the steady-close
404
- // threshold is reached even if the geometry was already static at release.
405
- const onPointerUp = (): void => {
382
+ const releasePointer = (e?: Event): void => {
406
383
  if (!pointerHeld) return
384
+ if (e && activePointerId !== (e as PointerEvent).pointerId) return
407
385
  pointerHeld = false
386
+ activePointerId = null
408
387
  openSentinel()
409
388
  }
410
389
 
411
- const startObserving = (): void => {
412
- if (observer) return
413
- observer = new ResizeObserver(emit)
414
- observer.observe(target)
415
- window.addEventListener('resize', emit)
416
- // A display:none transition is invisible to ResizeObserver; an
417
- // IntersectionObserver re-fires `emit`, which re-measures via
418
- // `computePlacement` (now-zero box → detach, restored box → visible).
419
- if (guardDisplayNone && typeof IntersectionObserver !== 'undefined') {
390
+ const onPointerUp = (e: Event): void => {
391
+ releasePointer(e)
392
+ }
393
+
394
+ const onPointerCancel = (e: Event): void => {
395
+ releasePointer(e)
396
+ }
397
+
398
+ const onWindowBlur = (): void => {
399
+ releasePointer()
400
+ }
401
+
402
+ const startOptionalObserving = (): void => {
403
+ if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
420
404
  io = new IntersectionObserver(emit)
421
- io.observe(target)
405
+ io.observe(targetRef!)
422
406
  }
423
- if (followScroll) {
424
- window.addEventListener('scroll', onScroll, {
425
- capture: true,
426
- passive: true,
427
- })
407
+ if (followScroll && !scrollListening) {
408
+ window.addEventListener('scroll', onScroll, passiveCapture)
409
+ scrollListening = true
428
410
  }
429
- if (followGeometry) {
430
- window.addEventListener('pointerdown', onPointerDown, { capture: true })
431
- window.addEventListener('pointerup', onPointerUp, { capture: true })
411
+ if (followGeometry && !geometryListening) {
412
+ window.addEventListener('pointerdown', onPointerDown, capture)
413
+ window.addEventListener('pointerup', onPointerUp, capture)
414
+ window.addEventListener('pointercancel', onPointerCancel, capture)
415
+ window.addEventListener('blur', onWindowBlur)
416
+ geometryListening = true
432
417
  }
433
418
  }
434
419
 
435
- const stopObserving = (): void => {
436
- if (observer) {
437
- observer.disconnect()
438
- observer = null
420
+ const stopOptionalObserving = (): void => {
421
+ if (io && !guardDisplayNone) {
422
+ io.disconnect()
423
+ io = null
424
+ }
425
+ if (scrollListening && !followScroll) {
426
+ window.removeEventListener('scroll', onScroll, passiveCapture)
427
+ scrollListening = false
439
428
  }
429
+ if (geometryListening && !followGeometry) {
430
+ window.removeEventListener('pointerdown', onPointerDown, capture)
431
+ window.removeEventListener('pointerup', onPointerUp, capture)
432
+ window.removeEventListener('pointercancel', onPointerCancel, capture)
433
+ window.removeEventListener('blur', onWindowBlur)
434
+ geometryListening = false
435
+ closeSentinel()
436
+ }
437
+ }
438
+
439
+ const stopAllOptionalObserving = (): void => {
440
440
  if (io) {
441
441
  io.disconnect()
442
442
  io = null
443
443
  }
444
- window.removeEventListener('resize', emit)
445
- window.removeEventListener('scroll', onScroll, {
446
- capture: true,
447
- } as EventListenerOptions)
448
- window.removeEventListener('pointerdown', onPointerDown, {
449
- capture: true,
450
- } as EventListenerOptions)
451
- window.removeEventListener('pointerup', onPointerUp, {
452
- capture: true,
453
- } as EventListenerOptions)
444
+ if (scrollListening) {
445
+ window.removeEventListener('scroll', onScroll, passiveCapture)
446
+ scrollListening = false
447
+ }
448
+ if (geometryListening) {
449
+ window.removeEventListener('pointerdown', onPointerDown, capture)
450
+ window.removeEventListener('pointerup', onPointerUp, capture)
451
+ window.removeEventListener('pointercancel', onPointerCancel, capture)
452
+ window.removeEventListener('blur', onWindowBlur)
453
+ geometryListening = false
454
+ }
454
455
  closeSentinel()
455
456
  }
456
457
 
458
+ const startObserving = (): void => {
459
+ if (observer) return
460
+ observer = new ResizeObserver(emit)
461
+ observer.observe(targetRef!)
462
+ window.addEventListener('resize', emit)
463
+ startOptionalObserving()
464
+ }
465
+
466
+ const stopObserving = (): void => {
467
+ if (observer) {
468
+ observer.disconnect()
469
+ observer = null
470
+ }
471
+ window.removeEventListener('resize', emit)
472
+ stopAllOptionalObserving()
473
+ }
474
+
457
475
  const apply = (): void => {
458
476
  lastPublished = null
459
477
  if (visible) {
460
478
  startObserving()
461
- lastPublished = computePlacement()
462
- publish(lastPublished)
479
+ const placement = computePlacement()
480
+ if (placement) publishCandidate(placement)
463
481
  } else {
464
482
  stopObserving()
465
483
  const hidden: Placement = { visible: false }
466
- lastPublished = hidden
467
- publish(hidden)
484
+ publishCandidate(hidden)
468
485
  }
469
486
  }
470
487
 
@@ -475,23 +492,29 @@ export function createPlacementAnchor(
475
492
  if (disposed) return
476
493
  publish = next.publish
477
494
  visible = next.visible
495
+ // Omitting a flag preserves its current value so callers that only
496
+ // pass { visible, publish } don't inadvertently disable enabled flags.
497
+ guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone
498
+ followScroll = next.followScroll ?? followScroll
499
+ followGeometry = next.followGeometry ?? followGeometry
500
+ if (visible && observer) {
501
+ stopOptionalObserving()
502
+ startOptionalObserving()
503
+ }
478
504
  apply()
479
505
  },
480
506
  dispose(): void {
481
507
  if (disposed) return
482
508
  disposed = true
483
509
  stopObserving()
510
+ targetRef = null
511
+ publish = NOOP_PUBLISH
512
+ lastPublished = null
484
513
  },
485
514
  pulse(durationMs?: number): void {
486
- // Imperative window open: start the animation-follow window. It
487
- // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
488
- // at that deadline — whichever comes first. The deadline is the upper bound
489
- // that guarantees a still-animating target cannot keep the sentinel
490
- // resident; without it, only steady-close applies.
491
515
  if (disposed || !followGeometry) return
492
516
  if (durationMs !== undefined && durationMs > 0) {
493
517
  const next = performance.now() + durationMs
494
- // Extend (never shorten) an existing window's deadline.
495
518
  sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
496
519
  }
497
520
  openSentinel()