bunnyquery 1.8.15 → 1.8.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.8.15",
3
+ "version": "1.8.16",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -153,6 +153,17 @@ export interface ScrollAnchor {
153
153
  settleReturn: () => boolean;
154
154
  /** A return is armed: its position, not the host's, decides scrollTop. */
155
155
  isReturning: () => boolean;
156
+ /**
157
+ * The reader is driving. Retire any armed return, at once.
158
+ *
159
+ * Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
160
+ * which fire synchronously on a real user action and never for a programmatic
161
+ * scroll. Waiting for the scroll EVENT is not good enough: a background refresh
162
+ * can settle in between, and a settle with a return still armed puts the reader
163
+ * back where they were before they left, which lands as "I touched the scroll
164
+ * and it threw me somewhere else".
165
+ */
166
+ release: () => void;
156
167
  /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
157
168
  pinBottom: () => void;
158
169
  /** The box is not being painted (hidden tab). Shared so hosts agree. */
@@ -187,6 +198,9 @@ var ROW_POS_ATTR = 'data-row-pos';
187
198
  var MAX_ALTS = 2;
188
199
  /** How far past the anchor collectAlts will look for them. */
189
200
  var ALT_SCAN_LIMIT = 64;
201
+ /** Settles an armed return may act on: the two halves of one head refresh, plus
202
+ * one spare for a refresh that was already in flight when the reader left. */
203
+ var MAX_RETURN_SETTLES = 3;
190
204
  /** data-row-pos is present but empty: the row cannot say where it is anchored. */
191
205
  var UNKNOWN_ROW_POS = '\u0000?';
192
206
 
@@ -313,6 +327,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
313
327
  // head refresh, which is what puts a pinned reader on the bottom that exists
314
328
  // after the deferred batch merges rather than the surface page's bottom.
315
329
  var returning = false;
330
+ // How many more settles an armed return may act on. A return exists to survive
331
+ // the two halves of one head refresh; beyond that it is a stale instruction
332
+ // lying in wait, and the reader has moved on. Bounded so a blur that never got
333
+ // a matching focus (clicking into devtools, another window, an iframe — all of
334
+ // which fire window blur) cannot arm something that fires minutes later.
335
+ var returnBudget = 0;
316
336
  // The last scrollTop THIS module wrote. A scroll event reporting anything else
317
337
  // is the reader, and the reader always wins — that is the whole retirement rule
318
338
  // for an armed return, and it is why every write below records it.
@@ -451,9 +471,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
451
471
  // module did not write is the reader, and the reader always wins. Checked
452
472
  // before the frozen bail because it is the one thing allowed to end a return.
453
473
  var b0 = options.getBox();
454
- if (returning && b0 && b0.scrollTop !== wroteTop) {
455
- parked = null; parkedStuck = false; returning = false;
456
- }
474
+ if (returning && b0 && b0.scrollTop !== wroteTop) release();
457
475
  // A scroll event while the tab is hidden is not the reader moving.
458
476
  if (frozen()) return;
459
477
  held = capture();
@@ -461,11 +479,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
461
479
 
462
480
  function hold(): void {
463
481
  if (frozen()) return;
464
- // Coming out of an absence: nothing that moved this box while nobody was
465
- // looking was the reader, so the parked place simply wins. `returning` keeps
466
- // this true across BOTH halves of a head refresh, so a correction the first
467
- // half's shrink clamped away is retried once the second half re-grows the list.
468
- if (sawFrozen || returning) { settleReturn(); return; }
482
+ // Coming out of a FROZEN stretch, and only that. An armed return is settled
483
+ // by the host, from its focus/visible handler and the refresh settles that
484
+ // follow — never from here. hold() runs on every render, including the many
485
+ // that happen WHILE the reader is still away, and letting those settle the
486
+ // return spent it before they were back to see it.
487
+ if (sawFrozen) { settleReturn(); return; }
469
488
  var box = options.getBox();
470
489
  if (!box || options.isStuck()) { held = null; return; }
471
490
  if (!held) { held = capture(); return; }
@@ -570,6 +589,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
570
589
  parked = capture();
571
590
  parkedStuck = !!options.isStuck();
572
591
  returning = true;
592
+ returnBudget = MAX_RETURN_SETTLES;
593
+ }
594
+
595
+ /** The reader is driving: an armed return is no longer anyone's business. */
596
+ function release(): void {
597
+ parked = null;
598
+ parkedStuck = false;
599
+ returning = false;
600
+ returnBudget = 0;
573
601
  }
574
602
 
575
603
  /**
@@ -605,7 +633,17 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
605
633
  */
606
634
  function settleReturn(): boolean {
607
635
  if (frozen()) return false;
636
+ var wasFrozen = sawFrozen;
608
637
  sawFrozen = false;
638
+ // Nothing is armed, so there is nothing to settle. Without this the call was
639
+ // a licence to re-impose `held` UNBOUNDED at any time — which is exactly how
640
+ // a reader who had already taken control got thrown back: they released the
641
+ // return, and the next background settle simply used the live anchor instead.
642
+ if (!returning && !wasFrozen) return false;
643
+ if (returning) {
644
+ if (returnBudget <= 0) { release(); return false; }
645
+ returnBudget--;
646
+ }
609
647
  // A reader who left pinned has no row to hold; the bottom is the place. Stay
610
648
  // armed so the NEXT settle re-pins after the deferred batch merges, which is
611
649
  // the bottom they actually mean.
@@ -619,7 +657,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
619
657
  // place reachable again. Believing the clamped write was a success is what
620
658
  // made the error permanent.
621
659
  parked = (!restorePinned || !restoreExact) ? target : null;
622
- returning = !!parked;
660
+ returning = !!parked && returnBudget > 0;
661
+ if (!returning) { parked = null; parkedStuck = false; returnBudget = 0; }
623
662
  return false;
624
663
  }
625
664
 
@@ -661,9 +700,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
661
700
 
662
701
  function forget(): void {
663
702
  held = null;
664
- parked = null;
665
- parkedStuck = false;
666
- returning = false;
703
+ release();
667
704
  }
668
705
 
669
706
  return {
@@ -675,6 +712,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
675
712
  park: park,
676
713
  settleReturn: settleReturn,
677
714
  isReturning: isReturning,
715
+ release: release,
678
716
  pinBottom: pinBottom,
679
717
  isFrozen: frozen,
680
718
  thaw: thaw,