@aiquants/virtualscroll 2.5.0 → 2.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/virtualscroll",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -234,7 +234,16 @@ const toggleListeners = (target: HTMLElement | Window, entries: ListenerEntry[],
234
234
  }
235
235
 
236
236
  export type ScrollPaneHandle = {
237
- scrollTo: (newPosition: number | ((prev: number) => number)) => number
237
+ /**
238
+ * Jumps to a position, clamped against the last-committed dims (or an explicit override).
239
+ * 位置へジャンプする。クランプは**最後に commit された寸法** (または明示指定) に対して行う。
240
+ *
241
+ * `dimsOverride` は「commit より新しい寸法を知っている呼び出し元」専用の口である
242
+ * (例: Fenwick 木を同期更新した直後のレイアウトシフト補正は、まだ commit されていない
243
+ * 木の合計高さでクランプしたい)。通常の呼び出しでは指定しないこと — 指定を誤ると
244
+ * 画面に出ていない寸法でクランプされ、A-1 の汚染を自分の手で再現することになる。
245
+ */
246
+ scrollTo: (newPosition: number | ((prev: number) => number), dimsOverride?: { contentSize: number; viewportSize: number }) => number
238
247
  getScrollPosition: () => number
239
248
  getContentSize: () => number
240
249
  getViewportSize: () => number
@@ -477,12 +486,20 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
477
486
  // contentSize と viewportSize を ref として保持
478
487
  // const sizeRef = useRef({ contentSize, viewportSize })
479
488
 
480
- // ❗ render 本体で代入する。layout effect へ移すと、サイズ変更と同じコミット内で走る
481
- // 他の layout effect からの scrollTo が 1 コミット古い寸法でクランプされ、タップスクロールの
482
- // 走破が短くなる (実機 E2E の等速性テストがサンプル数不足で落ちる形で退行が出る)。
483
- // 既知の代償は下の wheelPolicyRef のコメントを参照。
489
+ // ❗ **render 本体では代入しない (layout effect のみ = 常に「最後に commit された寸法」)。**
490
+ // render 中に書くと、中断されたトランジションの**コミットされなかった寸法**が残り、
491
+ // 保留中のホイール/ジャンプが画面に出ていない寸法でクランプされる (実測: 保留中の縮小で
492
+ // 一覧が先頭へ飛ぶ / ホイールが死ぬ。transitionClamp.spec.tsx が固定)。
493
+ //
494
+ // かつて「layout effect 化はタップスクロール E2E を退行させる」と記録されていたが、
495
+ // 8 回 x 3 系列の A/B/A 対照実験で**反証済み** (失敗は書き込み位置と無関係の
496
+ // コールドスタート負荷フレークで、両変種とも 6-7/8 で同率に失敗した)。加えて全計 1,893 回の
497
+ // scrollTo クランプ監査で committed 値と render 新値の食い違いは 0 件 — 同 commit 内で
498
+ // 新値を要する呼び出し元は存在しない (子 effect は本 effect より先に走るが、その窓に
499
+ // scrollTo 呼び出し元は無いことを確認済み)。commit より新しい寸法が要る唯一の呼び出し元
500
+ // (レイアウトシフト補正) は「committed + 今適用した delta」を `dimsOverride` で明示的に
501
+ // 渡す (木合計は渡さない — 木は破棄された WIP render にも変異される共有物)。
484
502
  const sizeRef = useRef({ contentSize, viewportSize })
485
- sizeRef.current = { contentSize, viewportSize }
486
503
 
487
504
  const isScrollable = useMemo(() => contentSize > viewportSize, [contentSize, viewportSize])
488
505
 
@@ -520,8 +537,18 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
520
537
  // )
521
538
 
522
539
  const scrollTo = useCallback(
523
- (newPosition: number | ((prev: number) => number)) => {
524
- const { contentSize: currentContentSize, viewportSize: currentViewportSize } = sizeRef.current
540
+ (newPosition: number | ((prev: number) => number), dimsOverride?: { contentSize: number; viewportSize: number }) => {
541
+ // 既定は「最後に commit された寸法」。dimsOverride commit より新しい寸法を
542
+ // 知っている呼び出し元 (レイアウトシフト補正) だけが渡す。
543
+ // ❗ 非有限の override は警告して無視する (position の NaN ガードと同じ姿勢)。
544
+ // NaN の contentSize は「スクロール不能」判定へ落ち、位置を 0 へ強制 +
545
+ // onScroll(0) まで発火してしまう
546
+ let effectiveDims = dimsOverride ?? sizeRef.current
547
+ if (dimsOverride && !(Number.isFinite(dimsOverride.contentSize) && Number.isFinite(dimsOverride.viewportSize))) {
548
+ Logger.warn("[ScrollPane] scrollTo received non-finite dimsOverride; falling back to the committed dims", { dimsOverride })
549
+ effectiveDims = sizeRef.current
550
+ }
551
+ const { contentSize: currentContentSize, viewportSize: currentViewportSize } = effectiveDims
525
552
  const currentIsScrollable = currentContentSize > currentViewportSize
526
553
  const prevPosition = scrollPositionRef.current
527
554
 
@@ -743,18 +770,14 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
743
770
 
744
771
  // ホイール適用の方針値。保持されたハンドル越しの呼び出しでも凍結させないため ref で持つ。
745
772
  //
746
- // ❗ **代入のタイミングは `sizeRef` と必ず揃える。** 片方だけを layout effect にすると、
747
- // 中断されたトランジション中に `applyWheel` は「スクロール可能」と判断して消費する一方、
748
- // `scrollTo` は 1 コミット古い寸法でクランプして 0 を返す、という最悪の組み合わせになる
749
- // (実測: 5,000 行の一覧が 1 ノッチで先頭へ飛び、以後 0 に貼り付く)。
750
- //
751
- // ❗ **既知の限界 (未修正)**: render 本体での ref 書き込みは、中断されたトランジションで
752
- // **コミットされなかった値**を残す。その間のホイールは画面に出ていない寸法で判断されるため、
753
- // 「スクロール可能な一覧なのにホイールが効かない」状態が保留中だけ起きる。両 ref を
754
- // layout effect へ移すのが筋だが、それだけではタップスクロールの走破が 1 コミット遅れて
755
- // 短くなる退行が出る (実機 E2E で実証済み)。恒久対応にはクランプ経路の見直しが必要。
773
+ // ❗ **代入のタイミングは `sizeRef` と必ず揃える (どちらも layout effect のみ)。**
774
+ // 片方だけ render 本体に残すと、中断されたトランジション中に `applyWheel` と `scrollTo` が
775
+ // 別々の世界の寸法で判断する最悪の組み合わせになる。
756
776
  const wheelPolicyRef = useRef({ isScrollable, viewportSize, wheelSpeedMultiplier })
757
- wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
777
+ useLayoutEffect(() => {
778
+ // sizeRef と同じ理由で「最後に commit された値」だけを保持する (A-1。sizeRef のコメント参照)
779
+ wheelPolicyRef.current = { isScrollable, viewportSize, wheelSpeedMultiplier }
780
+ }, [isScrollable, viewportSize, wheelSpeedMultiplier])
758
781
 
759
782
  /**
760
783
  * Applies one wheel event with the pane's own rules and reports whether it was consumed.
@@ -905,9 +928,9 @@ export const ScrollPane = forwardRef<ScrollPaneHandle, ScrollPaneProps>(
905
928
  useImperativeHandle(
906
929
  ref,
907
930
  () => ({
908
- scrollTo: (pos) => {
931
+ scrollTo: (pos, dimsOverride) => {
909
932
  stopInertia()
910
- return scrollTo(pos)
933
+ return scrollTo(pos, dimsOverride)
911
934
  },
912
935
  getScrollPosition: () => scrollPositionRef.current,
913
936
  getContentSize: () => contentSize,
@@ -1305,7 +1305,7 @@ const VirtualScrollInner = <T,>(
1305
1305
  * 乖離した場合 (同一同期バッチ内の旧 contentSize によるクランプ) は、新 contentSize
1306
1306
  * コミット後の補正再発行を予約。
1307
1307
  */
1308
- const issueCompensationScroll = useCallback((targetPosition: number): number => {
1308
+ const issueCompensationScroll = useCallback((targetPosition: number, appliedDelta = 0): number => {
1309
1309
  const pane = scrollPaneRef.current
1310
1310
  if (!pane) {
1311
1311
  return targetPosition
@@ -1313,7 +1313,20 @@ const VirtualScrollInner = <T,>(
1313
1313
  // クランプで位置が変化しない場合 onScroll は発火しない。事前位置と比較して増分を戻すために保持する
1314
1314
  const beforePosition = pane.getScrollPosition()
1315
1315
  isCompensatingRef.current += 1
1316
- const appliedPosition = pane.scrollTo(targetPosition)
1316
+ // クランプは **「committed 寸法 + 今適用した delta」**で行う (dimsOverride)。この補正は木を
1317
+ // 同期更新した**直後・commit 前**に走るため、ペインの committed sizeRef は必ず 1 commit 古く、
1318
+ // 既定のままだと旧最大値でクランプされ二段補正 (§8.6) の再発行に頼ることになる。
1319
+ //
1320
+ // ❗ `fenwickTree.getTotal()` ではなく committed + delta を使う。木は**破棄された WIP render
1321
+ // にも変異される共有物** (保留トランジション中は未 commit の行数の合計を返すことを
1322
+ // transitionClamp.spec.tsx の前提アサートで実証済み) であり、木合計でのクランプは
1323
+ // fenwick の並行安全設計の内部タイミングに依存する。committed + delta は定義から
1324
+ // 「この補正が保とうとしている committed 空間 + 今適用した変化」そのものであり、
1325
+ // WIP 変異のタイミングと無関係に正しい (通常時は木合計と同値で、一段目がそのまま
1326
+ // 目標へ届き、二段補正はバックストップとして休眠する)
1327
+ const freshContentSize = pane.getContentSize() + appliedDelta
1328
+ const freshViewportSize = pane.getViewportSize()
1329
+ const appliedPosition = pane.scrollTo(targetPosition, { contentSize: freshContentSize, viewportSize: freshViewportSize })
1317
1330
  if (appliedPosition === beforePosition) {
1318
1331
  // onScroll 不発: 補正カウンタの増分を巻き戻し、次の手動スクロールが補正扱いされるのを防ぐ
1319
1332
  isCompensatingRef.current = Math.max(0, isCompensatingRef.current - 1)
@@ -1507,7 +1520,7 @@ const VirtualScrollInner = <T,>(
1507
1520
  // 同一同期バッチ内では ScrollPane の sizeRef が旧 contentSize のままのため、scrollTo は
1508
1521
  // 旧最大値でクランプされ得る。issueCompensationScroll がカウンタのリークを防ぎつつ乖離時の
1509
1522
  // 再発行を予約し、論理位置 (latestScrollPositionRef) には補正目標を保持して二段目の収束先とする。
1510
- issueCompensationScroll(newPosition)
1523
+ issueCompensationScroll(newPosition, delta)
1511
1524
  updateScrollPositionImmediate(newPosition, { immediate: true })
1512
1525
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
1513
1526
  }
@@ -1746,7 +1759,18 @@ const VirtualScrollInner = <T,>(
1746
1759
 
1747
1760
  // If manual scroll (neither programmatic nor compensating), clear the pending anchor
1748
1761
  // This ensures that manual scrolling immediately detaches from any previous scroll target
1749
- if (!(isProgrammatic || isCompensating || isResizingRef.current)) {
1762
+ // ❗ isResizingRef は render 本体で立つ (in-commit §9 クランプ連鎖が読むため必須) が、
1763
+ // 中断されたトランジションの render でも立ってしまい、commit が無いので降りない。
1764
+ // このクロージャの itemCount は **commit を通ってしか届かない** (破棄 render のクロージャは
1765
+ // committed ツリーに残らない) ため、ref と閉じ込め値の照合で「committed な resize か」を
1766
+ // 判別できる。保留中 (ref=新 / クロージャ=旧) は不一致 → 手動スクロール扱いでアンカーが
1767
+ // 正しく外れる。resize の commit 内連鎖 (ref=新 / クロージャ=新) は一致 → 従来どおり。
1768
+ // ❗ 純粋な同数往復 (500→300 保留→500 bailout) は照合が正しく効く (bailout は ref の
1769
+ // 再前進も防ぐため不一致になる)。残存する盲点は「戻りに別 state 変更が同伴して一覧が
1770
+ // 再 render される」形のみ (ref が committed と同値へ前進し、同伴 commit ではリセット
1771
+ // 経路が走らない)。仕様書 §8.4 の既知の盲点を参照
1772
+ const isCommittedResize = isResizingRef.current && prevItemCountRef.current === itemCount
1773
+ if (!(isProgrammatic || isCompensating || isCommittedResize)) {
1750
1774
  pendingVisibleStartIndexRef.current = null
1751
1775
  }
1752
1776
 
@@ -1775,7 +1799,7 @@ const VirtualScrollInner = <T,>(
1775
1799
  }
1776
1800
  }
1777
1801
  },
1778
- [updateScrollPositionImmediate, enableScrollToTopBottomButtons],
1802
+ [updateScrollPositionImmediate, enableScrollToTopBottomButtons, itemCount],
1779
1803
  )
1780
1804
 
1781
1805
  // レンダリング範囲を計算
@@ -2189,7 +2213,7 @@ const VirtualScrollInner = <T,>(
2189
2213
  const newPosition = panePosition + shiftAmount
2190
2214
  // updateItemSize と同型: scrollTo は旧 contentSize でクランプされ得るため、
2191
2215
  // issueCompensationScroll でリークを防ぎつつ、乖離時は contentSize 同期 effect で再発行する。
2192
- issueCompensationScroll(newPosition)
2216
+ issueCompensationScroll(newPosition, shiftAmount)
2193
2217
  updateScrollPositionImmediate(newPosition, { immediate: true })
2194
2218
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (auto update)", { from: panePosition, to: newPosition, shiftAmount })
2195
2219
  } else if (panePosition !== latestScrollPositionRef.current) {