bunnyquery 1.8.14 → 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/dist/engine.d.mts CHANGED
@@ -1618,6 +1618,20 @@ interface ChatHost {
1618
1618
  * that cannot scroll has no way to reach page 2 (see viewport_fill). Only
1619
1619
  * the view can measure that, which is why the engine merely announces it. */
1620
1620
  onHistoryLoaded?(fetchMore: boolean, token: number): void;
1621
+ /**
1622
+ * A list refresh just changed heights: put the reader back where they were.
1623
+ *
1624
+ * Called at BOTH moments a first-page refresh moves things — the surface page
1625
+ * landing, and the deferred background-indexing batch merging on top of it a
1626
+ * round trip later — because leaving a wrong position on screen between the two
1627
+ * is what reads as "the scroll jumped, then travelled somewhere else".
1628
+ *
1629
+ * The view owns the decision (it is the only side that can measure): pinned to
1630
+ * the bottom means the bottom AFTER the batch merged, anywhere else means the
1631
+ * exact line the reader was on. Falls back to scrollToBottomIfSticky when a host
1632
+ * does not implement it, which is the old behaviour.
1633
+ */
1634
+ settleScroll?(): void;
1621
1635
  cancelRequest(opts: {
1622
1636
  url: string;
1623
1637
  method: string;
@@ -1874,6 +1888,23 @@ interface RowAnchor {
1874
1888
  * key. Never trusted without re-checking that it is still in the box.
1875
1889
  */
1876
1890
  el: AnchorRowEl | null;
1891
+ /**
1892
+ * The next few anchorable rows below the primary, each with its own offset.
1893
+ *
1894
+ * The primary row does not always survive: a refresh can drop it, a collapsed
1895
+ * indexing row can be re-identified, an expanded group can fold. Without a
1896
+ * fallback the only thing left is lost(), which guesses from the list's total
1897
+ * growth — and total growth includes everything added BELOW the reader, so a
1898
+ * merge that lands rows on both sides of them over-pays. A second row that is
1899
+ * still there beats any guess, and collecting them costs nothing: capture is
1900
+ * already walking these rows.
1901
+ */
1902
+ alts?: Array<{
1903
+ key: string;
1904
+ top: number;
1905
+ pos: string | null;
1906
+ el: AnchorRowEl;
1907
+ }>;
1877
1908
  }
1878
1909
  interface ScrollAnchorOptions {
1879
1910
  /** The scrolling message box, or null when it is not mounted. */
@@ -1883,6 +1914,21 @@ interface ScrollAnchorOptions {
1883
1914
  * scrollToBottom* paths own the position, so every method here no-ops.
1884
1915
  */
1885
1916
  isStuck: () => boolean;
1917
+ /**
1918
+ * The reader cannot see this box right now (the tab is hidden), so FREEZE:
1919
+ * remember where they were and refuse to move them.
1920
+ *
1921
+ * A hidden tab still runs everything that mutates the list — a resumed poll, a
1922
+ * head refresh and its deferred background batch, a settling request — and each
1923
+ * of those would otherwise write scrollTop against a layout nobody is looking at
1924
+ * and re-stamp the remembered position on the way through. The reader then comes
1925
+ * back to wherever the last of those writes happened to land, which is the
1926
+ * "somehow placed in the middle" they see, and only the NEXT correction puts
1927
+ * them right. So while frozen, reads still happen but nothing writes and nothing
1928
+ * re-stamps: the anchor holds the last position the reader actually had, and one
1929
+ * hold() on return puts them back on it.
1930
+ */
1931
+ isFrozen?: () => boolean;
1886
1932
  /**
1887
1933
  * Fall back to the raw scrollTop when the anchored row cannot be found again.
1888
1934
  *
@@ -1905,6 +1951,33 @@ interface ScrollAnchor {
1905
1951
  remember: () => void;
1906
1952
  /** Put the remembered place back, if it is still the reader's own. */
1907
1953
  hold: () => void;
1954
+ /** The reader is going away: park the exact place they are leaving. */
1955
+ park: () => void;
1956
+ /**
1957
+ * They are back. Puts them on the parked place, and STAYS ARMED until it has
1958
+ * actually landed. Returns true when the host must pin to the bottom instead
1959
+ * (the reader left pinned), which only the host can do meaningfully.
1960
+ */
1961
+ settleReturn: () => boolean;
1962
+ /** A return is armed: its position, not the host's, decides scrollTop. */
1963
+ isReturning: () => boolean;
1964
+ /**
1965
+ * The reader is driving. Retire any armed return, at once.
1966
+ *
1967
+ * Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
1968
+ * which fire synchronously on a real user action and never for a programmatic
1969
+ * scroll. Waiting for the scroll EVENT is not good enough: a background refresh
1970
+ * can settle in between, and a settle with a return still armed puts the reader
1971
+ * back where they were before they left, which lands as "I touched the scroll
1972
+ * and it threw me somewhere else".
1973
+ */
1974
+ release: () => void;
1975
+ /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
1976
+ pinBottom: () => void;
1977
+ /** The box is not being painted (hidden tab). Shared so hosts agree. */
1978
+ isFrozen: () => boolean;
1979
+ /** Deprecated alias of settleReturn, kept so a stale dist does not break. */
1980
+ thaw: () => void;
1908
1981
  /** Absorb one element's own resize. See below. */
1909
1982
  absorb: (el: AnchorGrowableEl | null | undefined) => void;
1910
1983
  /** Drop the remembered place (chat switch, unmount). */
package/dist/engine.d.ts CHANGED
@@ -1618,6 +1618,20 @@ interface ChatHost {
1618
1618
  * that cannot scroll has no way to reach page 2 (see viewport_fill). Only
1619
1619
  * the view can measure that, which is why the engine merely announces it. */
1620
1620
  onHistoryLoaded?(fetchMore: boolean, token: number): void;
1621
+ /**
1622
+ * A list refresh just changed heights: put the reader back where they were.
1623
+ *
1624
+ * Called at BOTH moments a first-page refresh moves things — the surface page
1625
+ * landing, and the deferred background-indexing batch merging on top of it a
1626
+ * round trip later — because leaving a wrong position on screen between the two
1627
+ * is what reads as "the scroll jumped, then travelled somewhere else".
1628
+ *
1629
+ * The view owns the decision (it is the only side that can measure): pinned to
1630
+ * the bottom means the bottom AFTER the batch merged, anywhere else means the
1631
+ * exact line the reader was on. Falls back to scrollToBottomIfSticky when a host
1632
+ * does not implement it, which is the old behaviour.
1633
+ */
1634
+ settleScroll?(): void;
1621
1635
  cancelRequest(opts: {
1622
1636
  url: string;
1623
1637
  method: string;
@@ -1874,6 +1888,23 @@ interface RowAnchor {
1874
1888
  * key. Never trusted without re-checking that it is still in the box.
1875
1889
  */
1876
1890
  el: AnchorRowEl | null;
1891
+ /**
1892
+ * The next few anchorable rows below the primary, each with its own offset.
1893
+ *
1894
+ * The primary row does not always survive: a refresh can drop it, a collapsed
1895
+ * indexing row can be re-identified, an expanded group can fold. Without a
1896
+ * fallback the only thing left is lost(), which guesses from the list's total
1897
+ * growth — and total growth includes everything added BELOW the reader, so a
1898
+ * merge that lands rows on both sides of them over-pays. A second row that is
1899
+ * still there beats any guess, and collecting them costs nothing: capture is
1900
+ * already walking these rows.
1901
+ */
1902
+ alts?: Array<{
1903
+ key: string;
1904
+ top: number;
1905
+ pos: string | null;
1906
+ el: AnchorRowEl;
1907
+ }>;
1877
1908
  }
1878
1909
  interface ScrollAnchorOptions {
1879
1910
  /** The scrolling message box, or null when it is not mounted. */
@@ -1883,6 +1914,21 @@ interface ScrollAnchorOptions {
1883
1914
  * scrollToBottom* paths own the position, so every method here no-ops.
1884
1915
  */
1885
1916
  isStuck: () => boolean;
1917
+ /**
1918
+ * The reader cannot see this box right now (the tab is hidden), so FREEZE:
1919
+ * remember where they were and refuse to move them.
1920
+ *
1921
+ * A hidden tab still runs everything that mutates the list — a resumed poll, a
1922
+ * head refresh and its deferred background batch, a settling request — and each
1923
+ * of those would otherwise write scrollTop against a layout nobody is looking at
1924
+ * and re-stamp the remembered position on the way through. The reader then comes
1925
+ * back to wherever the last of those writes happened to land, which is the
1926
+ * "somehow placed in the middle" they see, and only the NEXT correction puts
1927
+ * them right. So while frozen, reads still happen but nothing writes and nothing
1928
+ * re-stamps: the anchor holds the last position the reader actually had, and one
1929
+ * hold() on return puts them back on it.
1930
+ */
1931
+ isFrozen?: () => boolean;
1886
1932
  /**
1887
1933
  * Fall back to the raw scrollTop when the anchored row cannot be found again.
1888
1934
  *
@@ -1905,6 +1951,33 @@ interface ScrollAnchor {
1905
1951
  remember: () => void;
1906
1952
  /** Put the remembered place back, if it is still the reader's own. */
1907
1953
  hold: () => void;
1954
+ /** The reader is going away: park the exact place they are leaving. */
1955
+ park: () => void;
1956
+ /**
1957
+ * They are back. Puts them on the parked place, and STAYS ARMED until it has
1958
+ * actually landed. Returns true when the host must pin to the bottom instead
1959
+ * (the reader left pinned), which only the host can do meaningfully.
1960
+ */
1961
+ settleReturn: () => boolean;
1962
+ /** A return is armed: its position, not the host's, decides scrollTop. */
1963
+ isReturning: () => boolean;
1964
+ /**
1965
+ * The reader is driving. Retire any armed return, at once.
1966
+ *
1967
+ * Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
1968
+ * which fire synchronously on a real user action and never for a programmatic
1969
+ * scroll. Waiting for the scroll EVENT is not good enough: a background refresh
1970
+ * can settle in between, and a settle with a return still armed puts the reader
1971
+ * back where they were before they left, which lands as "I touched the scroll
1972
+ * and it threw me somewhere else".
1973
+ */
1974
+ release: () => void;
1975
+ /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
1976
+ pinBottom: () => void;
1977
+ /** The box is not being painted (hidden tab). Shared so hosts agree. */
1978
+ isFrozen: () => boolean;
1979
+ /** Deprecated alias of settleReturn, kept so a stale dist does not break. */
1980
+ thaw: () => void;
1908
1981
  /** Absorb one element's own resize. See below. */
1909
1982
  absorb: (el: AnchorGrowableEl | null | undefined) => void;
1910
1983
  /** Drop the remembered place (chat switch, unmount). */
package/dist/engine.mjs CHANGED
@@ -2578,6 +2578,9 @@ function shouldRescueInFlightMessage(m, ctx) {
2578
2578
  // src/engine/scroll_anchor.ts
2579
2579
  var ROW_KEY_ATTR = "data-row-key";
2580
2580
  var ROW_POS_ATTR = "data-row-pos";
2581
+ var MAX_ALTS = 2;
2582
+ var ALT_SCAN_LIMIT = 64;
2583
+ var MAX_RETURN_SETTLES = 3;
2581
2584
  var UNKNOWN_ROW_POS = "\0?";
2582
2585
  function createScrollAnchor(options) {
2583
2586
  var held = null;
@@ -2588,6 +2591,7 @@ function createScrollAnchor(options) {
2588
2591
  var boxTop = box.getBoundingClientRect().top;
2589
2592
  var kids = box.children;
2590
2593
  var fallback = null;
2594
+ var fallbackAt = -1;
2591
2595
  for (var i = 0; i < kids.length; i++) {
2592
2596
  var el = kids[i];
2593
2597
  if (!el || typeof el.getAttribute !== "function") continue;
@@ -2606,10 +2610,20 @@ function createScrollAnchor(options) {
2606
2610
  scrollHeight: box.scrollHeight,
2607
2611
  el
2608
2612
  };
2609
- if (rawPos === null) return cand;
2610
- if (!fallback) fallback = cand;
2613
+ if (rawPos === null) {
2614
+ cand.alts = collectAlts(box, boxTop, i + 1);
2615
+ return cand;
2616
+ }
2617
+ if (!fallback) {
2618
+ fallback = cand;
2619
+ fallbackAt = i;
2620
+ }
2621
+ }
2622
+ if (fallback) {
2623
+ fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
2624
+ return fallback;
2611
2625
  }
2612
- return fallback || {
2626
+ return {
2613
2627
  key: null,
2614
2628
  top: 0,
2615
2629
  pos: null,
@@ -2618,6 +2632,26 @@ function createScrollAnchor(options) {
2618
2632
  el: null
2619
2633
  };
2620
2634
  }
2635
+ function collectAlts(box, boxTop, from, ordinaryOnly) {
2636
+ var out = [];
2637
+ var kids = box.children;
2638
+ var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
2639
+ for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
2640
+ var el = kids[i];
2641
+ if (!el || typeof el.getAttribute !== "function") continue;
2642
+ var key = el.getAttribute(ROW_KEY_ATTR);
2643
+ if (!key) continue;
2644
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
2645
+ if (ordinaryOnly && rawPos !== null) continue;
2646
+ out.push({
2647
+ key,
2648
+ top: el.getBoundingClientRect().top - boxTop,
2649
+ pos: rawPos === null ? null : rawPos || UNKNOWN_ROW_POS,
2650
+ el
2651
+ });
2652
+ }
2653
+ return out.length ? out : void 0;
2654
+ }
2621
2655
  function findRow(box, anchor) {
2622
2656
  var el = anchor.el;
2623
2657
  if (el && el.parentNode === box) return el;
@@ -2630,31 +2664,80 @@ function createScrollAnchor(options) {
2630
2664
  }
2631
2665
  return null;
2632
2666
  }
2633
- function restore(anchor) {
2667
+ var sawFrozen = false;
2668
+ var parked = null;
2669
+ var parkedStuck = false;
2670
+ var returning = false;
2671
+ var returnBudget = 0;
2672
+ var wroteTop = -1;
2673
+ var restoreExact = true;
2674
+ var restorePinned = false;
2675
+ function frozen() {
2676
+ var f = !!options.isFrozen && options.isFrozen();
2677
+ if (f) sawFrozen = true;
2678
+ return f;
2679
+ }
2680
+ function restore(anchor, unbounded) {
2681
+ if (frozen()) return;
2634
2682
  var box = options.getBox();
2635
2683
  if (!box || !anchor || options.isStuck()) return;
2636
2684
  var el = findRow(box, anchor);
2637
2685
  if (el) {
2638
2686
  var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
2639
- if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS && livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) {
2640
- lost(box, anchor);
2641
- return;
2642
- }
2687
+ if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS && livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
2688
+ }
2689
+ if (el) {
2643
2690
  var boxTop = box.getBoundingClientRect().top;
2644
2691
  var delta = el.getBoundingClientRect().top - boxTop - anchor.top;
2645
2692
  var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
2646
- if (delta > slack || delta < -slack) {
2693
+ if (!unbounded && (delta > slack || delta < -slack)) {
2647
2694
  lost(box, anchor);
2648
2695
  return;
2649
2696
  }
2697
+ var want = box.scrollTop + delta;
2650
2698
  if (delta >= 1 || delta <= -1) box.scrollTop += delta;
2699
+ wroteTop = box.scrollTop;
2700
+ restorePinned = true;
2701
+ restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
2651
2702
  held = {
2652
2703
  key: anchor.key,
2653
2704
  top: anchor.top,
2654
2705
  pos: anchor.pos,
2655
2706
  scrollTop: box.scrollTop,
2656
2707
  scrollHeight: box.scrollHeight,
2657
- el
2708
+ el,
2709
+ // Carried, not dropped: one successful restore used to disarm the
2710
+ // standbys for every later hold.
2711
+ alts: anchor.alts
2712
+ };
2713
+ return;
2714
+ }
2715
+ var alts = anchor.alts;
2716
+ for (var ai = 0; alts && ai < alts.length; ai++) {
2717
+ var alt = alts[ai];
2718
+ var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
2719
+ if (!ael) continue;
2720
+ if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
2721
+ var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
2722
+ if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
2723
+ }
2724
+ var aboxTop = box.getBoundingClientRect().top;
2725
+ var adelta = ael.getBoundingClientRect().top - aboxTop - alt.top;
2726
+ var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
2727
+ if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
2728
+ var awant = box.scrollTop + adelta;
2729
+ if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
2730
+ wroteTop = box.scrollTop;
2731
+ restorePinned = true;
2732
+ restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
2733
+ held = {
2734
+ key: alt.key,
2735
+ top: alt.top,
2736
+ pos: alt.pos,
2737
+ scrollTop: box.scrollTop,
2738
+ scrollHeight: box.scrollHeight,
2739
+ el: ael,
2740
+ alts: alts.slice(ai + 1)
2658
2741
  };
2659
2742
  return;
2660
2743
  }
@@ -2665,9 +2748,13 @@ function createScrollAnchor(options) {
2665
2748
  var grew = box.scrollHeight - anchor.scrollHeight;
2666
2749
  if (grew > 0) {
2667
2750
  box.scrollTop = anchor.scrollTop + grew;
2751
+ wroteTop = box.scrollTop;
2668
2752
  return;
2669
2753
  }
2670
- if (options.rawFallback) box.scrollTop = anchor.scrollTop;
2754
+ if (options.rawFallback) {
2755
+ box.scrollTop = anchor.scrollTop;
2756
+ wroteTop = box.scrollTop;
2757
+ }
2671
2758
  }
2672
2759
  function preserve(mutate) {
2673
2760
  var anchor = capture();
@@ -2676,9 +2763,17 @@ function createScrollAnchor(options) {
2676
2763
  return result;
2677
2764
  }
2678
2765
  function remember() {
2766
+ var b0 = options.getBox();
2767
+ if (returning && b0 && b0.scrollTop !== wroteTop) release();
2768
+ if (frozen()) return;
2679
2769
  held = capture();
2680
2770
  }
2681
2771
  function hold() {
2772
+ if (frozen()) return;
2773
+ if (sawFrozen) {
2774
+ settleReturn();
2775
+ return;
2776
+ }
2682
2777
  var box = options.getBox();
2683
2778
  if (!box || options.isStuck()) {
2684
2779
  held = null;
@@ -2705,12 +2800,86 @@ function createScrollAnchor(options) {
2705
2800
  if (prev === void 0) prev = 0;
2706
2801
  var delta = h - prev;
2707
2802
  if (delta === 0) return;
2803
+ if (frozen()) {
2804
+ foldFrozenGrowth(box, el, delta);
2805
+ return;
2806
+ }
2708
2807
  if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
2709
2808
  box.scrollTop += delta;
2809
+ wroteTop = box.scrollTop;
2710
2810
  if (held) held = capture();
2711
2811
  }
2812
+ function park() {
2813
+ parked = capture();
2814
+ parkedStuck = !!options.isStuck();
2815
+ returning = true;
2816
+ returnBudget = MAX_RETURN_SETTLES;
2817
+ }
2818
+ function release() {
2819
+ parked = null;
2820
+ parkedStuck = false;
2821
+ returning = false;
2822
+ returnBudget = 0;
2823
+ }
2824
+ function pinBottom() {
2825
+ var box = options.getBox();
2826
+ if (!box) return;
2827
+ box.scrollTop = box.scrollHeight;
2828
+ wroteTop = box.scrollTop;
2829
+ }
2830
+ function settleReturn() {
2831
+ if (frozen()) return false;
2832
+ var wasFrozen = sawFrozen;
2833
+ sawFrozen = false;
2834
+ if (!returning && !wasFrozen) return false;
2835
+ if (returning) {
2836
+ if (returnBudget <= 0) {
2837
+ release();
2838
+ return false;
2839
+ }
2840
+ returnBudget--;
2841
+ }
2842
+ if (parkedStuck) {
2843
+ pinBottom();
2844
+ return true;
2845
+ }
2846
+ var target = parked || held;
2847
+ restoreExact = true;
2848
+ restorePinned = false;
2849
+ restore(target, true);
2850
+ parked = !restorePinned || !restoreExact ? target : null;
2851
+ returning = !!parked && returnBudget > 0;
2852
+ if (!returning) {
2853
+ parked = null;
2854
+ parkedStuck = false;
2855
+ returnBudget = 0;
2856
+ }
2857
+ return false;
2858
+ }
2859
+ function isReturning() {
2860
+ return returning;
2861
+ }
2862
+ function thaw() {
2863
+ settleReturn();
2864
+ }
2865
+ function foldFrozenGrowth(box, el, delta) {
2866
+ var elTop = el.getBoundingClientRect().top;
2867
+ foldInto(box, held, elTop, delta);
2868
+ foldInto(box, parked, elTop, delta);
2869
+ }
2870
+ function foldInto(box, a, elTop, delta) {
2871
+ if (!a || a.top >= 0) return;
2872
+ var rowEl = findRow(box, a);
2873
+ if (!rowEl) return;
2874
+ var within = elTop - rowEl.getBoundingClientRect().top;
2875
+ if (within < 0 || within >= rowEl.offsetHeight) return;
2876
+ if (within >= -a.top) return;
2877
+ a.top -= delta;
2878
+ a.scrollHeight += delta;
2879
+ }
2712
2880
  function forget() {
2713
2881
  held = null;
2882
+ release();
2714
2883
  }
2715
2884
  return {
2716
2885
  capture,
@@ -2718,6 +2887,13 @@ function createScrollAnchor(options) {
2718
2887
  preserve,
2719
2888
  remember,
2720
2889
  hold,
2890
+ park,
2891
+ settleReturn,
2892
+ isReturning,
2893
+ release,
2894
+ pinBottom,
2895
+ isFrozen: frozen,
2896
+ thaw,
2721
2897
  absorb,
2722
2898
  forget
2723
2899
  };
@@ -5820,6 +5996,7 @@ var ChatSession = class {
5820
5996
  releaseBgFlag();
5821
5997
  self.updateHistoryCache();
5822
5998
  self.host.notify();
5999
+ if (self.host.settleScroll) self.host.settleScroll();
5823
6000
  }, function() {
5824
6001
  releaseBgFlag();
5825
6002
  self.host.notify();
@@ -5901,7 +6078,7 @@ var ChatSession = class {
5901
6078
  self.drainBgTaskQueue();
5902
6079
  }
5903
6080
  if (!fetchMore) self.refreshLiveIndexState();
5904
- if (!fetchMore) return self.host.scrollToBottomIfSticky();
6081
+ if (!fetchMore) return self.host.settleScroll ? self.host.settleScroll() : self.host.scrollToBottomIfSticky();
5905
6082
  }).catch(function(err) {
5906
6083
  console.warn("[chat-engine] getChatHistory failed", err);
5907
6084
  }).then(function() {