view-anchor 0.2.2 → 1.0.0-beta.0

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 +71 -87
  2. package/README.zh-CN.md +59 -64
  3. package/dist/index.d.ts +5 -7
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -3
  6. package/dist/protocol-publisher.d.ts +7 -9
  7. package/dist/protocol-publisher.d.ts.map +1 -1
  8. package/dist/protocol-publisher.js +2 -8
  9. package/dist/protocol-types.d.ts +5 -8
  10. package/dist/protocol-types.d.ts.map +1 -1
  11. package/dist/protocol-types.js +3 -6
  12. package/dist/protocol.d.ts +2 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js +10 -2
  15. package/dist/react.d.ts +2 -15
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +43 -38
  18. package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
  19. package/dist/size-anchor.d.ts.map +1 -0
  20. package/dist/size-anchor.js +131 -0
  21. package/dist/types.d.ts +26 -32
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/view-anchor.d.ts +52 -33
  24. package/dist/view-anchor.d.ts.map +1 -1
  25. package/dist/view-anchor.js +186 -265
  26. package/docs/bidirectional-design.md +29 -34
  27. package/docs/index.html +656 -366
  28. package/docs/mechanism.md +35 -57
  29. package/docs/performance-report.md +12 -54
  30. package/docs/protocol.md +12 -3
  31. package/package.json +4 -6
  32. package/src/index.ts +5 -19
  33. package/src/protocol-publisher.ts +10 -16
  34. package/src/protocol-types.ts +5 -8
  35. package/src/protocol.ts +13 -7
  36. package/src/react.ts +51 -64
  37. package/src/size-anchor.ts +131 -0
  38. package/src/types.ts +29 -37
  39. package/src/view-anchor.ts +221 -287
  40. package/dist/measure-loop.d.ts +0 -24
  41. package/dist/measure-loop.d.ts.map +0 -1
  42. package/dist/measure-loop.js +0 -89
  43. package/dist/size-advertiser.d.ts.map +0 -1
  44. package/dist/size-advertiser.js +0 -94
  45. package/src/measure-loop.ts +0 -101
  46. package/src/size-advertiser.ts +0 -108
@@ -1,143 +1,17 @@
1
1
  import { watchAbort } from './abort.js';
2
- const ZERO = { x: 0, y: 0, width: 0, height: 0 };
3
2
  // Replaces a disposed instance's publish callback so a retained handle does
4
3
  // not keep the caller's original callback (and whatever it captured) alive.
5
4
  const NOOP_PUBLISH = () => false;
6
- // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
7
- // a collapsed rect). Coordinates (x, y) can be negative when an element is
8
- // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
5
+ // Default holdSelector: omitting the option keeps tracking presses on a role=separator splitter.
6
+ export const DEFAULT_HOLD_SELECTOR = '[role="separator"]';
7
+ // Round to integer pixels. Width and height are clamped to >= 0; x and y can be
8
+ // negative, so a view scrolled out of sight is not pinned to the screen edge.
9
9
  const clampRect = (r) => ({
10
10
  x: Math.round(r.x),
11
11
  y: Math.round(r.y),
12
12
  width: Math.max(0, Math.round(r.width)),
13
13
  height: Math.max(0, Math.round(r.height)),
14
14
  });
15
- /**
16
- * Bind a native view or external surface to the geometry of `target`.
17
- *
18
- * - `present === true`: measures `target.getBoundingClientRect()` and publishes
19
- * immediately, then re-measures synchronously on ResizeObserver and window resize.
20
- * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
21
- * and stops observing.
22
- * - `update(opts)`: re-applies options immediately.
23
- * - `dispose()`: stops observing and prevents any further publishes.
24
- *
25
- * Synchronous publishing: measurement and publishing occur directly in the
26
- * observer tick. Applying geometry outside the DOM may already be delayed;
27
- * adding requestAnimationFrame would add another frame of visual lag during drag
28
- * operations. High-frequency updates are deduplicated against the last accepted rect.
29
- */
30
- export function createViewAnchor(target, opts) {
31
- let present = opts.present;
32
- let publish = opts.publish;
33
- let observer = null;
34
- // Local, clearable alias for the `target` parameter so dispose() can drop
35
- // the strong reference without widening the public parameter's type.
36
- let targetRef = target;
37
- // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
38
- // force a re-publish even if the geometry did not change.
39
- let lastPublished = null;
40
- let publicationRevision = 0;
41
- let disposed = false;
42
- const measure = () => {
43
- const r = targetRef.getBoundingClientRect();
44
- // Drop ticks with non-finite values (NaN / Infinity are not usable geometry).
45
- if (!Number.isFinite(r.left) ||
46
- !Number.isFinite(r.top) ||
47
- !Number.isFinite(r.width) ||
48
- !Number.isFinite(r.height))
49
- return null;
50
- return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height });
51
- };
52
- const sameRect = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
53
- const publishCandidate = (candidate) => {
54
- const previous = lastPublished;
55
- const attempt = ++publicationRevision;
56
- lastPublished = candidate;
57
- try {
58
- const accepted = publish(candidate) !== false;
59
- // A reentrant dispose() during publish() already cleared lastPublished;
60
- // do not resurrect the pre-dispose value over that terminal state.
61
- if (!accepted && publicationRevision === attempt && !disposed)
62
- lastPublished = previous;
63
- return accepted;
64
- }
65
- catch (error) {
66
- if (publicationRevision === attempt && !disposed)
67
- lastPublished = previous;
68
- throw error;
69
- }
70
- };
71
- // Measure and publish synchronously on each observer tick.
72
- // Drops duplicate rects to coalesce same-frame resize events.
73
- const emit = () => {
74
- if (disposed || !present)
75
- return;
76
- const m = measure();
77
- if (!m)
78
- return;
79
- if (lastPublished && sameRect(lastPublished, m))
80
- return;
81
- publishCandidate(m);
82
- };
83
- const startObserving = () => {
84
- if (observer)
85
- return;
86
- observer = new ResizeObserver(emit);
87
- observer.observe(targetRef);
88
- window.addEventListener('resize', emit);
89
- };
90
- const stopObserving = () => {
91
- if (observer) {
92
- observer.disconnect();
93
- observer = null;
94
- }
95
- window.removeEventListener('resize', emit);
96
- };
97
- // Apply current options synchronously. Reset lastPublished so state changes
98
- // always re-publish even if dimensions have not changed.
99
- const apply = () => {
100
- lastPublished = null;
101
- if (present) {
102
- startObserving();
103
- const measured = measure();
104
- if (measured)
105
- publishCandidate(measured);
106
- }
107
- else {
108
- stopObserving();
109
- publishCandidate(ZERO);
110
- }
111
- };
112
- let removeAbortListener = () => { };
113
- const dispose = () => {
114
- if (disposed)
115
- return;
116
- disposed = true;
117
- removeAbortListener();
118
- removeAbortListener = () => { };
119
- stopObserving();
120
- targetRef = null;
121
- publish = NOOP_PUBLISH;
122
- lastPublished = null;
123
- };
124
- if (opts.signal?.aborted)
125
- dispose();
126
- else {
127
- removeAbortListener = watchAbort(opts.signal, dispose);
128
- apply();
129
- }
130
- return {
131
- update(next) {
132
- if (disposed)
133
- return;
134
- publish = next.publish;
135
- present = next.present;
136
- apply();
137
- },
138
- dispose,
139
- };
140
- }
141
15
  /**
142
16
  * Read target's current rect and return { visible: true, bounds }.
143
17
  * Does not infer visibility from dimensions.
@@ -161,19 +35,31 @@ const samePlacement = (a, b) => {
161
35
  return true;
162
36
  };
163
37
  /**
164
- * Explicit-visibility variant of createViewAnchor.
38
+ * Bind a native view or external surface to the geometry of `target`.
39
+ *
40
+ * When `visible === true`, publishes { visible: true, bounds } immediately,
41
+ * then re-measures synchronously on ResizeObserver and window resize.
42
+ * When `visible === false`, publishes { visible: false } and stops observing.
43
+ * Publishes are deduplicated against the last accepted Placement by default
44
+ * (see the `dedupe` option).
165
45
  */
166
- export function createPlacementAnchor(target, opts) {
46
+ export function createViewAnchor(target, opts) {
47
+ // A non-empty holdSelector must be a syntactically valid CSS selector
48
+ // (matches() throws SyntaxError otherwise). Validate before any state is
49
+ // created so a bad selector never partially applies.
50
+ if (opts.holdSelector)
51
+ target.matches(opts.holdSelector);
167
52
  let visible = opts.visible;
168
53
  let publish = opts.publish;
169
- let guardDisplayNone = opts.guardDisplayNone ?? false;
54
+ let treatZeroAreaAsHidden = opts.treatZeroAreaAsHidden ?? false;
170
55
  let followScroll = opts.followScroll ?? false;
171
56
  let followGeometry = opts.followGeometry ?? false;
172
- // Local, clearable alias for the `target` parameter so dispose() can drop
173
- // the strong reference without widening the public parameter's type.
57
+ let holdSelector = opts.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : opts.holdSelector;
58
+ let dedupe = opts.dedupe ?? true;
59
+ // Clearable alias so dispose() can drop the reference.
174
60
  let targetRef = target;
175
61
  let observer = null;
176
- let io = null;
62
+ let intersectionObserver = null;
177
63
  let scrollListening = false;
178
64
  let geometryListening = false;
179
65
  const capture = { capture: true };
@@ -181,20 +67,25 @@ export function createPlacementAnchor(target, opts) {
181
67
  let lastPublished = null;
182
68
  let publicationRevision = 0;
183
69
  let disposed = false;
184
- // --- Windowed RAF geometry sentinel state ---
185
- // The sentinel polls per frame during active movement and auto-closes once
70
+ // --- Frame following (followGeometry) state ---
71
+ // Frame following polls per frame during active movement and auto-closes once
186
72
  // geometry settles. While closed, no frame is scheduled (zero idle overhead).
187
73
  let rafId = null;
188
74
  let steadyFrames = 0;
189
75
  const STEADY_CLOSE_FRAMES = 2;
190
76
  const MAX_HIDDEN_FOLLOW_FRAMES = 30;
191
77
  const MAX_INVALID_FOLLOW_FRAMES = 30;
78
+ let hiddenFrames = 0;
192
79
  let invalidFrames = 0;
193
- // True while a [role="separator"] splitter drag is held.
194
- let pointerHeld = false;
195
- let activePointerId = null;
196
- let sentinelDeadline = null;
197
- const computePlacement = () => {
80
+ // Pointer ids currently matching holdSelector and held down. Non-empty
81
+ // keeps frame following open regardless of deadline or steady frames.
82
+ const heldPointerIds = new Set();
83
+ // The rect measured on the previous followed frame, independent of whether
84
+ // publish() accepted it. Drives the steady/moving decision so a publish()
85
+ // that keeps rejecting an unchanged measurement cannot keep resetting it.
86
+ let lastFramePlacement = null;
87
+ let followDeadline = null;
88
+ const measureTarget = () => {
198
89
  const p = measurePlacement(targetRef);
199
90
  if (p.visible &&
200
91
  (!Number.isFinite(p.bounds.x) ||
@@ -202,12 +93,12 @@ export function createPlacementAnchor(target, opts) {
202
93
  !Number.isFinite(p.bounds.width) ||
203
94
  !Number.isFinite(p.bounds.height)))
204
95
  return null;
205
- if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
96
+ if (treatZeroAreaAsHidden && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
206
97
  return { visible: false };
207
98
  }
208
99
  return p;
209
100
  };
210
- const publishCandidate = (candidate) => {
101
+ const publishPlacement = (candidate) => {
211
102
  const previous = lastPublished;
212
103
  const attempt = ++publicationRevision;
213
104
  lastPublished = candidate;
@@ -225,140 +116,151 @@ export function createPlacementAnchor(target, opts) {
225
116
  throw error;
226
117
  }
227
118
  };
228
- const emit = () => {
119
+ const measureAndPublish = () => {
229
120
  if (disposed || !visible)
230
121
  return;
231
- const p = computePlacement();
122
+ const p = measureTarget();
232
123
  if (!p)
233
124
  return;
234
- if (lastPublished && samePlacement(lastPublished, p))
125
+ if (dedupe && lastPublished && samePlacement(lastPublished, p))
235
126
  return;
236
- publishCandidate(p);
127
+ publishPlacement(p);
237
128
  };
238
- const shouldCloseOnHiddenPoll = () => {
239
- if (lastPublished?.visible === false)
240
- return true;
241
- return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
129
+ // Polls again next frame if following is still wanted after this frame's work.
130
+ const scheduleFollowFrame = () => {
131
+ if (!disposed && visible && followGeometry)
132
+ rafId = requestAnimationFrame(followFrame);
133
+ else
134
+ followDeadline = null;
242
135
  };
243
- const sentinelFrame = () => {
136
+ const followFrame = () => {
244
137
  rafId = null;
245
- if (disposed || !visible) {
246
- sentinelDeadline = null;
138
+ if (disposed ||
139
+ !visible ||
140
+ (heldPointerIds.size === 0 && followDeadline !== null && performance.now() >= followDeadline)) {
141
+ followDeadline = null;
247
142
  return;
248
143
  }
249
- if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
250
- sentinelDeadline = null;
251
- return;
252
- }
253
- const p = computePlacement();
144
+ const p = measureTarget();
254
145
  if (!p) {
255
- if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
256
- sentinelDeadline = null;
257
- return;
258
- }
259
- if (!disposed && visible && followGeometry) {
260
- rafId = requestAnimationFrame(sentinelFrame);
261
- }
262
- else {
263
- sentinelDeadline = null;
264
- }
146
+ if (++invalidFrames >= MAX_INVALID_FOLLOW_FRAMES)
147
+ followDeadline = null;
148
+ else
149
+ scheduleFollowFrame();
265
150
  return;
266
151
  }
267
152
  invalidFrames = 0;
268
153
  if (!p.visible) {
269
- if (shouldCloseOnHiddenPoll()) {
270
- sentinelDeadline = null;
271
- return;
272
- }
273
- if (!disposed && visible && followGeometry) {
274
- rafId = requestAnimationFrame(sentinelFrame);
154
+ // Hidden frames are never published here; stop once hidden is already
155
+ // published or the target stays hidden past its budget.
156
+ if (lastPublished?.visible === false || ++hiddenFrames >= MAX_HIDDEN_FOLLOW_FRAMES) {
157
+ followDeadline = null;
275
158
  }
276
159
  else {
277
- sentinelDeadline = null;
160
+ scheduleFollowFrame();
278
161
  }
279
162
  return;
280
163
  }
281
- if (lastPublished && samePlacement(lastPublished, p)) {
282
- steadyFrames++;
283
- if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
284
- sentinelDeadline = null;
164
+ // Steadiness is judged against what was measured last frame, not against
165
+ // what publish() accepted: a candidate that publish() keeps rejecting
166
+ // must not keep looking "different" forever just because publishPlacement
167
+ // rolls lastPublished back on rejection, which would spin the frame loop.
168
+ const steadyFrame = lastFramePlacement !== null && samePlacement(lastFramePlacement, p);
169
+ lastFramePlacement = p;
170
+ if (!dedupe || !lastPublished || !samePlacement(lastPublished, p))
171
+ publishPlacement(p);
172
+ if (steadyFrame) {
173
+ if (++steadyFrames >= STEADY_CLOSE_FRAMES && heldPointerIds.size === 0) {
174
+ followDeadline = null;
285
175
  return;
286
176
  }
287
177
  }
288
178
  else {
289
- publishCandidate(p);
290
179
  steadyFrames = 0;
180
+ hiddenFrames = 0;
291
181
  }
292
- if (!disposed && visible && followGeometry) {
293
- rafId = requestAnimationFrame(sentinelFrame);
294
- }
295
- else {
296
- sentinelDeadline = null;
297
- }
182
+ scheduleFollowFrame();
298
183
  };
299
- const openSentinel = () => {
184
+ const startFrameFollow = () => {
300
185
  if (!followGeometry || disposed)
301
186
  return;
302
187
  steadyFrames = 0;
188
+ hiddenFrames = 0;
189
+ // Seed from the last accepted placement so the first post-open frame
190
+ // doesn't look unsteady on an unchanged rect.
191
+ lastFramePlacement = lastPublished;
303
192
  if (rafId === null) {
304
193
  invalidFrames = 0;
305
- rafId = requestAnimationFrame(sentinelFrame);
194
+ rafId = requestAnimationFrame(followFrame);
306
195
  }
307
196
  };
308
- const closeSentinel = () => {
197
+ const stopFrameFollow = () => {
309
198
  if (rafId !== null) {
310
199
  cancelAnimationFrame(rafId);
311
200
  rafId = null;
312
201
  }
313
202
  steadyFrames = 0;
203
+ hiddenFrames = 0;
314
204
  invalidFrames = 0;
315
- sentinelDeadline = null;
316
- pointerHeld = false;
317
- activePointerId = null;
205
+ lastFramePlacement = lastPublished;
206
+ followDeadline = null;
207
+ heldPointerIds.clear();
318
208
  };
319
209
  const onScroll = () => {
320
210
  if (followGeometry)
321
- openSentinel();
211
+ startFrameFollow();
322
212
  else
323
- emit();
213
+ measureAndPublish();
324
214
  };
325
215
  const onPointerDown = (e) => {
326
216
  const t = e.target;
327
- if (t && t.closest && t.closest('[role="separator"]')) {
328
- if (!pointerHeld)
329
- activePointerId = e.pointerId;
330
- pointerHeld = true;
331
- openSentinel();
217
+ if (holdSelector && t && t.closest && t.closest(holdSelector)) {
218
+ // The first of possibly several concurrently-held pointers drops any
219
+ // pulse() deadline: a held press has no time limit.
220
+ if (heldPointerIds.size === 0)
221
+ followDeadline = null;
222
+ heldPointerIds.add(e.pointerId);
223
+ startFrameFollow();
332
224
  }
333
225
  };
334
- const releasePointer = (e) => {
335
- if (!pointerHeld)
226
+ // pointerId undefined means "release everything" (window blur, or an event
227
+ // that never carried a pointerId, matching the single-pointer tests that
228
+ // dispatch plain Events). Otherwise only that one id's hold ends, and the
229
+ // frame following only leaves its held (no-deadline) state once none remain.
230
+ const releasePointer = (pointerId) => {
231
+ if (heldPointerIds.size === 0)
336
232
  return;
337
- if (e && activePointerId !== e.pointerId)
233
+ if (pointerId !== undefined && !heldPointerIds.delete(pointerId))
338
234
  return;
339
- pointerHeld = false;
340
- activePointerId = null;
341
- openSentinel();
235
+ if (pointerId === undefined)
236
+ heldPointerIds.clear();
237
+ if (heldPointerIds.size > 0)
238
+ return;
239
+ followDeadline = null;
240
+ startFrameFollow();
342
241
  };
343
242
  const onPointerUp = (e) => {
344
- releasePointer(e);
243
+ releasePointer(e.pointerId);
345
244
  };
346
245
  const onPointerCancel = (e) => {
347
- releasePointer(e);
246
+ releasePointer(e.pointerId);
348
247
  };
349
248
  const onWindowBlur = () => {
350
249
  releasePointer();
351
250
  };
352
251
  const startOptionalObserving = () => {
353
- if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
354
- io = new IntersectionObserver(emit);
355
- io.observe(targetRef);
252
+ if (treatZeroAreaAsHidden &&
253
+ !intersectionObserver &&
254
+ typeof IntersectionObserver !== 'undefined') {
255
+ intersectionObserver = new IntersectionObserver(measureAndPublish);
256
+ intersectionObserver.observe(targetRef);
356
257
  }
357
258
  if (followScroll && !scrollListening) {
358
259
  window.addEventListener('scroll', onScroll, passiveCapture);
359
260
  scrollListening = true;
360
261
  }
361
- if (followGeometry && !geometryListening) {
262
+ // Mounted only while both followGeometry and holdSelector are set.
263
+ if (followGeometry && holdSelector && !geometryListening) {
362
264
  window.addEventListener('pointerdown', onPointerDown, capture);
363
265
  window.addEventListener('pointerup', onPointerUp, capture);
364
266
  window.addEventListener('pointercancel', onPointerCancel, capture);
@@ -366,48 +268,50 @@ export function createPlacementAnchor(target, opts) {
366
268
  geometryListening = true;
367
269
  }
368
270
  };
369
- const stopOptionalObserving = () => {
370
- if (io && !guardDisplayNone) {
371
- io.disconnect();
372
- io = null;
373
- }
374
- if (scrollListening && !followScroll) {
375
- window.removeEventListener('scroll', onScroll, passiveCapture);
376
- scrollListening = false;
377
- }
378
- if (geometryListening && !followGeometry) {
379
- window.removeEventListener('pointerdown', onPointerDown, capture);
380
- window.removeEventListener('pointerup', onPointerUp, capture);
381
- window.removeEventListener('pointercancel', onPointerCancel, capture);
382
- window.removeEventListener('blur', onWindowBlur);
383
- geometryListening = false;
384
- closeSentinel();
385
- }
271
+ const stopIntersection = () => {
272
+ if (!intersectionObserver)
273
+ return;
274
+ intersectionObserver.disconnect();
275
+ intersectionObserver = null;
386
276
  };
387
- const stopAllOptionalObserving = () => {
388
- if (io) {
389
- io.disconnect();
390
- io = null;
391
- }
392
- if (scrollListening) {
393
- window.removeEventListener('scroll', onScroll, passiveCapture);
394
- scrollListening = false;
395
- }
396
- if (geometryListening) {
397
- window.removeEventListener('pointerdown', onPointerDown, capture);
398
- window.removeEventListener('pointerup', onPointerUp, capture);
399
- window.removeEventListener('pointercancel', onPointerCancel, capture);
400
- window.removeEventListener('blur', onWindowBlur);
401
- geometryListening = false;
277
+ const stopScroll = () => {
278
+ if (!scrollListening)
279
+ return;
280
+ window.removeEventListener('scroll', onScroll, passiveCapture);
281
+ scrollListening = false;
282
+ };
283
+ const stopGeometryListeners = () => {
284
+ if (!geometryListening)
285
+ return;
286
+ window.removeEventListener('pointerdown', onPointerDown, capture);
287
+ window.removeEventListener('pointerup', onPointerUp, capture);
288
+ window.removeEventListener('pointercancel', onPointerCancel, capture);
289
+ window.removeEventListener('blur', onWindowBlur);
290
+ geometryListening = false;
291
+ };
292
+ // Detaches only the optional observers whose flag has been turned off.
293
+ const stopDisabledObserving = () => {
294
+ if (!treatZeroAreaAsHidden)
295
+ stopIntersection();
296
+ if (!followScroll)
297
+ stopScroll();
298
+ // The pointer listeners are only useful while both flags hold; drop them
299
+ // (and any held-pointer state they were tracking) as soon as either lapses.
300
+ if ((!followGeometry || !holdSelector) && geometryListening) {
301
+ stopGeometryListeners();
302
+ heldPointerIds.clear();
402
303
  }
403
- closeSentinel();
304
+ // Independent of whether the listeners were mounted: turning followGeometry
305
+ // off must always stop frame following, not just when holdSelector was set.
306
+ if (!followGeometry)
307
+ stopFrameFollow();
404
308
  };
405
309
  const startObserving = () => {
406
310
  if (observer)
407
311
  return;
408
- observer = new ResizeObserver(emit);
312
+ observer = new ResizeObserver(measureAndPublish);
409
313
  observer.observe(targetRef);
410
- window.addEventListener('resize', emit);
314
+ window.addEventListener('resize', measureAndPublish);
411
315
  startOptionalObserving();
412
316
  };
413
317
  const stopObserving = () => {
@@ -415,21 +319,24 @@ export function createPlacementAnchor(target, opts) {
415
319
  observer.disconnect();
416
320
  observer = null;
417
321
  }
418
- window.removeEventListener('resize', emit);
419
- stopAllOptionalObserving();
322
+ window.removeEventListener('resize', measureAndPublish);
323
+ stopIntersection();
324
+ stopScroll();
325
+ stopGeometryListeners();
326
+ stopFrameFollow();
420
327
  };
421
- const apply = () => {
328
+ const applyOptions = () => {
422
329
  lastPublished = null;
423
330
  if (visible) {
424
331
  startObserving();
425
- const placement = computePlacement();
332
+ const placement = measureTarget();
426
333
  if (placement)
427
- publishCandidate(placement);
334
+ publishPlacement(placement);
428
335
  }
429
336
  else {
430
337
  stopObserving();
431
338
  const hidden = { visible: false };
432
- publishCandidate(hidden);
339
+ publishPlacement(hidden);
433
340
  }
434
341
  };
435
342
  let removeAbortListener = () => { };
@@ -448,24 +355,38 @@ export function createPlacementAnchor(target, opts) {
448
355
  dispose();
449
356
  else {
450
357
  removeAbortListener = watchAbort(opts.signal, dispose);
451
- apply();
358
+ // A throwing first publish must not leave the observer/listeners it just
359
+ // started (startObserving() runs before the first publishPlacement())
360
+ // mounted on a handle the caller never got to dispose.
361
+ try {
362
+ applyOptions();
363
+ }
364
+ catch (error) {
365
+ dispose();
366
+ throw error;
367
+ }
452
368
  }
453
369
  return {
454
370
  update(next) {
455
371
  if (disposed)
456
372
  return;
373
+ // Validate before touching any state so a bad selector throws
374
+ // atomically and the previous holdSelector stays in effect.
375
+ if (next.holdSelector)
376
+ targetRef.matches(next.holdSelector);
457
377
  publish = next.publish;
458
378
  visible = next.visible;
459
- // Omitting a flag preserves its current value so callers that only
460
- // pass { visible, publish } don't inadvertently disable enabled flags.
461
- guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone;
462
- followScroll = next.followScroll ?? followScroll;
463
- followGeometry = next.followGeometry ?? followGeometry;
379
+ // Full configuration: omitted flags reset to defaults.
380
+ treatZeroAreaAsHidden = next.treatZeroAreaAsHidden ?? false;
381
+ followScroll = next.followScroll ?? false;
382
+ followGeometry = next.followGeometry ?? false;
383
+ holdSelector = next.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : next.holdSelector;
384
+ dedupe = next.dedupe ?? true;
464
385
  if (visible && observer) {
465
- stopOptionalObserving();
386
+ stopDisabledObserving();
466
387
  startOptionalObserving();
467
388
  }
468
- apply();
389
+ applyOptions();
469
390
  },
470
391
  dispose,
471
392
  pulse(durationMs) {
@@ -473,9 +394,9 @@ export function createPlacementAnchor(target, opts) {
473
394
  return;
474
395
  if (durationMs !== undefined && durationMs > 0) {
475
396
  const next = performance.now() + durationMs;
476
- sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
397
+ followDeadline = followDeadline === null ? next : Math.max(followDeadline, next);
477
398
  }
478
- openSentinel();
399
+ startFrameFollow();
479
400
  },
480
401
  };
481
402
  }