@aiquants/virtualscroll 1.24.0 → 2.0.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": "1.24.0",
3
+ "version": "2.0.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -25,11 +25,35 @@ export type VirtualScrollRange = {
25
25
  }
26
26
 
27
27
  /**
28
- * Handle interface for controlling the VirtualScroll component externally.
28
+ * Imperative handle of VirtualScroll. Every position it accepts or returns is in the
29
+ * LOGICAL coordinate space (content px, insets excluded) — the same space as onScroll /
30
+ * onRangeChange / initialScrollOffset / getScrollAnchor.
31
+ * VirtualScroll の命令ハンドル。受け取る位置・返す位置は**すべて論理座標** (inset 抜きの
32
+ * コンテンツ px) で、onScroll / onRangeChange / initialScrollOffset / getScrollAnchor と同一空間。
29
33
  *
30
- * 外部から VirtualScroll コンポーネントを制御するためのハンドルインターフェース。
34
+ * ペイン座標 (inset 込み) は内部実装詳細であり、このハンドルからは一切露出しない。
35
+ * 2.0.0 以前は getScrollPosition と scrollTo の返値だけがペイン座標という非対称があり、
36
+ * 消費者側の換算コードが 2 度の実害バグ (daily-report 初期 6px ずれ / directory-tree の
37
+ * inset 二重適用) を生んだ。座標系の混在を消費者から構造的に不能にするための統一である。
38
+ * ScrollPane 自身のハンドル (ScrollPaneHandle) はペインそのものなのでペイン座標のままであり、
39
+ * その境界はこの型が引き受ける。
31
40
  */
32
- export type VirtualScrollHandle = ScrollPaneHandle & {
41
+ export type VirtualScrollHandle = {
42
+ /**
43
+ * Scrolls to a LOGICAL position (updater form receives the current logical position).
44
+ * Returns the applied (clamped) LOGICAL position.
45
+ * 論理位置へスクロール (updater は現在の論理位置を受け取る)。適用後 (クランプ後) の論理位置を返す。
46
+ */
47
+ scrollTo: (position: number | ((prev: number) => number)) => number
48
+ /**
49
+ * Current LOGICAL scroll position. -1 when the pane is not connected.
50
+ * 現在の論理スクロール位置。ペイン未接続時は -1。
51
+ */
52
+ getScrollPosition: () => number
53
+ /** Total pane content size including insets (a size, not a position) / インセット込みのコンテンツ総高さ (位置ではなくサイズ)。未接続時は -1 */
54
+ getContentSize: () => number
55
+ /** Viewport size / ビューポートの高さ。未接続時は -1 */
56
+ getViewportSize: () => number
33
57
  /** Scrolls to a specific item index / 指定したアイテムインデックスへスクロール */
34
58
  scrollToIndex: (index: number, options?: { align?: "top" | "bottom" | "center"; offset?: number }) => void
35
59
  /** Gets the total height managed by the Fenwick Tree / Fenwick Tree で管理されている総高さを取得 */
@@ -40,6 +64,12 @@ export type VirtualScrollHandle = ScrollPaneHandle & {
40
64
  focusItemAtIndex: (index: number, options?: { ensureVisible?: boolean }) => void
41
65
  /** Gets the current scroll range information / 現在のスクロール範囲情報を取得 */
42
66
  getRange: () => VirtualScrollRange
67
+ /**
68
+ * Captures the current top-row anchor ({index, offsetPx}) for exact restore via initialScrollAnchor.
69
+ * initialScrollAnchor での厳密復元用に、現在の先頭可視行アンカー ({index, offsetPx}) を取得。
70
+ * itemCount 0 のときは null。offsetPx は先頭可視行の上端がビューポート上端より上に隠れている px (論理座標)。
71
+ */
72
+ getScrollAnchor: () => { index: number; offsetPx: number } | null
43
73
  /**
44
74
  * Manually updates the size of a specific item. Contract: after calling this,
45
75
  * `getItemHeight(index)` must return the same `size`; `getItemHeight` is the source of truth,
@@ -132,6 +162,19 @@ export type VirtualScrollProps<T> = {
132
162
  children: (item: T, index: number) => ReactNode
133
163
  initialScrollIndex?: number
134
164
  initialScrollOffset?: number
165
+ /**
166
+ * Anchor-based initial position: starts with row `index` scrolled `offsetPx` px past the viewport top.
167
+ * アンカー基準の初期位置。行 index の上端がビューポート上端より offsetPx px 分だけ上に隠れた位置で開始する。
168
+ *
169
+ * 可変高さ一覧の「位置キープ」はこれを使うこと。initialScrollOffset の生 px 復元は、実測高さが
170
+ * 具現化されていない再マウント空間では px→行変換が推定値で行われ、別の行に着地する
171
+ * (実測: 深い復元で 21 行漂着)。アンカーは行の同一性で位置決めするため空間差に不変。
172
+ * 保存側は VirtualScrollHandle.getScrollAnchor() で取得した値をそのまま渡す。
173
+ * 優先順位: initialScrollAnchor > initialScrollIndex > initialScrollOffset。
174
+ * マウント時に itemCount > 0 なら初回コミットから厳密。itemCount 0 でマウントした場合は
175
+ * 最初のデータ到着 (0 → N) 時に scrollToIndex 経由で遅延適用される (近似 → 自己修復)。
176
+ */
177
+ initialScrollAnchor?: { index: number; offsetPx?: number }
135
178
  callbackThrottleMs?: number
136
179
  contentInsets?: ScrollPaneProps["contentInsets"]
137
180
  onItemFocus?: (index: number) => void
@@ -703,6 +746,7 @@ const VirtualScrollInner = <T,>(
703
746
  background,
704
747
  initialScrollIndex,
705
748
  initialScrollOffset,
749
+ initialScrollAnchor,
706
750
  callbackThrottleMs = 5,
707
751
  contentInsets,
708
752
  onItemFocus,
@@ -717,7 +761,9 @@ const VirtualScrollInner = <T,>(
717
761
 
718
762
  const { enablePointerDrag, pointerDragInputs, enableKeyboardNavigation = true, wheelSpeedMultiplier, inertiaOptions, clipItemHeight = false, resetOnGetItemHeightChange = false } = behaviorOptions ?? {}
719
763
 
720
- const scrollPaneRef = useRef<VirtualScrollHandle>(null)
764
+ // 内部ペイン ref の型は ScrollPaneHandle (ペイン座標)。VirtualScrollHandle と誤記すると
765
+ // 2.0.0 以降は型ドキュメントが「論理座標を返す」と嘘をつく (構造的互換で型検査は通ってしまう)
766
+ const scrollPaneRef = useRef<ScrollPaneHandle>(null)
721
767
  const currentRangeRef = useRef<VirtualScrollRange>({
722
768
  renderingStartIndex: 0,
723
769
  renderingEndIndex: 0,
@@ -726,18 +772,32 @@ const VirtualScrollInner = <T,>(
726
772
  scrollPosition: 0,
727
773
  totalHeight: 0,
728
774
  })
775
+ // アンカー付きマウントは保留アンカーを最初から種まきする: マウント後の実測反映 (contentSize
776
+ // コミット) のたびにドリフト補正 effect が同じ行へ再ピン留めし、自己修復する。ユーザーの
777
+ // 手動スクロールで解除される (handleScroll 参照)。offset は scrollToIndex と同じ「減算」規約の
778
+ // ため符号を反転して持つ (offsetPx = 行上端がビューポート上端より上に隠れる px、正の値)
729
779
  const pendingVisibleStartIndexRef = useRef<{
730
780
  index: number
731
781
  align?: "top" | "bottom" | "center"
732
782
  offset?: number
733
- } | null>(null)
783
+ } | null>(initialScrollAnchor && itemCount > 0 ? { index: minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1), align: "top", offset: -Math.max(0, initialScrollAnchor.offsetPx ?? 0) } : null)
734
784
  // 「アンマウント済みか」を追跡する (初期 false)。「マウント済みか」の正ガードだと、初回レンダー中に
735
785
  // 予約された高さ照合マイクロタスクが passive effect (マウントフラグ設定) より先に実行されて破棄される。
736
786
  const isUnmountedRef = useRef(false)
737
787
 
788
+ // 初回実行スキップ用 (マウント時に走る effect 本体が、直前で種まきした初期アンカーを消さないため)
789
+ const isFirstGetItemHeightRunRef = useRef(true)
738
790
  useEffect(() => {
739
791
  // 目的: アイテムの高さ取得ロジックがリセットされた場合に、古いインデックスへのアンカーを解除する。
740
792
  // 依存関係: resetOnGetItemHeightChange, getItemHeight
793
+ // ❗ 初回 (マウント) は解除しない: このフラグの意味は「getItemHeight の**変更**時に解除」で
794
+ // あり、マウント時の実行で解除すると initialScrollAnchor が種まきした保留アンカー
795
+ // (実測反映ごとの自己修復再ピン留め) が resetOnGetItemHeightChange: true の消費者で
796
+ // 無言に無効化されてしまう
797
+ if (isFirstGetItemHeightRunRef.current) {
798
+ isFirstGetItemHeightRunRef.current = false
799
+ return
800
+ }
741
801
  if (resetOnGetItemHeightChange) {
742
802
  // When configured to reset on logic change, we drop the scroll anchor
743
803
  // to avoid sticking to an index that may no longer be relevant contextually.
@@ -756,7 +816,9 @@ const VirtualScrollInner = <T,>(
756
816
  // (sampleRangeChanged) が破壊的な tree.reset を発火し、実測済みの全行高さが破棄されてしまう。
757
817
  const [initialSampleRange] = useState(() => {
758
818
  const SAMPLE_HALF_WINDOW = 50
759
- const anchor = typeof initialScrollIndex === "number" && Number.isFinite(initialScrollIndex) ? Math.max(0, Math.trunc(initialScrollIndex)) : 0
819
+ // アンカー復元はアンカー行を、index 復元はその行を窓の中心にする (どちらも「最初に見る領域」)
820
+ const anchorSource = initialScrollAnchor?.index ?? initialScrollIndex
821
+ const anchor = typeof anchorSource === "number" && Number.isFinite(anchorSource) ? Math.max(0, Math.trunc(anchorSource)) : 0
760
822
  return { from: Math.max(0, anchor - SAMPLE_HALF_WINDOW), to: anchor + SAMPLE_HALF_WINDOW }
761
823
  })
762
824
 
@@ -777,7 +839,20 @@ const VirtualScrollInner = <T,>(
777
839
  const [initialValues] = useState(() => {
778
840
  let position = 0
779
841
  let total = 0
780
- if (typeof initialScrollIndex === "number") {
842
+ if (initialScrollAnchor && itemCount > 0) {
843
+ // アンカー復元: アンカー行の周辺を具現化し、その行の上端 + offsetPx を初期位置にする。
844
+ // 初回描画のレンジ計算も同じツリー状態から導かれるため、推定誤差の量に関係なく
845
+ // 「アンカー行がちょうど offsetPx 分だけ上に隠れた」画で最初のフレームから出る
846
+ const safeIndex = minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1)
847
+ const anchorOffsetPx = Math.max(0, initialScrollAnchor.offsetPx ?? 0)
848
+ const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
849
+ const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
850
+ const options = safeIndex > 0 || anchorOffsetPx > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
851
+ const { cumulative, total: materializedTotal, currentValue } = fenwickTree.prefixSum(safeIndex, options)
852
+ const logicalOffset = Math.max(cumulative - currentValue + anchorOffsetPx, 0)
853
+ position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
854
+ total = materializedTotal ?? fenwickTree.getTotal()
855
+ } else if (typeof initialScrollIndex === "number") {
781
856
  const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
782
857
  const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
783
858
  const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
@@ -1068,7 +1143,10 @@ const VirtualScrollInner = <T,>(
1068
1143
  didApplyInitialOffsetRef.current = true
1069
1144
  // 目的: 初期オフセットを一度だけ適用し (同期処理)、ペインと論理位置を同期させる。
1070
1145
  // 依存関係: initialScrollOffset (初回のみ実行されるガード節あり)
1071
- if (typeof initialScrollOffset === "number") {
1146
+ // initialScrollOffset の適用はそれが「最優先の初期位置指定」である場合のみ。
1147
+ // アンカー/インデックス指定と併用されたとき、この passive effect (描画後) が offset で
1148
+ // 上書きすると「初回描画は index 位置 → 1 コミット後に offset 位置へ跳ぶ」踏み潰しになる
1149
+ if (initialScrollAnchor == null && typeof initialScrollIndex !== "number" && typeof initialScrollOffset === "number") {
1072
1150
  const paneOffset = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
1073
1151
  const needsPaneSync = Math.abs(paneOffset - latestScrollPositionRef.current) > 0.5
1074
1152
  updateScrollPositionImmediate(paneOffset, { immediate: true })
@@ -1078,7 +1156,7 @@ const VirtualScrollInner = <T,>(
1078
1156
  } else {
1079
1157
  updateScrollPositionImmediate(latestScrollPositionRef.current, { immediate: true })
1080
1158
  }
1081
- }, [initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
1159
+ }, [initialScrollAnchor, initialScrollIndex, initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
1082
1160
 
1083
1161
  useEffect(() => {
1084
1162
  // itemCount triggers recalculation of contentSize
@@ -1162,10 +1240,18 @@ const VirtualScrollInner = <T,>(
1162
1240
  // 依存関係: resolvedInsets.top
1163
1241
  const previousTop = previousTopInsetRef.current
1164
1242
  if (previousTop === resolvedInsets.top) return
1243
+ previousTopInsetRef.current = resolvedInsets.top
1244
+
1245
+ // ❗ 保留アンカー (scrollToIndex / initialScrollAnchor 由来) が居る間は位置決めをドリフト補正
1246
+ // effect へ委譲する。ここでも相対シフトすると、アンカー基準の絶対位置決め (新 top で再計算済み)
1247
+ // と二重適用になり、論理位置が inset 差分ぶんズレる (2.0.0 の「論理位置は inset 非依存」契約の
1248
+ // 破れとして動的 inset ピンが検知した実バグ。previousTopInsetRef の更新だけ行う)
1249
+ if (pendingVisibleStartIndexRef.current) {
1250
+ return
1251
+ }
1165
1252
 
1166
1253
  const logicalPosition = toLogicalPositionWithInset(latestScrollPositionRef.current, previousTop)
1167
1254
  const panePosition = toPanePositionWithInset(logicalPosition, resolvedInsets.top)
1168
- previousTopInsetRef.current = resolvedInsets.top
1169
1255
  latestScrollPositionRef.current = panePosition
1170
1256
  scrollPaneRef.current?.scrollTo(panePosition)
1171
1257
  updateScrollPositionImmediate(panePosition, { immediate: true })
@@ -1322,6 +1408,21 @@ const VirtualScrollInner = <T,>(
1322
1408
  [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
1323
1409
  )
1324
1410
 
1411
+ // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
1412
+ // 初期値経路 (useState) はマウント時データが前提のため、データ後着の消費者では
1413
+ // アンカーが無言に落ちる。最初の 0 → N 遷移で scrollToIndex 経由で適用する
1414
+ // (アンカー近傍の具現化 + 保留アンカー設定を再利用。初回コミット厳密性はデータが
1415
+ // ある状態でのマウントのみで保証され、遅延適用は近似 → ドリフト補正で自己修復)
1416
+ const deferredInitialAnchorRef = useRef(initialScrollAnchor && itemCount === 0 ? initialScrollAnchor : null)
1417
+ useEffect(() => {
1418
+ const deferred = deferredInitialAnchorRef.current
1419
+ if (!deferred || itemCount === 0) {
1420
+ return
1421
+ }
1422
+ deferredInitialAnchorRef.current = null
1423
+ scrollToIndex(minmax(Math.trunc(deferred.index), 0, itemCount - 1), { align: "top", offset: -Math.max(0, deferred.offsetPx ?? 0) })
1424
+ }, [itemCount, scrollToIndex])
1425
+
1325
1426
  /**
1326
1427
  * Scrolls to a raw offset while resolving to an index.
1327
1428
  *
@@ -1346,6 +1447,33 @@ const VirtualScrollInner = <T,>(
1346
1447
  [fenwickTree, itemCount, scrollToIndex],
1347
1448
  )
1348
1449
 
1450
+ /**
1451
+ * Captures the current top-row anchor for exact position restore across remounts.
1452
+ * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。
1453
+ *
1454
+ * offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
1455
+ * 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
1456
+ * ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
1457
+ * 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
1458
+ */
1459
+ const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
1460
+ if (itemCount === 0) {
1461
+ return null
1462
+ }
1463
+ const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
1464
+ const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(logical, { materializeOption: { materialize: false } })
1465
+ if (index === -1) {
1466
+ // 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
1467
+ return { index: itemCount - 1, offsetPx: 0 }
1468
+ }
1469
+ if (cumulative === logical) {
1470
+ // 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
1471
+ return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
1472
+ }
1473
+ const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
1474
+ return { index, offsetPx: Math.max(0, logical - itemTop) }
1475
+ }, [fenwickTree, itemCount, resolvedInsets.top])
1476
+
1349
1477
  /**
1350
1478
  * Imperative scroll entry supporting updater functions.
1351
1479
  *
@@ -1359,7 +1487,9 @@ const VirtualScrollInner = <T,>(
1359
1487
  const panePosition = scrollPaneRef.current?.getScrollPosition()
1360
1488
  const effectivePosition = typeof panePosition === "number" ? panePosition : latestScrollPositionRef.current
1361
1489
  updateScrollPositionImmediate(effectivePosition)
1362
- return effectivePosition
1490
+ // ❗ 返値も論理座標 (2.0.0)。ペイン座標を返すと「入力は論理・返値はペイン」の非対称が
1491
+ // 復活し、返値をミラーへ書き戻す消費者が inset 分ずれる
1492
+ return toLogicalPositionWithInset(effectivePosition, resolvedInsetsTopRef.current)
1363
1493
  },
1364
1494
  [resolvedInsets.top, scrollTo, updateScrollPositionImmediate],
1365
1495
  )
@@ -1887,7 +2017,12 @@ const VirtualScrollInner = <T,>(
1887
2017
  useImperativeHandle(
1888
2018
  ref,
1889
2019
  () => ({
1890
- getScrollPosition: () => scrollPaneRef.current?.getScrollPosition() ?? -1,
2020
+ getScrollPosition: () => {
2021
+ // ❗ 論理座標で返す (2.0.0)。ペイン座標を返す実装へ戻さないこと: 消費者の換算コードが
2022
+ // 座標混在バグの温床になる (この統一が本質修正)。inset は走行中も最新を ref から読む
2023
+ const panePosition = scrollPaneRef.current?.getScrollPosition()
2024
+ return typeof panePosition === "number" ? toLogicalPositionWithInset(panePosition, resolvedInsetsTopRef.current) : -1
2025
+ },
1891
2026
  getContentSize: () => scrollPaneRef.current?.getContentSize() ?? -1,
1892
2027
  getViewportSize: () => scrollPaneRef.current?.getViewportSize() ?? -1,
1893
2028
  scrollTo: scrollToHandle,
@@ -1896,9 +2031,10 @@ const VirtualScrollInner = <T,>(
1896
2031
  getFenwickSize: () => fenwickTree.getSize(),
1897
2032
  focusItemAtIndex,
1898
2033
  getRange: () => currentRangeRef.current,
2034
+ getScrollAnchor,
1899
2035
  updateItemSize,
1900
2036
  }),
1901
- [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, updateItemSize],
2037
+ [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
1902
2038
  )
1903
2039
 
1904
2040
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom