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