@aiquants/virtualscroll 1.23.0 → 1.25.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.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -40,6 +40,12 @@ export type VirtualScrollHandle = ScrollPaneHandle & {
40
40
  focusItemAtIndex: (index: number, options?: { ensureVisible?: boolean }) => void
41
41
  /** Gets the current scroll range information / 現在のスクロール範囲情報を取得 */
42
42
  getRange: () => VirtualScrollRange
43
+ /**
44
+ * Captures the current top-row anchor ({index, offsetPx}) for exact restore via initialScrollAnchor.
45
+ * initialScrollAnchor での厳密復元用に、現在の先頭可視行アンカー ({index, offsetPx}) を取得。
46
+ * itemCount 0 のときは null。offsetPx は先頭可視行の上端がビューポート上端より上に隠れている px (論理座標)。
47
+ */
48
+ getScrollAnchor: () => { index: number; offsetPx: number } | null
43
49
  /**
44
50
  * Manually updates the size of a specific item. Contract: after calling this,
45
51
  * `getItemHeight(index)` must return the same `size`; `getItemHeight` is the source of truth,
@@ -132,6 +138,19 @@ export type VirtualScrollProps<T> = {
132
138
  children: (item: T, index: number) => ReactNode
133
139
  initialScrollIndex?: number
134
140
  initialScrollOffset?: number
141
+ /**
142
+ * Anchor-based initial position: starts with row `index` scrolled `offsetPx` px past the viewport top.
143
+ * アンカー基準の初期位置。行 index の上端がビューポート上端より offsetPx px 分だけ上に隠れた位置で開始する。
144
+ *
145
+ * 可変高さ一覧の「位置キープ」はこれを使うこと。initialScrollOffset の生 px 復元は、実測高さが
146
+ * 具現化されていない再マウント空間では px→行変換が推定値で行われ、別の行に着地する
147
+ * (実測: 深い復元で 21 行漂着)。アンカーは行の同一性で位置決めするため空間差に不変。
148
+ * 保存側は VirtualScrollHandle.getScrollAnchor() で取得した値をそのまま渡す。
149
+ * 優先順位: initialScrollAnchor > initialScrollIndex > initialScrollOffset。
150
+ * マウント時に itemCount > 0 なら初回コミットから厳密。itemCount 0 でマウントした場合は
151
+ * 最初のデータ到着 (0 → N) 時に scrollToIndex 経由で遅延適用される (近似 → 自己修復)。
152
+ */
153
+ initialScrollAnchor?: { index: number; offsetPx?: number }
135
154
  callbackThrottleMs?: number
136
155
  contentInsets?: ScrollPaneProps["contentInsets"]
137
156
  onItemFocus?: (index: number) => void
@@ -180,7 +199,12 @@ const normalizeInsets = (insets?: ScrollPaneContentInsets): Required<ScrollPaneC
180
199
 
181
200
  const toLogicalPositionWithInset = (pane: number, top: number) => (pane <= top ? 0 : pane - top)
182
201
 
183
- const toPanePositionWithInset = (logical: number, top: number) => (logical <= 0 ? top : logical + top)
202
+ // 論理 0 は「本当の先頭」= ペイン 0 (上インセットが見えている状態) へ写像する。
203
+ // ❗ `logical <= 0 ? top` (インセットを隠して先頭行を上端密着にする端点) へ戻さないこと:
204
+ // マウント既定 (initialScroll 未指定 = ペイン 0) や ScrollPane のクランプ休止点 (ペイン 0) と
205
+ // 端点が食い違い、「保存した論理 0 を復元すると数 px スクロールされて見える」実害バグになる
206
+ // (toLogical はペイン [0..top] を論理 0 へ潰す不可逆写像のため、逆写像の端点選択がここで確定する)
207
+ const toPanePositionWithInset = (logical: number, top: number) => (logical <= 0 ? 0 : logical + top)
184
208
 
185
209
  /**
186
210
  * Maximum run length of consecutive zero-height rows scanned linearly before attempting an
@@ -572,15 +596,27 @@ const useThrottledInvoker = <Callback, Payload>(callbackRef: React.MutableRefObj
572
596
  // last: 最終実行時刻, id: RAF ID, arg: 待機中の引数
573
597
  const state = useRef({ last: 0, id: null as number | null, arg: null as Payload | null }).current
574
598
 
575
- // クリーンアップ: アンマウント時にRAFをキャンセル
599
+ // クリーンアップ: アンマウント時に RAF をキャンセルし、待機中のペイロードは**破棄せず同期配信**する。
600
+ // ❗ 破棄すると、フリング中のタブ切替などで確定済みの待機通知まで消費側へ届かず、
601
+ // 「スクロールのたびに書き戻す」型の位置保存が取りこぼす。なおこの flush が救えるのは
602
+ // 「キューに載った最後のペイロード」まで: 最後の render-loop tick 以後の移動 (最大 1 フレーム分、
603
+ // 慣性で概ね 100px 未満) は残余として許容する (読中でないフリング途中の位置のため実害は軽微)
576
604
  useEffect(
577
605
  () => () => {
578
606
  if (state.id !== null) {
579
607
  cancelAnimationFrame(state.id)
580
608
  state.id = null
581
609
  }
610
+ if (state.arg !== null && callbackRef.current) {
611
+ try {
612
+ invoke(callbackRef.current, state.arg)
613
+ } catch (e) {
614
+ console.error("[useThrottledInvoker] Error flushing pending callback on unmount", e)
615
+ }
616
+ state.arg = null
617
+ }
582
618
  },
583
- [state],
619
+ [state, callbackRef, invoke],
584
620
  )
585
621
 
586
622
  return useCallback(
@@ -686,6 +722,7 @@ const VirtualScrollInner = <T,>(
686
722
  background,
687
723
  initialScrollIndex,
688
724
  initialScrollOffset,
725
+ initialScrollAnchor,
689
726
  callbackThrottleMs = 5,
690
727
  contentInsets,
691
728
  onItemFocus,
@@ -709,18 +746,32 @@ const VirtualScrollInner = <T,>(
709
746
  scrollPosition: 0,
710
747
  totalHeight: 0,
711
748
  })
749
+ // アンカー付きマウントは保留アンカーを最初から種まきする: マウント後の実測反映 (contentSize
750
+ // コミット) のたびにドリフト補正 effect が同じ行へ再ピン留めし、自己修復する。ユーザーの
751
+ // 手動スクロールで解除される (handleScroll 参照)。offset は scrollToIndex と同じ「減算」規約の
752
+ // ため符号を反転して持つ (offsetPx = 行上端がビューポート上端より上に隠れる px、正の値)
712
753
  const pendingVisibleStartIndexRef = useRef<{
713
754
  index: number
714
755
  align?: "top" | "bottom" | "center"
715
756
  offset?: number
716
- } | null>(null)
757
+ } | null>(initialScrollAnchor && itemCount > 0 ? { index: minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1), align: "top", offset: -Math.max(0, initialScrollAnchor.offsetPx ?? 0) } : null)
717
758
  // 「アンマウント済みか」を追跡する (初期 false)。「マウント済みか」の正ガードだと、初回レンダー中に
718
759
  // 予約された高さ照合マイクロタスクが passive effect (マウントフラグ設定) より先に実行されて破棄される。
719
760
  const isUnmountedRef = useRef(false)
720
761
 
762
+ // 初回実行スキップ用 (マウント時に走る effect 本体が、直前で種まきした初期アンカーを消さないため)
763
+ const isFirstGetItemHeightRunRef = useRef(true)
721
764
  useEffect(() => {
722
765
  // 目的: アイテムの高さ取得ロジックがリセットされた場合に、古いインデックスへのアンカーを解除する。
723
766
  // 依存関係: resetOnGetItemHeightChange, getItemHeight
767
+ // ❗ 初回 (マウント) は解除しない: このフラグの意味は「getItemHeight の**変更**時に解除」で
768
+ // あり、マウント時の実行で解除すると initialScrollAnchor が種まきした保留アンカー
769
+ // (実測反映ごとの自己修復再ピン留め) が resetOnGetItemHeightChange: true の消費者で
770
+ // 無言に無効化されてしまう
771
+ if (isFirstGetItemHeightRunRef.current) {
772
+ isFirstGetItemHeightRunRef.current = false
773
+ return
774
+ }
724
775
  if (resetOnGetItemHeightChange) {
725
776
  // When configured to reset on logic change, we drop the scroll anchor
726
777
  // to avoid sticking to an index that may no longer be relevant contextually.
@@ -739,7 +790,9 @@ const VirtualScrollInner = <T,>(
739
790
  // (sampleRangeChanged) が破壊的な tree.reset を発火し、実測済みの全行高さが破棄されてしまう。
740
791
  const [initialSampleRange] = useState(() => {
741
792
  const SAMPLE_HALF_WINDOW = 50
742
- const anchor = typeof initialScrollIndex === "number" && Number.isFinite(initialScrollIndex) ? Math.max(0, Math.trunc(initialScrollIndex)) : 0
793
+ // アンカー復元はアンカー行を、index 復元はその行を窓の中心にする (どちらも「最初に見る領域」)
794
+ const anchorSource = initialScrollAnchor?.index ?? initialScrollIndex
795
+ const anchor = typeof anchorSource === "number" && Number.isFinite(anchorSource) ? Math.max(0, Math.trunc(anchorSource)) : 0
743
796
  return { from: Math.max(0, anchor - SAMPLE_HALF_WINDOW), to: anchor + SAMPLE_HALF_WINDOW }
744
797
  })
745
798
 
@@ -760,7 +813,20 @@ const VirtualScrollInner = <T,>(
760
813
  const [initialValues] = useState(() => {
761
814
  let position = 0
762
815
  let total = 0
763
- if (typeof initialScrollIndex === "number") {
816
+ if (initialScrollAnchor && itemCount > 0) {
817
+ // アンカー復元: アンカー行の周辺を具現化し、その行の上端 + offsetPx を初期位置にする。
818
+ // 初回描画のレンジ計算も同じツリー状態から導かれるため、推定誤差の量に関係なく
819
+ // 「アンカー行がちょうど offsetPx 分だけ上に隠れた」画で最初のフレームから出る
820
+ const safeIndex = minmax(Math.trunc(initialScrollAnchor.index), 0, itemCount - 1)
821
+ const anchorOffsetPx = Math.max(0, initialScrollAnchor.offsetPx ?? 0)
822
+ const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
823
+ const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
824
+ const options = safeIndex > 0 || anchorOffsetPx > 0 ? { materializeOption: { materialize: true, ranges: [{ from: safeIndexFrom, to: safeIndexTo }] } } : undefined
825
+ const { cumulative, total: materializedTotal, currentValue } = fenwickTree.prefixSum(safeIndex, options)
826
+ const logicalOffset = Math.max(cumulative - currentValue + anchorOffsetPx, 0)
827
+ position = toPanePositionWithInset(logicalOffset, resolvedInsets.top)
828
+ total = materializedTotal ?? fenwickTree.getTotal()
829
+ } else if (typeof initialScrollIndex === "number") {
764
830
  const safeIndex = minmax(initialScrollIndex, 0, itemCount - 1)
765
831
  const safeIndexFrom = minmax(safeIndex - overscanCount * 2, 0, itemCount - 1)
766
832
  const safeIndexTo = minmax(safeIndex + overscanCount * 2, 0, itemCount - 1)
@@ -1051,7 +1117,10 @@ const VirtualScrollInner = <T,>(
1051
1117
  didApplyInitialOffsetRef.current = true
1052
1118
  // 目的: 初期オフセットを一度だけ適用し (同期処理)、ペインと論理位置を同期させる。
1053
1119
  // 依存関係: initialScrollOffset (初回のみ実行されるガード節あり)
1054
- if (typeof initialScrollOffset === "number") {
1120
+ // initialScrollOffset の適用はそれが「最優先の初期位置指定」である場合のみ。
1121
+ // アンカー/インデックス指定と併用されたとき、この passive effect (描画後) が offset で
1122
+ // 上書きすると「初回描画は index 位置 → 1 コミット後に offset 位置へ跳ぶ」踏み潰しになる
1123
+ if (initialScrollAnchor == null && typeof initialScrollIndex !== "number" && typeof initialScrollOffset === "number") {
1055
1124
  const paneOffset = toPanePositionWithInset(Math.max(initialScrollOffset, 0), resolvedInsets.top)
1056
1125
  const needsPaneSync = Math.abs(paneOffset - latestScrollPositionRef.current) > 0.5
1057
1126
  updateScrollPositionImmediate(paneOffset, { immediate: true })
@@ -1061,7 +1130,7 @@ const VirtualScrollInner = <T,>(
1061
1130
  } else {
1062
1131
  updateScrollPositionImmediate(latestScrollPositionRef.current, { immediate: true })
1063
1132
  }
1064
- }, [initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
1133
+ }, [initialScrollAnchor, initialScrollIndex, initialScrollOffset, resolvedInsets.top, updateScrollPositionImmediate])
1065
1134
 
1066
1135
  useEffect(() => {
1067
1136
  // itemCount triggers recalculation of contentSize
@@ -1305,6 +1374,21 @@ const VirtualScrollInner = <T,>(
1305
1374
  [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, updateScrollPositionImmediate],
1306
1375
  )
1307
1376
 
1377
+ // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
1378
+ // 初期値経路 (useState) はマウント時データが前提のため、データ後着の消費者では
1379
+ // アンカーが無言に落ちる。最初の 0 → N 遷移で scrollToIndex 経由で適用する
1380
+ // (アンカー近傍の具現化 + 保留アンカー設定を再利用。初回コミット厳密性はデータが
1381
+ // ある状態でのマウントのみで保証され、遅延適用は近似 → ドリフト補正で自己修復)
1382
+ const deferredInitialAnchorRef = useRef(initialScrollAnchor && itemCount === 0 ? initialScrollAnchor : null)
1383
+ useEffect(() => {
1384
+ const deferred = deferredInitialAnchorRef.current
1385
+ if (!deferred || itemCount === 0) {
1386
+ return
1387
+ }
1388
+ deferredInitialAnchorRef.current = null
1389
+ scrollToIndex(minmax(Math.trunc(deferred.index), 0, itemCount - 1), { align: "top", offset: -Math.max(0, deferred.offsetPx ?? 0) })
1390
+ }, [itemCount, scrollToIndex])
1391
+
1308
1392
  /**
1309
1393
  * Scrolls to a raw offset while resolving to an index.
1310
1394
  *
@@ -1329,6 +1413,33 @@ const VirtualScrollInner = <T,>(
1329
1413
  [fenwickTree, itemCount, scrollToIndex],
1330
1414
  )
1331
1415
 
1416
+ /**
1417
+ * Captures the current top-row anchor for exact position restore across remounts.
1418
+ * 再マウント越しの厳密な位置復元のために、現在の先頭可視行アンカーを取得する処理。
1419
+ *
1420
+ * offsetPx は「先頭可視行の上端がビューポート上端より上に隠れている px」(正の値、論理座標)。
1421
+ * 復元は initialScrollAnchor へそのまま渡す。可視ウィンドウの行高さは描画のたびに実測が
1422
+ * ツリーへ照合されるため、保存時点のアンカーは常に正確で、生 px と違い再マウント後の
1423
+ * 推定空間の違いに対して不変 (px 復元は深い位置で別の行に着地する)。
1424
+ */
1425
+ const getScrollAnchor = useCallback((): { index: number; offsetPx: number } | null => {
1426
+ if (itemCount === 0) {
1427
+ return null
1428
+ }
1429
+ const logical = toLogicalPositionWithInset(latestScrollPositionRef.current, resolvedInsets.top)
1430
+ const { index, cumulative, currentValue } = fenwickTree.findIndexAtOrAfter(logical, { materializeOption: { materialize: false } })
1431
+ if (index === -1) {
1432
+ // 末尾越え (推定総高さより深い位置) は最終行アンカーへ丸める
1433
+ return { index: itemCount - 1, offsetPx: 0 }
1434
+ }
1435
+ if (cumulative === logical) {
1436
+ // 行の下端がちょうどビューポート上端 = 可視先頭は次の行 (可視域計算と同じ境界規約)
1437
+ return { index: minmax(index + 1, 0, itemCount - 1), offsetPx: 0 }
1438
+ }
1439
+ const itemTop = (cumulative ?? 0) - (currentValue ?? 0)
1440
+ return { index, offsetPx: Math.max(0, logical - itemTop) }
1441
+ }, [fenwickTree, itemCount, resolvedInsets.top])
1442
+
1332
1443
  /**
1333
1444
  * Imperative scroll entry supporting updater functions.
1334
1445
  *
@@ -1879,9 +1990,10 @@ const VirtualScrollInner = <T,>(
1879
1990
  getFenwickSize: () => fenwickTree.getSize(),
1880
1991
  focusItemAtIndex,
1881
1992
  getRange: () => currentRangeRef.current,
1993
+ getScrollAnchor,
1882
1994
  updateItemSize,
1883
1995
  }),
1884
- [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, updateItemSize],
1996
+ [scrollToHandle, scrollToIndex, fenwickTree, focusItemAtIndex, getScrollAnchor, updateItemSize],
1885
1997
  )
1886
1998
 
1887
1999
  const totalContentHeight = fenwickTree.getTotal() + resolvedInsets.top + resolvedInsets.bottom