@aiquants/virtualscroll 3.10.0 → 3.11.1

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.
@@ -41,7 +41,7 @@ import { Logger } from "./logger.ts"
41
41
  import { resolveTapScrollCircleOptions, ScrollBar, type ScrollBarTapCircleOptions } from "./ScrollBar.tsx"
42
42
  import type { ScrollPaneProps } from "./ScrollPane.tsx"
43
43
  import { TapScrollCircle } from "./TapScrollCircle.tsx"
44
- import { useFenwickMapTree } from "./useFenwickMapTree.ts"
44
+ import { FENWICK_LOOKUP_ONLY, useFenwickMapTree, useFenwickTreeRevision } from "./useFenwickMapTree.ts"
45
45
  import { GRID_TAP_CIRCLE_DEFAULT_OFFSET, useGridTapScroll } from "./useGridTapScroll.ts"
46
46
  import { minmax } from "./utils.ts"
47
47
  import { ANCHOR_REBASE_DISTANCE, computeRenderingRanges, MAX_RENDERED_ITEMS, VirtualScroll, type VirtualScrollBehaviorOptions, type VirtualScrollHandle, type VirtualScrollRange, type VirtualScrollScrollBarOptions, ZERO_HEIGHT_RUN_LIMIT } from "./VirtualScroll.tsx"
@@ -144,7 +144,7 @@ export const MAX_FROZEN_TRAILING_ROWS = 128
144
144
  * Maximum number of CONSECUTIVE NON-CONVERGENT self-heal flushes. A stable accessor converges in
145
145
  * ONE flush (tree == accessor afterwards), so the next collect pass is clean and RESETS this
146
146
  * counter — sequential legitimate width changes never accumulate. A truly oscillating getColWidth
147
- * (a contract violation) never produces a clean pass, so the heal→epoch→re-collect microtask
147
+ * (a contract violation) never produces a clean pass, so the heal→revision→re-collect microtask
148
148
  * cycle would spin forever — past this limit healing is suspended with a single warning (bounded
149
149
  * degradation instead of a livelock). ❗ The state is deliberately window-agnostic ({count,
150
150
  * warned} — no window key): a key-scoped reset would be evaded forever by an oscillation whose
@@ -155,7 +155,7 @@ export const MAX_FROZEN_TRAILING_ROWS = 128
155
155
  * 「**連続する非収束**」self-heal フラッシュ数の上限。安定したアクセサは 1 回で収束する
156
156
  * (治癒後は 木 == アクセサ) ため次の走査は乖離ゼロとなり本カウンタは**解消**される — 逐次の
157
157
  * 正当な幅変更は蓄積しない。真に振動する getColWidth (契約違反) は収束パスを一度も作れず、
158
- * 治癒→エポック→再走査のマイクロタスク循環が無限旋回するため、超過時は治癒を停止し 1 回だけ
158
+ * 治癒→版数→再走査のマイクロタスク循環が無限旋回するため、超過時は治癒を停止し 1 回だけ
159
159
  * 警告する (livelock でなく有界な縮退)。❗ 状態は意図的に窓非依存 ({count, warned} — 窓キーを
160
160
  * 持たない): キー基準の解消は、振幅が窓端を動かす振動が治癒のたびにキーを交互させて永久回避する
161
161
  * (実測反証済み)。停止後の回復は収束パス解消が担う (**どの窓でも**収束 1 回で治癒が再武装する)。
@@ -437,7 +437,13 @@ export type VirtualGridProps<T> = {
437
437
  testId?: string
438
438
  }
439
439
 
440
- /** Imperative handle (logical coordinates everywhere; -1 sentinels pre-attach — row-parity incl. return values). / 命令ハンドル (全て論理座標・未接続 -1 番兵 — 返り値契約まで行側と対称)。 */
440
+ /**
441
+ * Imperative handle (logical coordinates everywhere — row-parity incl. return values). React attaches it after the embedded
442
+ * row list, so it never reads as unconnected; a handle the host keeps after the grid unmounts reports the last position and
443
+ * never throws.
444
+ * 命令ハンドル (全て論理座標 — 返り値契約まで行側と対称)。React は埋め込みの行の一覧の後にこれを取り付けるので、未接続の値を返す
445
+ * ことはない。グリッドのアンマウントの後もホストが持ち続けたハンドルは最後の位置を返し、投げない。
446
+ */
441
447
  export type VirtualGridHandle = {
442
448
  /**
443
449
  * Jump API — do NOT use for continuous input (use scrollBy). Vertical delegates to the row
@@ -470,7 +476,7 @@ export type VirtualGridHandle = {
470
476
  * 隠れた末尾セルはどのスクロール位置でも可視化できない — no-op はそこでも正確。
471
477
  */
472
478
  scrollToCell(row: number, col: number, options?: { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }): void
473
- /** Synchronous-fresh position read (internal refs — safe mid-event; {-1,-1} pre-attach). / 同期・最新の位置読み (内部 ref — イベント中も安全。未接続 {-1,-1})。 */
479
+ /** Synchronous-fresh position read (internal refs — safe mid-event; after the grid unmounts, the last position). / 同期・最新の位置読み (内部 ref — イベント中も安全。グリッドのアンマウントの後は最後の位置)。 */
474
480
  getScrollPosition(): { x: number; y: number }
475
481
  /** {index, offset} anchor capture for exact restore; null when either count is 0, or the embedded scroll rows are absent (all-frozen OR a degenerate band whose H_F fills the viewport — both force itemCount 0, so there is no scroll row to anchor). / 厳密復元用アンカー捕獲。どちらかの count が 0、またはスクロール行が不在 (全行凍結 / H_F がビューポートを埋める縮退帯 — いずれも itemCount 0) なら null。 */
476
482
  getScrollAnchor(): { row: number; col: number; offsetX: number; offsetY: number } | null
@@ -480,14 +486,16 @@ export type VirtualGridHandle = {
480
486
  /** Pair-call seam: getRowHeight stays the truth. 3-way split — leading band rows bump the frozen-row epoch, trailing band rows bump the trailing-row epoch (accessor re-read; the bands hold no tree), scroll rows delegate to the row updateItemSize at −R. / 対呼び出しシーム (getRowHeight が正)。3 分岐 — 先頭帯行は凍結行 epoch、末尾帯行は末尾行 epoch の繰上げ (アクセサ再読 — 帯に木は無い)、スクロール行は −R で行 updateItemSize へ委譲。 */
481
487
  updateRowSize(row: number, px: number): void
482
488
  /**
483
- * Column twin: validates (MAX_TRACK_SIZE fail-fast), updates the width tree, and bumps the
484
- * width epoch so committed cells REPAINT even when the window key is unchanged (small grids,
485
- * offsetting batch resizes). Multi-call batch loops coalesce into one recompute via React's
486
- * automatic state batching (one commit per synchronous batch — the design's rAF-coalescing
487
- * intent realized at commit granularity).
488
- * 列双子: 検証 (fail-fast) → 幅木更新 → 幅エポックの繰上げで、窓キー不変でもコミット済み
489
- * セルを再描画する (小さなグリッド・相殺バッチリサイズ)。連続呼び出しは React の自動
490
- * バッチングで 1 コミットに合流 (設計の rAF 合流意図のコミット粒度での実現)。
489
+ * Column twin: validates (MAX_TRACK_SIZE fail-fast) and updates the width tree. A width that
490
+ * changes advances the width tree's revision, so committed cells REPAINT even when the window
491
+ * key is unchanged (small grids, offsetting batch resizes); the width the column already has
492
+ * changes nothing and re-renders nothing. Multi-call batch loops coalesce into one recompute via
493
+ * React's automatic state batching (one commit per synchronous batch — the design's
494
+ * rAF-coalescing intent realized at commit granularity).
495
+ * 列双子: 検証 (fail-fast) → 幅木の更新。幅が変われば幅木の版数が進み、窓キー不変でもコミット済み
496
+ * セルを再描画する (小さなグリッド・相殺バッチリサイズ)。列が既に持つ幅は何も変えず、何も
497
+ * 再描画しない。連続呼び出しは React の自動バッチングで 1 コミットに合流 (設計の rAF 合流意図の
498
+ * コミット粒度での実現)。
491
499
  */
492
500
  updateColSize(col: number, px: number): void
493
501
  /**
@@ -573,7 +581,7 @@ export const computeColumnPlacement = (
573
581
  // アンカー再基準化 (行 memo と同一ロジック): 描画窓先頭の絶対 X が現アンカーから距離超過の
574
582
  // ときのみ付替え。付替え先は任意座標 (グリッド量子化ではなくトリガー距離 — 付録 A.2)
575
583
  const safeStart = minmax(ranges.renderingStartIndex, 0, colCount - 1)
576
- const { cumulative, currentValue } = colTree.prefixSum(safeStart, { materializeOption: { materialize: false } })
584
+ const { cumulative, currentValue } = colTree.prefixSum(safeStart, FENWICK_LOOKUP_ONLY)
577
585
  const startPosition = cumulative - currentValue
578
586
  const colAnchor = Number.isFinite(startPosition) && Math.abs(startPosition - prevAnchor) > ANCHOR_REBASE_DISTANCE ? startPosition : prevAnchor
579
587
  return {
@@ -605,10 +613,8 @@ const EMPTY_PLACED_COLUMNS: PlacedColumn[] = []
605
613
  const collectVisibleColumns = (startCol: number, endCol: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>, maxColumns: number): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; truncated: number } => {
606
614
  const columns: PlacedColumn[] = []
607
615
  const widthUpdates: Array<{ index: number; value: number }> = []
608
- if (endCol < startCol) {
609
- return { columns, widthUpdates, truncated: 0 }
610
- }
611
- const startPrefix = colTree.prefixSum(startCol, { materializeOption: { materialize: false } })
616
+ // 呼び出し元は空窓 (start > end) を先に除くので、ここへ来る窓は 1 列以上
617
+ const startPrefix = colTree.prefixSum(startCol, FENWICK_LOOKUP_ONLY)
612
618
  // 窓先頭の絶対 left から幅を積む O(k) 走査 (列ごとの prefixSum O(k·log n) を回避 — 行 memo と同型)
613
619
  let runningLeft = startPrefix.cumulative - startPrefix.currentValue
614
620
  let zeroRun = 0
@@ -636,11 +642,11 @@ const collectVisibleColumns = (startCol: number, endCol: number, getColWidth: (c
636
642
  if (zeroRun > ZERO_HEIGHT_RUN_LIMIT) {
637
643
  // 幅 0 ランの Fenwick ジャンプ (行側と共有定数・同一比較 `>` — 複製禁止): 次の非 0 列へ。
638
644
  // +0.5 は右開区間バンプ — 木側の値で跳ぶため self-heal 前の乖離があっても有界性は保たれる
639
- const jump = colTree.findIndexAtOrAfter(runningLeft + 0.5, { materializeOption: { materialize: false } })
645
+ const jump = colTree.findIndexAtOrAfter(runningLeft + 0.5, FENWICK_LOOKUP_ONLY)
640
646
  if (jump.index === -1 || jump.index <= i || !Number.isSafeInteger(jump.index)) {
641
647
  break
642
648
  }
643
- const jumpPrefix = colTree.prefixSum(jump.index, { materializeOption: { materialize: false } })
649
+ const jumpPrefix = colTree.prefixSum(jump.index, FENWICK_LOOKUP_ONLY)
644
650
  runningLeft = jumpPrefix.cumulative - jumpPrefix.currentValue
645
651
  i = jump.index
646
652
  zeroRun = 0
@@ -849,10 +855,10 @@ const VirtualGridInner = <T,>(
849
855
  }, [resetOnGetColWidthChange, defaultColWidth])
850
856
  const colTree = useFenwickMapTree(colCount, validatedGetColWidth, colTreeOptions)
851
857
 
852
- // ---- 幅エポック (木の内容バージョン): updateColSize / self-heal 適用で繰上げ、窓キー不変でも
853
- // 配置 memo と列窓再計算を失効させる。行側は総高 (contentSize) を反応辺にするが、相殺
854
- // バッチリサイズ (総和不変) も拾えるようエポックはその厳密上位互換 ----
855
- const [widthEpoch, setWidthEpoch] = useState(0)
858
+ // ---- 列幅木の版数: 木は同一性を保ったまま描画の外で変わる (updateColSize・幅 self-heal) ので、変更はどれも changeColTree を
859
+ // 通し、木を読む memo と列窓の再計算は colTreeRevision に依存させる。総幅は相殺バッチリサイズ (総和不変) で変わらないため
860
+ // 合図にならず、同じ幅の書き込みは木の版を動かさないので再計算も確定も足さない (行側の VirtualScroll と同じ仕組み) ----
861
+ const { revision: colTreeRevision, change: changeColTree } = useFenwickTreeRevision(colTree)
856
862
 
857
863
  // ---- 凍結帯 (設計 §8) — 形状検証は fail-fast、colCount 超過だけは動的クランプ (シート
858
864
  // 切替の過渡で colCount が一時 0 になる正当構成を throw で殺さないための文書化契約) ----
@@ -973,7 +979,6 @@ const VirtualGridInner = <T,>(
973
979
  // ---- 論理位置 (float64 ref — DOM へは書かない) と鮮度序列 ----
974
980
  const hxRef = useRef(0)
975
981
  const vyRef = useRef(0)
976
- const attachedRef = useRef(false)
977
982
 
978
983
  // ---- コミット済み列窓 + アンカー (M10 整合の単位) ----
979
984
  const [columnWindow, setColumnWindow] = useState<ColumnWindowState>(INITIAL_COLUMN_WINDOW)
@@ -982,7 +987,7 @@ const VirtualGridInner = <T,>(
982
987
  /** Anchor the DOM currently shows (updated post-commit in the layout effect). / DOM が現在表示中のアンカー (commit 後の layout effect で更新)。 */
983
988
  const committedAnchorRef = useRef(0)
984
989
 
985
- const [totalWidth, setTotalWidth] = useState(() => colTree.getTotal() ?? 0)
990
+ const [totalWidth, setTotalWidth] = useState(() => colTree.getTotal())
986
991
  const totalWidthRef = useRef(totalWidth)
987
992
  totalWidthRef.current = totalWidth
988
993
  /** Mirrors the tree total into state+ref immediately (same tick — drag loops clamp fresh). / 木総幅を state + ref へ即時反映 (同 tick — ドラッグループが新鮮にクランプ)。 */
@@ -1006,36 +1011,61 @@ const VirtualGridInner = <T,>(
1006
1011
  // ---- ビューポート実測 (RO — 縦バー幅ぶんを列窓から差し引く) ----
1007
1012
  const scrollBarWidth = scrollBarOptions?.width ?? 12
1008
1013
  const explicitViewport = viewportSize === undefined ? null : { width: Math.max(0, Math.round(viewportSize.width) - scrollBarWidth), height: Math.max(0, Math.round(viewportSize.height) - scrollBarWidth) }
1009
- const [measuredViewport, setMeasuredViewport] = useState({ width: 0, height: 0 })
1010
- const viewport = explicitViewport ?? measuredViewport
1014
+ // ルートの箱はバー幅を引く前の寸法で持ち、引くのは描画の側に置く。バー幅が変わっても測り直さずに済み、計測はルートが
1015
+ // 付いている間ずっと同じ 1 つで足りる
1016
+ const [measuredRootSize, setMeasuredRootSize] = useState({ width: 0, height: 0 })
1017
+ const viewport = explicitViewport ?? { width: Math.max(0, measuredRootSize.width - scrollBarWidth), height: Math.max(0, measuredRootSize.height - scrollBarWidth) }
1011
1018
  const viewportRef = useRef(viewport)
1012
1019
  viewportRef.current = viewport
1013
1020
  /** Degenerate band: H_F + H_T fills the measured viewport — scroll rows go canonical-EMPTY (§3.3 縮退帯の縦対称; 3.5.0 で −H_T を一般化). / 縮退帯: H_F + H_T が実測ビューポートを食い尽くす — スクロール行は正準空窓 (§3.3 の縦対称。3.5.0 で −H_T を一般化)。 */
1014
1021
  const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight - trailingHeight <= 0
1015
1022
  degenerateBandRef.current = degenerateBand
1016
1023
  const hasExplicitViewport = explicitViewport !== null
1017
- useLayoutEffect(() => {
1018
- if (hasExplicitViewport) {
1019
- return
1020
- }
1021
- const root = rootRef.current
1022
- if (root === null) {
1023
- return
1024
- }
1025
- const measure = () => {
1026
- // ❗ 論理 (レイアウト) px で測る — getBoundingClientRect は祖先 transform scale 下で
1027
- // 視覚 px (論理 × z) を返し、ズームホストで窓寸・クランプ・列窓の全幾何が z 倍へ歪む。
1028
- // clientWidth / clientHeight は transform 非影響のレイアウト px (行側 ScrollPane の
1029
- // 自己計測と同じ座標系)
1030
- const width = Math.max(0, Math.round(root.clientWidth) - scrollBarWidth)
1031
- const height = Math.max(0, Math.round(root.clientHeight) - scrollBarWidth)
1032
- setMeasuredViewport((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
1033
- }
1034
- measure()
1035
- const observer = new ResizeObserver(measure)
1036
- observer.observe(root)
1037
- return () => observer.disconnect()
1038
- }, [scrollBarWidth, hasExplicitViewport])
1024
+ /**
1025
+ * Attaches the grid root: keeps `rootRef` on it and, while the viewport is self-measured, keeps `measuredRootSize` at
1026
+ * the root's layout box with one ResizeObserver for as long as the root stays attached. A ref callback with a cleanup
1027
+ * (React 19) receives the element itself, so the measurement never runs without one; the cleanup detaches the root and
1028
+ * stops the observer. Its identity changes only when the viewport mode changes.
1029
+ *
1030
+ * グリッドのルートを取り付ける処理。`rootRef` をルートに保ち、ビューポートを自分で測る間は、ルートが付いている限り 1 つの
1031
+ * ResizeObserver で `measuredRootSize` をルートのレイアウトの箱に保つ。後始末を返す ref コールバック (React 19) は要素そのものを
1032
+ * 受け取るので、要素の無いまま測ることはない。後始末はルートを外して監視を止める。同一性はビューポートの方式が変わるときだけ変わる。
1033
+ *
1034
+ * @param root - The grid root element / グリッドのルート要素
1035
+ * @returns The cleanup that detaches the root / ルートを外す後始末
1036
+ */
1037
+ const attachRoot = useCallback(
1038
+ (root: HTMLDivElement) => {
1039
+ rootRef.current = root
1040
+ if (hasExplicitViewport) {
1041
+ return () => {
1042
+ rootRef.current = null
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Reads the root's layout box into `measuredRootSize` (an unchanged box keeps the state as it is).
1047
+ *
1048
+ * ルートのレイアウトの箱を `measuredRootSize` へ読み込む処理 (箱が変わらなければ状態をそのまま保つ)。
1049
+ */
1050
+ const measure = () => {
1051
+ // ❗ 論理 (レイアウト) px で測る — getBoundingClientRect は祖先 transform scale 下で
1052
+ // 視覚 px (論理 × z) を返し、ズームホストで窓寸・クランプ・列窓の全幾何が z 倍へ歪む。
1053
+ // clientWidth / clientHeight は transform 非影響のレイアウト px (行側 ScrollPane の
1054
+ // 自己計測と同じ座標系)
1055
+ const width = Math.round(root.clientWidth)
1056
+ const height = Math.round(root.clientHeight)
1057
+ setMeasuredRootSize((previous) => (previous.width === width && previous.height === height ? previous : { width, height }))
1058
+ }
1059
+ measure()
1060
+ const observer = new ResizeObserver(measure)
1061
+ observer.observe(root)
1062
+ return () => {
1063
+ observer.disconnect()
1064
+ rootRef.current = null
1065
+ }
1066
+ },
1067
+ [hasExplicitViewport],
1068
+ )
1039
1069
 
1040
1070
  // ---- 残差 var の書き手 (1 点)。コミット済みアンカー基準 — スクロール中の中間フレームでも
1041
1071
  // セル left (コミット済みアンカー) と常に同一基準で整合する ----
@@ -1235,7 +1265,7 @@ const VirtualGridInner = <T,>(
1235
1265
  // colCount 縮小で範囲外化したアンカーは最終列へ明示クランプ (fail-safe — 木側の暗黙
1236
1266
  // クランプへ依存しない。挙動は §7 の既知の制約として記録)
1237
1267
  const safeCol = minmax(anchor.col, 0, Math.max(0, colCount - 1))
1238
- const prefix = colTree.prefixSum(safeCol, { materializeOption: { materialize: false } })
1268
+ const prefix = colTree.prefixSum(safeCol, FENWICK_LOOKUP_ONLY)
1239
1269
  const colLeft = prefix.cumulative - prefix.currentValue
1240
1270
  const colWidth = prefix.currentValue
1241
1271
  // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport − W_T) — 列座標から W_F を引き、
@@ -1249,7 +1279,7 @@ const VirtualGridInner = <T,>(
1249
1279
  )
1250
1280
 
1251
1281
  // ---- 幅 self-heal (窓内の getColWidth と木の乖離をバッチ適用 — 行 memo の toUpdateHeights 双子)。
1252
- // 適用後はエポックを繰上げ、治癒前の stale な左座標で確定したセル配置を再計算させる ----
1282
+ // 適用は木の版数を進め、治癒前の stale な左座標で確定したセル配置を再計算させる ----
1253
1283
  const pendingWidthUpdatesRef = useRef<Array<{ index: number; value: number }> | null>(null)
1254
1284
  const flushWidthUpdates = useCallback(() => {
1255
1285
  const updates = pendingWidthUpdatesRef.current
@@ -1257,17 +1287,16 @@ const VirtualGridInner = <T,>(
1257
1287
  if (updates === null || updates.length === 0) {
1258
1288
  return
1259
1289
  }
1260
- commitTotalWidth(colTree.updates(updates))
1261
- setWidthEpoch((epoch) => epoch + 1)
1262
- }, [colTree, commitTotalWidth])
1290
+ commitTotalWidth(changeColTree(() => colTree.updates(updates)))
1291
+ }, [colTree, changeColTree, commitTotalWidth])
1263
1292
 
1264
1293
  // ---- 凍結帯の列挙 (設計 §8 — 窓化されない静的帯)。幅 self-heal は共有バッチへ合流し、
1265
1294
  // 暴走ガードは帯専用カウンタ (スクロール窓側と独立 — 凍結専用振動アクセサが窓側の
1266
1295
  // 収束リセットで永久にガードを回避する穴を作らない) ----
1267
1296
  const frozenHealBurstRef = useRef({ count: 0, warned: false })
1268
1297
  const { frozenColumns, frozenWidth } = useMemo(() => {
1269
- // widthEpoch は木内容バージョンの反応辺 (スクロール窓 memo と同じ規律)
1270
- void widthEpoch
1298
+ // 列幅木の版数は木の中身の反応辺 (スクロール窓 memo と同じ規律)
1299
+ void colTreeRevision
1271
1300
  if (effectiveFrozenCols === 0) {
1272
1301
  // 凍結解除は帯ガードのエピソードも閉じる — 次の凍結エピソードが停止状態を継承して
1273
1302
  // 無言で self-heal 不能になる越境リークの遮断 (R3 実証)
@@ -1297,7 +1326,7 @@ const VirtualGridInner = <T,>(
1297
1326
  frozenHealBurstRef.current.warned = false
1298
1327
  }
1299
1328
  return { frozenColumns: columns, frozenWidth: width }
1300
- }, [effectiveFrozenCols, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1329
+ }, [effectiveFrozenCols, colTreeRevision, validatedGetColWidth, colTree, flushWidthUpdates])
1301
1330
  frozenWidthRef.current = frozenWidth
1302
1331
  // 凍結幅 var の書き手 (1 点): クリップ左端 (.aqvs-grid-row-scroll の left) と残差の −W_F 項
1303
1332
  // (.aqvs-grid-row-scroll-inner の calc) の両消費者が読む
@@ -1316,8 +1345,8 @@ const VirtualGridInner = <T,>(
1316
1345
  // 閉じる — 先頭帯の「越境リーク遮断」と同文) ----
1317
1346
  const trailingHealBurstRef = useRef({ count: 0, warned: false })
1318
1347
  const { trailingColumns, trailingWidth } = useMemo(() => {
1319
- // widthEpoch は木内容バージョンの反応辺 (先頭帯 memo と同じ規律)
1320
- void widthEpoch
1348
+ // 列幅木の版数は木の中身の反応辺 (先頭帯 memo と同じ規律)
1349
+ void colTreeRevision
1321
1350
  if (effectiveTrailingCols === 0) {
1322
1351
  // 末尾凍結解除は帯ガードのエピソードも閉じる (先頭帯の R3 実証と同文の遮断)
1323
1352
  trailingHealBurstRef.current.count = 0
@@ -1346,7 +1375,7 @@ const VirtualGridInner = <T,>(
1346
1375
  trailingHealBurstRef.current.warned = false
1347
1376
  }
1348
1377
  return { trailingColumns: columns, trailingWidth: width }
1349
- }, [effectiveTrailingCols, colCount, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1378
+ }, [effectiveTrailingCols, colCount, colTreeRevision, validatedGetColWidth, colTree, flushWidthUpdates])
1350
1379
  trailingWidthRef.current = trailingWidth
1351
1380
  // 末尾幅 var の書き手 (3.5.0 プラン §6.1-8 — **単一書き手規律**: この effect だけが書く)。
1352
1381
  // 消費者はクリップ右端 (.aqvs-grid-row-scroll の right)・末尾クリップの max() 左端と inner 幅。
@@ -1410,24 +1439,24 @@ const VirtualGridInner = <T,>(
1410
1439
  * throttled `onScroll` relay (`handleVerticalScroll`) — which regresses it by up to the
1411
1440
  * `callbackThrottleMs` window mid-drive; a driver base read from the mirror overstates
1412
1441
  * `actualDelta` by the staleness gap, drives the axis residual negative, and the driver
1413
- * then issues REAL backward `scrollBy` writes (the 3.6.0 y-axis tap yo-yo — v3.6.1).
1442
+ * then issues REAL backward `scrollBy` writes (the 3.6.0 y-axis tap yo-yo — v3.6.1). The grid
1443
+ * handle's `getScrollPosition` reads its y here too. Only once the grid has unmounted (a kept
1444
+ * handle, a tap frame between the unmount commit and the passive cleanup) is the embedded
1445
+ * handle gone; then the mirror is the position `applyVy` holds still at, and it is returned.
1414
1446
  * 埋め込みハンドル経由の鮮度 y 位置 — `applyVy` の `scrollBy` が updater を解決するのと
1415
1447
  * **同一の権威** (ペイン内部 ref)。`getMaxVy` の鮮度チャネル、および「`getHx` が `applyHx`
1416
1448
  * の書き先セルそのものを読む」x 側と対称。ここで `vyRef` を読んではならない: あの鏡像には
1417
1449
  * 第 2 の書き手 (スロットル済み `onScroll` 中継 = `handleVerticalScroll`) が居て、駆動中に
1418
1450
  * 最大 `callbackThrottleMs` 窓ぶん巻き戻す。鏡像基準の `actualDelta` は staleness ギャップ
1419
1451
  * ぶん過大になり、軸残差が負へ落ちて、ドライバ自身が**実後退**の `scrollBy` を発行する
1420
- * (3.6.0 の y 軸タップヨーヨー — v3.6.1)。
1452
+ * (3.6.0 の y 軸タップヨーヨー — v3.6.1)。グリッドのハンドルの `getScrollPosition` も y をここで読む。
1453
+ * 埋め込みハンドルが無いのはグリッドがアンマウントした後だけ (保持したハンドル・アンマウントの確定から
1454
+ * 受け身の後始末までのタップのフレーム) で、そのときは `applyVy` が留まる位置である鏡像を返す。
1421
1455
  */
1422
1456
  const getVy = useCallback(() => {
1423
1457
  const inner = scrollHandleRef.current
1424
- if (inner === null) {
1425
- // ハンドル未接続時は applyVy と同じ「動かない」姿勢 — 同じ座標 (vyRef) を基準に返す
1426
- return vyRef.current
1427
- }
1428
- const position = inner.getScrollPosition()
1429
- // ペイン未接続の番兵 (-1) も applyVy の不動姿勢と同じ座標へ落とす (負位置は実在しない)
1430
- return position < 0 ? vyRef.current : position
1458
+ // 埋め込みのペインは埋め込みのハンドルと同じ確定で付き外れするので、ハンドルがある間は未接続の番兵 (-1) が返らない
1459
+ return inner === null ? vyRef.current : inner.getScrollPosition()
1431
1460
  }, [])
1432
1461
  /**
1433
1462
  * Fresh y extent through the embedded handle — the same freshness channel the vertical bar
@@ -1444,17 +1473,19 @@ const VirtualGridInner = <T,>(
1444
1473
  }, [])
1445
1474
  /**
1446
1475
  * The y apply seam (§3.5-8): wraps the embedded `scrollBy` AND syncs `vyRef.current =
1447
- * applied` before returning — the grid handle `scrollBy` y-half twin, symmetric with
1448
- * `applyHxRef`. Without the sync, x-frame `onScroll({ x, y: vyRef.current })` notifications
1449
- * during a diagonal drag carry y stale by up to the `callbackThrottleMs` window (MV-XY29).
1476
+ * applied` before returning — the y half of the grid handle's `scrollBy` as well, symmetric
1477
+ * with `applyHxRef`. Without the sync, x-frame `onScroll({ x, y: vyRef.current })`
1478
+ * notifications during a diagonal drag carry y stale by up to the `callbackThrottleMs` window
1479
+ * (MV-XY29). Without the embedded handle (only after the grid unmounted) nothing moves and the
1480
+ * current mirror is returned.
1450
1481
  * y 適用シーム (§3.5-8): 埋め込み `scrollBy` をラップし、**返す前に** `vyRef.current =
1451
- * applied` を同期する — グリッドハンドル `scrollBy` の y 半分の双子で `applyHxRef` と対称。
1452
- * 同期が無いと対角ドラッグ中の x フレーム通知が stale y を運ぶ (MV-XY29)。
1482
+ * applied` を同期する — グリッドハンドルの `scrollBy` の y 半分もこれを通り、`applyHxRef` と対称。
1483
+ * 同期が無いと対角ドラッグ中の x フレーム通知が stale y を運ぶ (MV-XY29)。埋め込みハンドルが無い
1484
+ * (グリッドのアンマウントの後だけ) ときは何も動かさず、今の鏡像を返す。
1453
1485
  */
1454
1486
  const applyVy = useCallback((delta: number): number => {
1455
1487
  const inner = scrollHandleRef.current
1456
1488
  if (inner === null) {
1457
- // ハンドル未接続時は現在値を返して動かない (グリッドハンドル scrollBy と同じ姿勢)
1458
1489
  return vyRef.current
1459
1490
  }
1460
1491
  const applied = inner.scrollBy(delta)
@@ -1490,7 +1521,7 @@ const VirtualGridInner = <T,>(
1490
1521
  return minmax(baseOpacity * resolvedTapX.opacity, 0, 1)
1491
1522
  }, [isTapActive, resolvedTapX.opacity])
1492
1523
 
1493
- // ---- ビューポート / 総幅 / 幅エポック変化での再クランプ + 列窓追随。保留列アンカーが
1524
+ // ---- ビューポート / 総幅 / 列幅木の版数の変化での再クランプ + 列窓追随。保留列アンカーが
1494
1525
  // あれば位置はアンカーから再導出 (単なる再クランプでは推定空間の hx が残留する) ----
1495
1526
  useLayoutEffect(() => {
1496
1527
  const pending = pendingColAnchorRef.current
@@ -1500,7 +1531,7 @@ const VirtualGridInner = <T,>(
1500
1531
  applyHxRef.current(resolveColumnAnchorHx(pending))
1501
1532
  }
1502
1533
  refreshColumns()
1503
- }, [viewport.width, totalWidth, widthEpoch, frozenWidth, trailingWidth, refreshColumns, resolveColumnAnchorHx])
1534
+ }, [viewport.width, totalWidth, colTreeRevision, frozenWidth, trailingWidth, refreshColumns, resolveColumnAnchorHx])
1504
1535
 
1505
1536
  // ---- 初期アンカー復元 (横) — 生 px でなく {col, offsetX} (行と同一契約)。マウント時 1 回。
1506
1537
  // 保留列アンカーとして張り、以後の self-heal による列左の実測化へ追随させる ----
@@ -1528,7 +1559,6 @@ const VirtualGridInner = <T,>(
1528
1559
  // ---- 縦軸の結線 ----
1529
1560
  const handleVerticalScroll = useCallback((position: number) => {
1530
1561
  vyRef.current = position
1531
- attachedRef.current = true
1532
1562
  onScrollRef.current?.({ x: hxRef.current, y: position })
1533
1563
  }, [])
1534
1564
  const handleVerticalRangeChange = useCallback(
@@ -1573,14 +1603,14 @@ const VirtualGridInner = <T,>(
1573
1603
  // (行凍結設計 §4-7、3.5.0 プラン §6.1-13)
1574
1604
  const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length + trailingRows.length)
1575
1605
  // self-heal の暴走ガード: 乖離→治癒→再乖離の非収束が連続する = getColWidth が自身の
1576
- // 過去値と不一致を繰り返す契約違反 (振動アクセサ)。放置すると治癒→エポック→再走査の
1606
+ // 過去値と不一致を繰り返す契約違反 (振動アクセサ)。放置すると治癒→版数→再走査の
1577
1607
  // マイクロタスクループが無限旋回するため、上限で治癒を停止し 1 回だけ警告する。
1578
1608
  // 状態は {count, warned} のみ — 窓キーは保持しない (キー基準の解消は窓端を動かす振動に
1579
1609
  // 永久回避されるため構造的に持たない。解消は収束パスのみ)
1580
1610
  const healBurstRef = useRef({ count: 0, warned: false })
1581
1611
  const { placedColumns, truncatedCols } = useMemo(() => {
1582
- // widthEpoch は木の内容バージョンの反応辺 (木は identity 安定のため値経由では失効しない)
1583
- void widthEpoch
1612
+ // 列幅木の版数は木の中身の反応辺 (木は同一性を保つので、木の参照では失効しない)
1613
+ void colTreeRevision
1584
1614
  // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F + W_T ≥ viewport の縮退 — 3.5.0 一般化) は
1585
1615
  // スクロール列を描かない — 凍結帯 / 末尾帯自体は独立 memo が描き続ける定義済み縮退
1586
1616
  const bandWidth = Math.max(0, viewport.width - frozenWidth - trailingWidth)
@@ -1623,7 +1653,7 @@ const VirtualGridInner = <T,>(
1623
1653
  // deps へ trailingWidth / trailingColumns.length を明示追加 (3.5.0 プラン §6.1-6 / F4):
1624
1654
  // bandWidth 式とスクロール列予算が同 memo 内で両値を読む — 追加を怠ると列窓キー不変の
1625
1655
  // T トグルで memo が stale 化し、予算が trailing 分だけ過大に残る
1626
- }, [columnWindow, colCount, viewport.width, frozenWidth, trailingWidth, frozenColumns.length, trailingColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1656
+ }, [columnWindow, colCount, viewport.width, frozenWidth, trailingWidth, frozenColumns.length, trailingColumns.length, colTreeRevision, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1627
1657
 
1628
1658
  // 打切りは無言にしない (設計 §3.2 — 行側の病的ガードと違い正当構成で到達し得る)
1629
1659
  const lastTruncationKeyRef = useRef("")
@@ -1715,11 +1745,14 @@ const VirtualGridInner = <T,>(
1715
1745
  // ---- liveRegion (2D — 文言は消費側所有、静定デバウンス) ----
1716
1746
  const [liveMessage, setLiveMessage] = useState("")
1717
1747
  const liveTimerRef = useRef<number | null>(null)
1748
+ // 埋め込みの一覧はアンマウントで待っていた範囲の通知を同期で配る (取りこぼさないため)。それはグリッド自身の後始末の後に
1749
+ // 届くので、アンマウントの後は読み上げを予約しない (予約すると消費側の buildMessage がアンマウントの後に走る)
1750
+ const isLiveRegionClosedRef = useRef(false)
1718
1751
  const liveRegionRef = useRef(liveRegion)
1719
1752
  liveRegionRef.current = liveRegion
1720
1753
  const scheduleLiveMessage = useCallback(() => {
1721
1754
  const options = liveRegionRef.current
1722
- if (options === undefined) {
1755
+ if (options === undefined || isLiveRegionClosedRef.current) {
1723
1756
  return
1724
1757
  }
1725
1758
  // 不正な debounceMs は黙って既定へ読み替えず読み上げを無効化する (警告は下の effect が 1 回だけ出す)
@@ -1734,10 +1767,8 @@ const VirtualGridInner = <T,>(
1734
1767
  liveTimerRef.current = null
1735
1768
  const range = buildRange()
1736
1769
  if (range !== null) {
1737
- setLiveMessage((prev) => {
1738
- const next = options.buildMessage(range)
1739
- return next === prev ? prev : next
1740
- })
1770
+ // 同じ文言の再設定は React が Object.is で捨てる (再描画しない) ので、比べずに書く (行側のライブリージョンと同じ)
1771
+ setLiveMessage(options.buildMessage(range))
1741
1772
  }
1742
1773
  }, debounce)
1743
1774
  }, [buildRange])
@@ -1750,14 +1781,16 @@ const VirtualGridInner = <T,>(
1750
1781
  Logger.warn(`[VirtualGrid] liveRegion.debounceMs must be a finite number >= 0, received ${liveDebounceMs}. Announcements are disabled.`)
1751
1782
  }
1752
1783
  }, [hasLiveRegion, liveDebounceValid, liveDebounceMs])
1753
- useEffect(
1754
- () => () => {
1784
+ useEffect(() => {
1785
+ // StrictMode の二重マウント (後始末の後に再び effect) でも読み上げを再開できるよう、本体で開き直す
1786
+ isLiveRegionClosedRef.current = false
1787
+ return () => {
1788
+ isLiveRegionClosedRef.current = true
1755
1789
  if (liveTimerRef.current !== null) {
1756
1790
  window.clearTimeout(liveTimerRef.current)
1757
1791
  }
1758
- },
1759
- [],
1760
- )
1792
+ }
1793
+ }, [])
1761
1794
  useEffect(() => {
1762
1795
  scheduleLiveMessage()
1763
1796
  }, [columnWindow, scheduleLiveMessage])
@@ -1790,12 +1823,8 @@ const VirtualGridInner = <T,>(
1790
1823
  return { x, y }
1791
1824
  },
1792
1825
  scrollBy: (delta) => {
1793
- const inner = scrollHandleRef.current
1794
- let y = vyRef.current
1795
- if (delta.y !== undefined && delta.y !== 0 && inner !== null) {
1796
- y = inner.scrollBy(delta.y)
1797
- vyRef.current = y
1798
- }
1826
+ // 縦の差分はタップの駆動と同じ継ぎ目 (applyVy) で当て、適用と鏡像の同期を 2 通りに書かない
1827
+ const y = delta.y !== undefined && delta.y !== 0 ? applyVy(delta.y) : vyRef.current
1799
1828
  let x = hxRef.current
1800
1829
  if (delta.x !== undefined && delta.x !== 0) {
1801
1830
  pendingColAnchorRef.current = null
@@ -1827,7 +1856,7 @@ const VirtualGridInner = <T,>(
1827
1856
  }
1828
1857
  }
1829
1858
  },
1830
- getScrollPosition: () => (attachedRef.current || scrollHandleRef.current !== null ? { x: hxRef.current, y: scrollHandleRef.current?.getScrollPosition() ?? vyRef.current } : { x: -1, y: -1 }),
1859
+ getScrollPosition: () => ({ x: hxRef.current, y: getVy() }),
1831
1860
  getScrollAnchor: () => {
1832
1861
  if (rowCount === 0 || colCount === 0) {
1833
1862
  return null
@@ -1842,16 +1871,17 @@ const VirtualGridInner = <T,>(
1842
1871
  // 凍結帯 (§8): アンカーはスクロール帯の可視先頭 = 木座標 W_F + hx で捕獲する
1843
1872
  // (復元側 resolveColumnAnchorHx が W_F を引き戻すため往復が恒等)
1844
1873
  const bandStart = frozenWidthRef.current + hxRef.current
1845
- const found = colTree.findIndexAtOrAfter(bandStart, { materializeOption: { materialize: false } })
1874
+ const found = colTree.findIndexAtOrAfter(bandStart, FENWICK_LOOKUP_ONLY)
1846
1875
  if (found.index === -1) {
1847
1876
  return { row: rowAnchor.index + effectiveFrozenRows, col: colCount - 1, offsetX: 0, offsetY: rowAnchor.offsetPx }
1848
1877
  }
1849
1878
  if (found.cumulative === bandStart) {
1850
1879
  return { row: rowAnchor.index + effectiveFrozenRows, col: minmax(found.index + 1, 0, colCount - 1), offsetX: 0, offsetY: rowAnchor.offsetPx }
1851
1880
  }
1881
+ // 見つけた列の左端は木から読み直す (探索の結果の型は列が無い場合の undefined を含むため)
1882
+ const column = colTree.prefixSum(found.index, FENWICK_LOOKUP_ONLY)
1852
1883
  // 行はシフト空間 → フル行空間へ +R 翻訳 (行凍結設計 §3 — 復元側は −R で往復恒等)
1853
- const colLeft = (found.cumulative ?? 0) - (found.currentValue ?? 0)
1854
- return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - colLeft), offsetY: rowAnchor.offsetPx }
1884
+ return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - (column.cumulative - column.currentValue)), offsetY: rowAnchor.offsetPx }
1855
1885
  },
1856
1886
  getViewportSize: () => ({ width: viewportRef.current.width, height: viewportRef.current.height }),
1857
1887
  // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)。
@@ -1873,10 +1903,9 @@ const VirtualGridInner = <T,>(
1873
1903
  },
1874
1904
  updateColSize: (col, px) => {
1875
1905
  validateTrackSize(px, "col", col)
1876
- // 総幅は ref へも即時反映 (同 tick の scrollBy/scrollTo が旧上限でクランプしないため)
1877
- commitTotalWidth(colTree.update(minmax(Math.trunc(col), 0, Math.max(0, colCount - 1)), px))
1878
- // 窓キー不変の幅変更でもセル配置を失効させる (バッチループは React 自動バッチングで 1 コミット)
1879
- setWidthEpoch((epoch) => epoch + 1)
1906
+ // 総幅は ref へも即時反映 (同 tick の scrollBy/scrollTo が旧上限でクランプしないため)。幅が変われば木の版数が進み、
1907
+ // 窓キー不変でもセル配置を失効させる (バッチループは React 自動バッチングで 1 コミット)。同じ幅は何も失効させない
1908
+ commitTotalWidth(changeColTree(() => colTree.update(minmax(Math.trunc(col), 0, Math.max(0, colCount - 1)), px)))
1880
1909
  },
1881
1910
  getFrozenSize: () => ({
1882
1911
  cols: effectiveFrozenCols,
@@ -1905,11 +1934,11 @@ const VirtualGridInner = <T,>(
1905
1934
  }
1906
1935
  },
1907
1936
  }),
1908
- [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows],
1937
+ [rowCount, colCount, colTree, changeColTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, getVy, applyVy, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows],
1909
1938
  )
1910
1939
 
1911
1940
  return (
1912
- <div ref={rootRef} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", effectiveTrailingRows > 0 ? "aqvs-grid-has-trailing-rows" : "", className)} data-testid={testId}>
1941
+ <div ref={attachRoot} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", effectiveTrailingRows > 0 ? "aqvs-grid-has-trailing-rows" : "", className)} data-testid={testId}>
1913
1942
  {effectiveFrozenRows > 0 ? (
1914
1943
  // 凍結行帯 (行凍結設計 §4-2): グリッド所有の帯外バンド — 行本体は renderRow を
1915
1944
  // そのまま再利用する (F > 0 の凍結セル / クリップ / hx 残差 var は継承で無償追随
@@ -2003,7 +2032,7 @@ const VirtualGridInner = <T,>(
2003
2032
  onScroll={(request, previous) => {
2004
2033
  // バー操作は手動横入力 — 列アンカーの張りを解除する
2005
2034
  pendingColAnchorRef.current = null
2006
- return applyHxRef.current(typeof request === "function" ? request(previous ?? hxRef.current) : request)
2035
+ return applyHxRef.current(typeof request === "function" ? request(previous) : request)
2007
2036
  }}
2008
2037
  // バー固有の設定は縦バー (内包 VirtualScroll へ scrollBarOptions を全量透過) と同じ値を
2009
2038
  // 横バーへも渡す — 片方のバーにだけ効く設定面を作らない