@aiquants/virtualscroll 3.9.1 → 3.9.2

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": "3.9.1",
3
+ "version": "3.9.2",
4
4
  "description": "High-performance virtual scrolling component for React with variable item heights",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -678,6 +678,24 @@ const edgeOfAlignment = (align: "top" | "bottom" | "center" | undefined): Aligne
678
678
  return align === "bottom" ? "end" : "start"
679
679
  }
680
680
 
681
+ /**
682
+ * Returns the canonical form of an alignment to remember: `null` for one that can never change the edge the items
683
+ * wrapper keeps, the alignment itself otherwise. An alignment at pane position 0 or below holds only at positions within
684
+ * `EDGE_POSITION_TOLERANCE` of position 0, where `resolveItemsWrapperSnapEdge` answers `"start"` from the position
685
+ * alone, so it means the same as remembering nothing. Storing only the canonical form keeps one state for one meaning:
686
+ * a list the user scrolled back to the top and a list `scrollToIndex` aligned there hold the same state, so aligning it
687
+ * to the top again writes nothing.
688
+ *
689
+ * 覚える揃えの正規形を返す処理。行ラッパーが守る端を決して変えない揃えは `null`、それ以外は揃えそのもの。ペイン位置 0 以下の
690
+ * 揃えが成り立つのは位置 0 から `EDGE_POSITION_TOLERANCE` 以内の位置だけで、そこでは `resolveItemsWrapperSnapEdge` が位置
691
+ * だけから `"start"` を返すため、何も覚えないのと同じ意味。正規形だけを持つことで 1 つの意味に 1 つの状態となり、利用者が
692
+ * 先頭へ戻した一覧と `scrollToIndex` が先頭へ揃えた一覧が同じ状態を持ち、先頭へ揃え直しても何も書かない。
693
+ *
694
+ * @param alignment - The alignment, or `null` / 揃え (無ければ `null`)
695
+ * @returns The canonical alignment, or `null` / 正規形の揃え (無ければ `null`)
696
+ */
697
+ const canonicalAlignedEdge = (alignment: AlignedEdge | null): AlignedEdge | null => (alignment !== null && alignment.panePosition <= 0 ? null : alignment)
698
+
681
699
  /**
682
700
  * Chooses the edge the items-wrapper translate keeps when it is snapped to the device-pixel grid
683
701
  * (`snapToDevicePixelGrid`), so that aligned content never loses part of its edge gutter to the snap:
@@ -1428,26 +1446,48 @@ const VirtualScrollInner = <T,>(
1428
1446
 
1429
1447
  const [scrollPosition, setScrollPosition] = useState(initialValues.position)
1430
1448
  const [contentSize, setContentSize] = useState<number>(initialValues.total)
1431
- // 初期位置の index とアンカーは上端揃えの scrollToIndex と同じ位置なので、その位置の上端揃えとして覚えておく。位置 0 から
1432
- // 始まる一覧も先頭の行の上端に揃っている (位置 0 の行ラッパーは覚えた揃えによらず上端で揃える) ため、同じく覚える。覚えずに
1433
- // 始めると、先頭へ戻すだけの最初の scrollToIndex (ホストが開いた直後の打鍵で先頭へ戻すなど) が状態を書き、確定を 1 回足す
1434
- const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" || initialValues.position <= EDGE_POSITION_TOLERANCE ? { edge: "start", panePosition: initialValues.position } : null))
1449
+ // 初期位置の index とアンカーは上端揃えの scrollToIndex と同じ位置なので、その位置の上端揃えとして覚えておく。覚えずに
1450
+ // 始めると、同じ行へ揃え直すだけの最初の scrollToIndex が状態を書き、確定を 1 回足す。位置 0 では正規形が null になる
1451
+ const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" ? canonicalAlignedEdge({ edge: "start", panePosition: initialValues.position }) : null))
1435
1452
  // 状態の同期の写し。手動スクロールのたびに状態へ null を書くと、同じ値でも React が描画を予約し得るため、写しで要否を決める
1436
1453
  const alignedEdgeRef = useRef(alignedEdge)
1454
+ // contentSize の同期の写し。確定の直後は同じ値の setState でも React が描画を 1 回予約するため、書くかどうかを写しで決める
1455
+ const contentSizeRef = useRef(contentSize)
1456
+
1457
+ /**
1458
+ * Writes the content size the pane and the scroll bar are sized by, and its synchronous copy, only when the value
1459
+ * differs from the one last written (every writer of the content size goes through here, so the copy is the value the
1460
+ * state settles on).
1461
+ *
1462
+ * ペインとスクロールバーの寸法の元になる中身の寸法と、その同期の写しへ、最後に書いた値と違うときだけ書く処理 (中身の寸法の
1463
+ * 書き手はすべてここを通るため、写しは状態が落ち着く値)。
1464
+ *
1465
+ * @param next - The content size (px) / 中身の寸法 (px)
1466
+ */
1467
+ const writeContentSize = useCallback((next: number): void => {
1468
+ if (contentSizeRef.current === next) {
1469
+ return
1470
+ }
1471
+ contentSizeRef.current = next
1472
+ setContentSize(next)
1473
+ }, [])
1437
1474
 
1438
1475
  /**
1439
- * Remembers the alignment that holds at a pane position, or forgets it with `null`: writes the state the items wrapper
1440
- * snaps with and its synchronous copy, only when the edge or the pane position differs from the remembered one.
1476
+ * Remembers the alignment that holds at a pane position, or forgets it with `null`: reduces it to its canonical form
1477
+ * (`canonicalAlignedEdge`, so an alignment at position 0 is the same as none), then writes the state the items wrapper
1478
+ * snaps with and its synchronous copy, only when the canonical form differs from the remembered one in edge or pane
1479
+ * position.
1441
1480
  *
1442
- * あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。行ラッパーが揃えに使う状態と、その同期の写しへ、覚えた揃えと
1443
- * 辺かペイン位置が違うときだけ書く。
1481
+ * あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。揃えを正規形にし (`canonicalAlignedEdge`。位置 0 の揃えは
1482
+ * 揃え無しと同じ)、行ラッパーが揃えに使う状態と、その同期の写しへ、正規形が覚えた揃えと辺かペイン位置で違うときだけ書く。
1444
1483
  *
1445
- * @param next - The alignment, or `null` / 揃え (無ければ `null`)
1484
+ * @param alignment - The alignment, or `null` / 揃え (無ければ `null`)
1446
1485
  */
1447
- const rememberAlignedEdge = useCallback((next: AlignedEdge | null): void => {
1486
+ const rememberAlignedEdge = useCallback((alignment: AlignedEdge | null): void => {
1448
1487
  const current = alignedEdgeRef.current
1488
+ const next = canonicalAlignedEdge(alignment)
1449
1489
  // 同じ揃えでも新しいオブジェクトを書けば React は描画を予約する。同じ位置への scrollToIndex (ホストが打鍵ごとに
1450
- // 先頭へ戻すなど) のたびに確定が 1 回増えるため、辺と位置が同じなら書かない
1490
+ // 揃え直すなど) のたびに確定が 1 回増えるため、辺と位置が同じなら書かない
1451
1491
  if (current === next || (current !== null && next !== null && current.edge === next.edge && current.panePosition === next.panePosition)) {
1452
1492
  return
1453
1493
  }
@@ -1457,10 +1497,15 @@ const VirtualScrollInner = <T,>(
1457
1497
 
1458
1498
  /**
1459
1499
  * Moves the remembered alignment along with a position change that keeps the aligned row in place (layout-shift
1460
- * compensation): when the alignment held at `fromPanePosition`, it now holds at `toPanePosition`.
1500
+ * compensation): when the alignment held at `fromPanePosition`, it now holds at `toPanePosition`. Only a remembered
1501
+ * alignment moves: the top edge a list keeps at position 0 comes from the position (`resolveItemsWrapperSnapEdge`), not
1502
+ * from the state (`canonicalAlignedEdge`), so a move away from position 0 carries no edge, and an alignment carried to
1503
+ * position 0 or below is dropped.
1461
1504
  *
1462
1505
  * 揃えた行をその場に留める位置の変化 (レイアウトシフトの補正) に合わせて、覚えた揃えを動かす処理。揃えが
1463
- * `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。
1506
+ * `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。動くのは覚えた揃えだけ。位置 0 の一覧が守る上端は
1507
+ * 状態ではなく位置から決まる (`resolveItemsWrapperSnapEdge`・`canonicalAlignedEdge`) ため、位置 0 から離れる変化は端を
1508
+ * 運ばず、位置 0 以下へ運んだ揃えは消える。
1464
1509
  *
1465
1510
  * @param fromPanePosition - Pane position before the change / 変化の前のペイン位置
1466
1511
  * @param toPanePosition - Pane position after the change / 変化の後のペイン位置
@@ -1861,7 +1906,7 @@ const VirtualScrollInner = <T,>(
1861
1906
 
1862
1907
  const totalHeight = fenwickTree.getTotal()
1863
1908
  if (contentSize !== totalHeight) {
1864
- setContentSize(totalHeight)
1909
+ writeContentSize(totalHeight)
1865
1910
  return
1866
1911
  }
1867
1912
 
@@ -1933,7 +1978,7 @@ const VirtualScrollInner = <T,>(
1933
1978
  }
1934
1979
  }
1935
1980
  isResizingRef.current = false
1936
- }, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
1981
+ }, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom, writeContentSize])
1937
1982
 
1938
1983
  useEffect(() => {
1939
1984
  // 目的: 上部インセットが動的に変更された場合、論理スクロール位置を維持したまま、ペインのスクロール位置を再計算してずらす。
@@ -1985,7 +2030,7 @@ const VirtualScrollInner = <T,>(
1985
2030
 
1986
2031
  const total = fenwickTree.update(safeIndex, size)
1987
2032
  if (total !== undefined) {
1988
- setContentSize(total)
2033
+ writeContentSize(total)
1989
2034
  }
1990
2035
  Logger.debug("[VirtualScroll] Updated item size manually", { index: safeIndex, size, total })
1991
2036
 
@@ -2028,16 +2073,23 @@ const VirtualScrollInner = <T,>(
2028
2073
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
2029
2074
  }
2030
2075
  },
2031
- [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
2076
+ [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2032
2077
  )
2033
2078
 
2034
2079
  /**
2035
2080
  * Scrolls to the requested logical index: lands the pane exactly on the aligned position (clamped to the content),
2036
2081
  * pins it as the pending alignment for drift correction, and remembers the alignment's edge at that position for
2037
- * the device-pixel snap of the items wrapper (`resolveItemsWrapperSnapEdge`).
2082
+ * the device-pixel snap of the items wrapper (`resolveItemsWrapperSnapEdge`, in its canonical form). The content size
2083
+ * and the scroll position are written only when they differ from their synchronous copies (`writeContentSize`; for the
2084
+ * position, `latestScrollPositionRef` while the render loop is stopped, read before the pane moves), so a call that
2085
+ * leaves the list where it is (the same row, alignment and content) writes no state, schedules no render and reports
2086
+ * no position to `onScroll`, like `scrollTo` to the current position.
2038
2087
  *
2039
2088
  * 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
2040
- * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を覚える (`resolveItemsWrapperSnapEdge`)。
2089
+ * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を正規形で覚える
2090
+ * (`resolveItemsWrapperSnapEdge`)。中身の寸法とスクロール位置は同期の写し (`writeContentSize`。位置は描画ループが止まって
2091
+ * いる間の `latestScrollPositionRef` を、ペインを動かす前に読む) と違うときだけ書くため、一覧をその場に留める呼び出し (同じ行・揃え・中身) は状態を書かず、描画を予約せず、`onScroll` へ位置を
2092
+ * 知らせない (現在位置への `scrollTo` と同じ)。
2041
2093
  *
2042
2094
  * @param index - Item index / アイテムのインデックス
2043
2095
  * @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
@@ -2099,6 +2151,10 @@ const VirtualScrollInner = <T,>(
2099
2151
  const edge = edgeOfAlignment(options?.align)
2100
2152
  rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
2101
2153
 
2154
+ // ❗ 判定はペインを動かす前に行う。ペインの scrollTo はスクロールの処理を同期で呼び、位置の写しを着地位置へ進めて
2155
+ // 描画ループを起こすが、状態はそのループの次のフレームまで古いまま。描画ループが止まっている間だけ、状態 (予約済みの
2156
+ // 値を含む) が位置の写しと一致する
2157
+ const stateHoldsLanding = !renderLoopRef.current.loopActive && latestScrollPositionRef.current === clampedPaneOffset
2102
2158
  const currentPanePosition = scrollPaneRef.current?.getScrollPosition() ?? -1
2103
2159
  // ❗ 揃えは厳密に着地させる。半ピクセル未満のずれを残すと、揃えた行の端の余白をそのずれが削り、行ラッパーを
2104
2160
  // 揃えた端の側へ丸めても取り戻せない (覚えた揃えもペインの位置と一致せず効かない)
@@ -2120,12 +2176,15 @@ const VirtualScrollInner = <T,>(
2120
2176
  scrollPaneRef.current?.scrollTo(clampedPaneOffset)
2121
2177
  }
2122
2178
 
2123
- setContentSize(total)
2124
- updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
2179
+ writeContentSize(total)
2180
+ // 確定の直後は同じ値の setState でも React が描画を 1 回予約するため、状態が既に着地位置なら書かない
2181
+ if (!stateHoldsLanding) {
2182
+ updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
2183
+ }
2125
2184
 
2126
2185
  Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
2127
2186
  },
2128
- [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate],
2187
+ [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2129
2188
  )
2130
2189
 
2131
2190
  // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
@@ -2652,7 +2711,7 @@ const VirtualScrollInner = <T,>(
2652
2711
  // contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
2653
2712
  // fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
2654
2713
  // fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
2655
- // 両経路とも setContentSize を伴うため、contentSize を依存へ含めることで
2714
+ // 両経路とも中身の寸法の書き込み (writeContentSize) を伴うため、contentSize を依存へ含めることで
2656
2715
  // 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
2657
2716
  void contentSize
2658
2717
 
@@ -2763,7 +2822,7 @@ const VirtualScrollInner = <T,>(
2763
2822
  if (isUnmountedRef.current || typeof total !== "number") {
2764
2823
  return
2765
2824
  }
2766
- setContentSize(total)
2825
+ writeContentSize(total)
2767
2826
  Logger.debug("[VirtualScroll] Updated heights for items", toUpdateHeights, "New total height:", total)
2768
2827
  const panePosition = scrollPaneRef.current?.getScrollPosition() ?? latestScrollPositionRef.current
2769
2828
 
@@ -2807,6 +2866,7 @@ const VirtualScrollInner = <T,>(
2807
2866
  resolvedLabels,
2808
2867
  updateScrollPositionImmediate,
2809
2868
  visibleStartIndex,
2869
+ writeContentSize,
2810
2870
  ])
2811
2871
 
2812
2872
  /**