@aiquants/virtualscroll 3.9.0 → 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.0",
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,30 +1446,66 @@ 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 と同じ位置なので、その位置の上端揃えとして覚えておく
1432
- const [alignedEdge, setAlignedEdge] = useState<AlignedEdge | null>(() => ((initialScrollAnchor && itemCount > 0) || typeof initialScrollIndex === "number" ? { 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))
1433
1452
  // 状態の同期の写し。手動スクロールのたびに状態へ null を書くと、同じ値でも React が描画を予約し得るため、写しで要否を決める
1434
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
+ }, [])
1435
1474
 
1436
1475
  /**
1437
- * Remembers the alignment that holds at a pane position, or forgets it with `null`: writes the state the items wrapper
1438
- * snaps with and its synchronous copy.
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.
1439
1480
  *
1440
- * あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。行ラッパーが揃えに使う状態と、その同期の写しへ書く。
1481
+ * あるペイン位置で成り立つ揃えを覚える処理 (`null` で忘れる)。揃えを正規形にし (`canonicalAlignedEdge`。位置 0 の揃えは
1482
+ * 揃え無しと同じ)、行ラッパーが揃えに使う状態と、その同期の写しへ、正規形が覚えた揃えと辺かペイン位置で違うときだけ書く。
1441
1483
  *
1442
- * @param next - The alignment, or `null` / 揃え (無ければ `null`)
1484
+ * @param alignment - The alignment, or `null` / 揃え (無ければ `null`)
1443
1485
  */
1444
- const rememberAlignedEdge = useCallback((next: AlignedEdge | null): void => {
1486
+ const rememberAlignedEdge = useCallback((alignment: AlignedEdge | null): void => {
1487
+ const current = alignedEdgeRef.current
1488
+ const next = canonicalAlignedEdge(alignment)
1489
+ // 同じ揃えでも新しいオブジェクトを書けば React は描画を予約する。同じ位置への scrollToIndex (ホストが打鍵ごとに
1490
+ // 揃え直すなど) のたびに確定が 1 回増えるため、辺と位置が同じなら書かない
1491
+ if (current === next || (current !== null && next !== null && current.edge === next.edge && current.panePosition === next.panePosition)) {
1492
+ return
1493
+ }
1445
1494
  alignedEdgeRef.current = next
1446
1495
  setAlignedEdge(next)
1447
1496
  }, [])
1448
1497
 
1449
1498
  /**
1450
1499
  * Moves the remembered alignment along with a position change that keeps the aligned row in place (layout-shift
1451
- * 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.
1452
1504
  *
1453
1505
  * 揃えた行をその場に留める位置の変化 (レイアウトシフトの補正) に合わせて、覚えた揃えを動かす処理。揃えが
1454
- * `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。
1506
+ * `fromPanePosition` で成り立っていたなら、`toPanePosition` で成り立つ。動くのは覚えた揃えだけ。位置 0 の一覧が守る上端は
1507
+ * 状態ではなく位置から決まる (`resolveItemsWrapperSnapEdge`・`canonicalAlignedEdge`) ため、位置 0 から離れる変化は端を
1508
+ * 運ばず、位置 0 以下へ運んだ揃えは消える。
1455
1509
  *
1456
1510
  * @param fromPanePosition - Pane position before the change / 変化の前のペイン位置
1457
1511
  * @param toPanePosition - Pane position after the change / 変化の後のペイン位置
@@ -1852,7 +1906,7 @@ const VirtualScrollInner = <T,>(
1852
1906
 
1853
1907
  const totalHeight = fenwickTree.getTotal()
1854
1908
  if (contentSize !== totalHeight) {
1855
- setContentSize(totalHeight)
1909
+ writeContentSize(totalHeight)
1856
1910
  return
1857
1911
  }
1858
1912
 
@@ -1924,7 +1978,7 @@ const VirtualScrollInner = <T,>(
1924
1978
  }
1925
1979
  }
1926
1980
  isResizingRef.current = false
1927
- }, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom])
1981
+ }, [contentSize, fenwickTree, itemCount, resolvedInsets.top, applySelfAdjustment, rememberAlignedEdge, updateScrollPositionImmediate, viewportSize, resolvedInsets.bottom, writeContentSize])
1928
1982
 
1929
1983
  useEffect(() => {
1930
1984
  // 目的: 上部インセットが動的に変更された場合、論理スクロール位置を維持したまま、ペインのスクロール位置を再計算してずらす。
@@ -1976,7 +2030,7 @@ const VirtualScrollInner = <T,>(
1976
2030
 
1977
2031
  const total = fenwickTree.update(safeIndex, size)
1978
2032
  if (total !== undefined) {
1979
- setContentSize(total)
2033
+ writeContentSize(total)
1980
2034
  }
1981
2035
  Logger.debug("[VirtualScroll] Updated item size manually", { index: safeIndex, size, total })
1982
2036
 
@@ -2019,16 +2073,23 @@ const VirtualScrollInner = <T,>(
2019
2073
  Logger.debug("[VirtualScroll] Adjusted scroll for layout shift (manual update)", { from: currentPanePosition, to: newPosition, causedByIndex: safeIndex, delta, activeVisibleStartIndex })
2020
2074
  }
2021
2075
  },
2022
- [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets],
2076
+ [fenwickTree, itemCount, applySelfAdjustment, carryAlignedEdge, issueCompensationScroll, updateScrollPositionImmediate, contentInsets, writeContentSize],
2023
2077
  )
2024
2078
 
2025
2079
  /**
2026
2080
  * Scrolls to the requested logical index: lands the pane exactly on the aligned position (clamped to the content),
2027
2081
  * pins it as the pending alignment for drift correction, and remembers the alignment's edge at that position for
2028
- * 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.
2029
2087
  *
2030
2088
  * 指定インデックスへのスクロールを実行する処理。ペインを揃えた位置 (中身の範囲へクランプ) へ厳密に着地させ、ドリフト補正の
2031
- * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を覚える (`resolveItemsWrapperSnapEdge`)。
2089
+ * 保留中の揃えとして留め、行ラッパーの装置の画素への揃えのためにその位置で揃えの端を正規形で覚える
2090
+ * (`resolveItemsWrapperSnapEdge`)。中身の寸法とスクロール位置は同期の写し (`writeContentSize`。位置は描画ループが止まって
2091
+ * いる間の `latestScrollPositionRef` を、ペインを動かす前に読む) と違うときだけ書くため、一覧をその場に留める呼び出し (同じ行・揃え・中身) は状態を書かず、描画を予約せず、`onScroll` へ位置を
2092
+ * 知らせない (現在位置への `scrollTo` と同じ)。
2032
2093
  *
2033
2094
  * @param index - Item index / アイテムのインデックス
2034
2095
  * @param options - Alignment (`"top"` by default) and offset / 揃え方 (既定は `"top"`) と offset
@@ -2090,6 +2151,10 @@ const VirtualScrollInner = <T,>(
2090
2151
  const edge = edgeOfAlignment(options?.align)
2091
2152
  rememberAlignedEdge(edge === null ? null : { edge, panePosition: clampedPaneOffset })
2092
2153
 
2154
+ // ❗ 判定はペインを動かす前に行う。ペインの scrollTo はスクロールの処理を同期で呼び、位置の写しを着地位置へ進めて
2155
+ // 描画ループを起こすが、状態はそのループの次のフレームまで古いまま。描画ループが止まっている間だけ、状態 (予約済みの
2156
+ // 値を含む) が位置の写しと一致する
2157
+ const stateHoldsLanding = !renderLoopRef.current.loopActive && latestScrollPositionRef.current === clampedPaneOffset
2093
2158
  const currentPanePosition = scrollPaneRef.current?.getScrollPosition() ?? -1
2094
2159
  // ❗ 揃えは厳密に着地させる。半ピクセル未満のずれを残すと、揃えた行の端の余白をそのずれが削り、行ラッパーを
2095
2160
  // 揃えた端の側へ丸めても取り戻せない (覚えた揃えもペインの位置と一致せず効かない)
@@ -2111,12 +2176,15 @@ const VirtualScrollInner = <T,>(
2111
2176
  scrollPaneRef.current?.scrollTo(clampedPaneOffset)
2112
2177
  }
2113
2178
 
2114
- setContentSize(total)
2115
- updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
2179
+ writeContentSize(total)
2180
+ // 確定の直後は同じ値の setState でも React が描画を 1 回予約するため、状態が既に着地位置なら書かない
2181
+ if (!stateHoldsLanding) {
2182
+ updateScrollPositionImmediate(clampedPaneOffset, { immediate: true })
2183
+ }
2116
2184
 
2117
2185
  Logger.debug("[VirtualScroll] Setting scroll position to:", clampedPaneOffset, { original: paneOffset, max: maxScrollPosition })
2118
2186
  },
2119
- [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate],
2187
+ [fenwickTree, overscanCount, itemCount, resolvedInsets.top, resolvedInsets.bottom, viewportSize, rememberAlignedEdge, updateScrollPositionImmediate, writeContentSize],
2120
2188
  )
2121
2189
 
2122
2190
  // アンカー付きマウントが itemCount 0 で始まった場合の遅延適用 (一度きり)。
@@ -2643,7 +2711,7 @@ const VirtualScrollInner = <T,>(
2643
2711
  // contentSize は memo 本体では直接使わないが「反応辺」として依存に含める:
2644
2712
  // fenwickTree は安定参照のため、非同期高さ更新 (updateItemSize / 高さ照合マイクロタスクの
2645
2713
  // fenwickTree.updates) で木の prefix 和が変わってもこの memo は自動では失効しない。
2646
- // 両経路とも setContentSize を伴うため、contentSize を依存へ含めることで
2714
+ // 両経路とも中身の寸法の書き込み (writeContentSize) を伴うため、contentSize を依存へ含めることで
2647
2715
  // 行 top を最新の prefix 和で確実に再計算させる (報告座標と視覚描画の desync 防止)。
2648
2716
  void contentSize
2649
2717
 
@@ -2754,7 +2822,7 @@ const VirtualScrollInner = <T,>(
2754
2822
  if (isUnmountedRef.current || typeof total !== "number") {
2755
2823
  return
2756
2824
  }
2757
- setContentSize(total)
2825
+ writeContentSize(total)
2758
2826
  Logger.debug("[VirtualScroll] Updated heights for items", toUpdateHeights, "New total height:", total)
2759
2827
  const panePosition = scrollPaneRef.current?.getScrollPosition() ?? latestScrollPositionRef.current
2760
2828
 
@@ -2798,6 +2866,7 @@ const VirtualScrollInner = <T,>(
2798
2866
  resolvedLabels,
2799
2867
  updateScrollPositionImmediate,
2800
2868
  visibleStartIndex,
2869
+ writeContentSize,
2801
2870
  ])
2802
2871
 
2803
2872
  /**