@aiquants/virtualscroll 1.18.5 → 1.19.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.
Files changed (40) hide show
  1. package/README.md +20 -13
  2. package/dist/ScrollPane.d.cts +2 -0
  3. package/dist/ScrollPane.d.ts +2 -0
  4. package/dist/ScrollPane.d.ts.map +1 -1
  5. package/dist/VirtualScroll.d.cts +2 -0
  6. package/dist/VirtualScroll.d.ts +2 -0
  7. package/dist/VirtualScroll.d.ts.map +1 -1
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.cts +6 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1115 -1112
  13. package/dist/styles/virtualscroll.css +1 -1
  14. package/dist/styles/virtualscroll.standalone.css +3 -0
  15. package/package.json +6 -4
  16. package/src/ScrollBar.spec.tsx +620 -0
  17. package/src/ScrollBar.tsx +1397 -0
  18. package/src/ScrollPane.spec.tsx +482 -0
  19. package/src/ScrollPane.tsx +913 -0
  20. package/src/TapScrollCircle.spec.tsx +275 -0
  21. package/src/TapScrollCircle.tsx +363 -0
  22. package/src/VirtualScroll.spec.ts +623 -0
  23. package/src/VirtualScroll.tsx +1891 -0
  24. package/src/cli.server.spec.ts +137 -0
  25. package/src/cli.server.ts +110 -0
  26. package/src/index.ts +24 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +7 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +298 -0
  32. package/src/tapScrollCircleSampleVisual.tsx +74 -0
  33. package/src/useFenwickMapTree.huge.spec.ts +388 -0
  34. package/src/useFenwickMapTree.spec.ts +1518 -0
  35. package/src/useFenwickMapTree.ts +1368 -0
  36. package/src/useHeightCache.ts +32 -0
  37. package/src/useLruCache.spec.ts +382 -0
  38. package/src/useLruCache.ts +301 -0
  39. package/src/utils.spec.ts +39 -0
  40. package/src/utils.ts +16 -0
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Provides regression tests for VirtualScroll range calculations.
3
+ *
4
+ * VirtualScroll のレンジ計算に関する回帰テストを提供。
5
+ */
6
+ import { act, render } from "@testing-library/react"
7
+ import React from "react"
8
+ import { createRoot } from "react-dom/client"
9
+ import { describe, expect, it, vi } from "vitest"
10
+ import { FenwickMapTree } from "./useFenwickMapTree.ts"
11
+ import { computeRenderingRanges, VirtualScroll, type VirtualScrollHandle } from "./VirtualScroll.tsx"
12
+
13
+ /**
14
+ * Produces a uniform height resolver for testing.
15
+ *
16
+ * テスト用に一定の高さを返却する関数を生成。
17
+ */
18
+ const createUniformHeightGetter = (height: number) => (_index: number) => height
19
+ // const LARGE_ITEM_COUNT = 9_007_199_254_740_991
20
+ const LARGE_ITEM_COUNT = Number.MAX_SAFE_INTEGER
21
+ // const LARGE_ITEM_COUNT = 1_0_000_000_000_000_000
22
+ // const LARGE_ITEM_COUNT = 1_000_000_000_000_000_000
23
+
24
+ /**
25
+ * Ensures the range computation clamps scroll offsets that exceed total height.
26
+ *
27
+ * レンジ計算が合計高さを超えるスクロールオフセットを抑制することを確認。
28
+ */
29
+ const clampScenario = () => {
30
+ const itemCount = 10
31
+ const itemHeight = 30
32
+ const viewportSize = 120
33
+ const overscanCount = 0
34
+ const fenwick = new FenwickMapTree(itemCount, itemHeight, { materialize: true })
35
+ const getter = createUniformHeightGetter(itemHeight)
36
+ const ranges = computeRenderingRanges(9999, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
37
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
38
+ expect(ranges.visibleEndIndex).toBe(itemCount - 1)
39
+ expect(ranges.visibleStartIndex).toBeGreaterThanOrEqual(itemCount - expectedVisibleCount)
40
+ expect(ranges.renderingEndIndex).toBe(itemCount - 1)
41
+ }
42
+
43
+ /**
44
+ * Custom Fenwick tree that always yields a missing index to force fallback.
45
+ *
46
+ * フォールバック動作を強制するために常にインデックス未発見を返す Fenwick 木。
47
+ */
48
+ class ForceFallbackFenwick extends FenwickMapTree {
49
+ /**
50
+ * Always reports an unfound index to exercise fallback handling.
51
+ *
52
+ * フォールバック処理を動作させるため常に未発見インデックスを返却。
53
+ */
54
+ findIndexAtOrAfter(..._args: Parameters<FenwickMapTree["findIndexAtOrAfter"]>): { index: number; total: number | undefined; cumulative: number | undefined; currentValue: number | undefined; safeIndex: number | undefined } {
55
+ return { index: -1, total: this.getTotal(), cumulative: undefined, currentValue: undefined, safeIndex: undefined }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Confirms that the fallback snaps ranges to the tail when no index is located.
61
+ *
62
+ * インデックスが見つからない場合にレンジが末尾へ寄ることを確認。
63
+ */
64
+ const fallbackScenario = () => {
65
+ const itemCount = 6
66
+ const itemHeight = 40
67
+ const viewportSize = 160
68
+ const overscanCount = 2
69
+ const fenwick = new ForceFallbackFenwick(itemCount, itemHeight, { materialize: true })
70
+ const getter = createUniformHeightGetter(itemHeight)
71
+ const ranges = computeRenderingRanges(500, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
72
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
73
+ const expectedVisibleStart = Math.max(0, itemCount - expectedVisibleCount)
74
+ expect(ranges.visibleStartIndex).toBe(expectedVisibleStart)
75
+ expect(ranges.visibleEndIndex).toBe(itemCount - 1)
76
+ expect(ranges.renderingStartIndex).toBe(Math.max(0, expectedVisibleStart - overscanCount))
77
+ }
78
+
79
+ /**
80
+ * Ensures clamp logic remains efficient with extremely large datasets.
81
+ *
82
+ * 超大規模データセットでもクランプ処理が有効であることを確認。
83
+ */
84
+ const clampScenarioLarge = () => {
85
+ const itemCount = LARGE_ITEM_COUNT
86
+ const itemHeight = 30
87
+ const viewportSize = 240
88
+ const overscanCount = 1
89
+ const fenwick = new FenwickMapTree(itemCount, itemHeight)
90
+ const getter = createUniformHeightGetter(itemHeight)
91
+ const overscrollPosition = fenwick.getTotal() * 1.01
92
+ const ranges = computeRenderingRanges(overscrollPosition, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
93
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
94
+ const expectedVisibleStart = Math.max(0, itemCount - expectedVisibleCount)
95
+ expect(ranges.visibleEndIndex).toBe(itemCount - 1)
96
+ expect(ranges.visibleStartIndex).toBe(expectedVisibleStart)
97
+ expect(ranges.renderingEndIndex).toBe(itemCount - 1)
98
+ expect(ranges.renderingStartIndex).toBe(Math.max(0, expectedVisibleStart - overscanCount))
99
+ }
100
+
101
+ /**
102
+ * Confirms fallback remains tail-aligned for massive inputs.
103
+ *
104
+ * 超大規模データでもフォールバックが末尾寄せのままであることを確認。
105
+ */
106
+ const fallbackScenarioLarge = () => {
107
+ const itemCount = LARGE_ITEM_COUNT
108
+ const itemHeight = 40
109
+ const viewportSize = 200
110
+ const overscanCount = 3
111
+ const fenwick = new ForceFallbackFenwick(itemCount, itemHeight)
112
+ const getter = createUniformHeightGetter(itemHeight)
113
+ const ranges = computeRenderingRanges(Number.MAX_SAFE_INTEGER, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
114
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
115
+ const expectedVisibleStart = Math.max(0, itemCount - expectedVisibleCount)
116
+ expect(ranges.visibleStartIndex).toBe(expectedVisibleStart)
117
+ expect(ranges.visibleEndIndex).toBe(itemCount - 1)
118
+ expect(ranges.renderingStartIndex).toBe(Math.max(0, expectedVisibleStart - overscanCount))
119
+ }
120
+
121
+ /**
122
+ * Validates head clamp with extremely large collections.
123
+ *
124
+ * 超大規模データで下限クランプが先頭位置を維持することを確認。
125
+ */
126
+ const underflowScenarioLarge = () => {
127
+ const itemCount = LARGE_ITEM_COUNT
128
+ const itemHeight = 25
129
+ const viewportSize = 125
130
+ const overscanCount = 4
131
+ const fenwick = new FenwickMapTree(itemCount, itemHeight)
132
+ const getter = createUniformHeightGetter(itemHeight)
133
+ const ranges = computeRenderingRanges(-Number.MAX_SAFE_INTEGER, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
134
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
135
+ const expectedVisibleEnd = Math.min(itemCount - 1, expectedVisibleCount - 1)
136
+ expect(ranges.visibleStartIndex).toBe(0)
137
+ expect(ranges.renderingStartIndex).toBe(0)
138
+ expect(ranges.visibleEndIndex).toBe(expectedVisibleEnd)
139
+ expect(ranges.renderingEndIndex).toBe(Math.min(itemCount - 1, expectedVisibleEnd + overscanCount))
140
+ }
141
+
142
+ /**
143
+ * Verifies that underflowing scroll offsets clamp to the head range.
144
+ *
145
+ * スクロールオフセットの下限越えが先頭レンジに丸められることを確認。
146
+ */
147
+ const underflowScenario = () => {
148
+ const itemCount = 8
149
+ const itemHeight = 25
150
+ const viewportSize = 100
151
+ const overscanCount = 3
152
+ const fenwick = new FenwickMapTree(itemCount, itemHeight, { materialize: true })
153
+ const getter = createUniformHeightGetter(itemHeight)
154
+ const ranges = computeRenderingRanges(-250, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
155
+ const expectedVisibleCount = Math.ceil(viewportSize / itemHeight)
156
+ const expectedVisibleEnd = Math.min(itemCount - 1, expectedVisibleCount - 1)
157
+ expect(ranges.visibleStartIndex).toBe(0)
158
+ expect(ranges.renderingStartIndex).toBe(0)
159
+ expect(ranges.visibleEndIndex).toBe(expectedVisibleEnd)
160
+ expect(ranges.renderingEndIndex).toBe(Math.min(itemCount - 1, expectedVisibleEnd + overscanCount))
161
+ }
162
+
163
+ /**
164
+ * Ensures partially visible items at the bottom are included in the visible range.
165
+ *
166
+ * 下部で部分的に見えているアイテムが可視範囲に含まれることを確認。
167
+ */
168
+ const partialVisibilityScenario = () => {
169
+ const itemCount = 10
170
+ const itemHeight = 100
171
+ const viewportSize = 300
172
+ const overscanCount = 0
173
+ const scrollPosition = 150
174
+
175
+ const fenwick = new FenwickMapTree(itemCount, itemHeight, { materialize: true })
176
+ const getter = (_index: number) => itemHeight
177
+
178
+ const ranges = computeRenderingRanges(scrollPosition, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
179
+
180
+ // Index 0: 0-100
181
+ // Index 1: 100-200 (visible from 150-200, height 50)
182
+ // Index 2: 200-300 (visible full, height 100)
183
+ // Index 3: 300-400 (visible full, height 100)
184
+ // Index 4: 400-500 (visible from 400-450, height 50)
185
+
186
+ // Total visible height needed: 300
187
+ // 50 (idx 1) + 100 (idx 2) + 100 (idx 3) + 50 (idx 4) = 300
188
+
189
+ expect(ranges.visibleStartIndex).toBe(1)
190
+ expect(ranges.visibleEndIndex).toBe(4)
191
+ }
192
+
193
+ describe("computeRenderingRanges", () => {
194
+ it("スクロール上限を超えた場合でも末尾レンジを維持する", () => {
195
+ clampScenario()
196
+ })
197
+
198
+ it("Fenwick が未発見を返しても末尾にフォールバックする", () => {
199
+ fallbackScenario()
200
+ })
201
+
202
+ it("スクロール下限を下回っても先頭レンジを維持する", () => {
203
+ underflowScenario()
204
+ })
205
+
206
+ it("超大規模データでも上限クランプが高速に動作する", () => {
207
+ clampScenarioLarge()
208
+ })
209
+
210
+ it("超大規模データでもフォールバックが末尾へ寄る", () => {
211
+ fallbackScenarioLarge()
212
+ })
213
+
214
+ it("超大規模データでも下限クランプが先頭レンジを保つ", () => {
215
+ underflowScenarioLarge()
216
+ })
217
+
218
+ it("部分的に見えているアイテムも可視範囲に含める", () => {
219
+ partialVisibilityScenario()
220
+ })
221
+
222
+ /**
223
+ * Ensures a long run of zero-height (collapsed) rows does not force a full forward scan.
224
+ *
225
+ * 高さ0(折りたたみ)の連続行があっても前方走査が全件に膨らまないことを確認。
226
+ */
227
+ it("高さ0の折りたたみ行が連続してもビューポート先頭以降を全件走査しない", () => {
228
+ const itemCount = 100_000
229
+ const viewportSize = 200
230
+ const overscanCount = 2
231
+ // 木は全行 40px と推定 (getter と不一致) のままにして、ジャンプが前進できないケースを再現する。
232
+ const fenwick = new FenwickMapTree(itemCount, 40, { materialize: true })
233
+ // index 5..89999 が折りたたみ(高さ0)、それ以外は 40px。
234
+ const getter = (index: number) => (index < 5 ? 40 : index < 90_000 ? 0 : 40)
235
+ const ranges = computeRenderingRanges(200, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
236
+ // ジャンプ不能 (木と getter の不一致) の場合でも、連続 0 行の閾値近傍で有界に打ち切られる。
237
+ // ガードが無いと 90,000 件近くまで走査してしまう。
238
+ expect(ranges.renderingEndIndex).toBeLessThan(2000)
239
+ })
240
+
241
+ /**
242
+ * Ensures sporadic zero-height rows inside the viewport do not truncate the forward scan.
243
+ *
244
+ * 可視域途中の散発的な高さ0行で前方走査が打ち切られないことを確認 (描画不足回帰の再発防止)。
245
+ */
246
+ it("可視域途中の高さ0行があっても以降の可視行が描画される", () => {
247
+ const itemCount = 100
248
+ const viewportSize = 300
249
+ const overscanCount = 2
250
+ const getter = (index: number) => (index === 5 ? 0 : 30)
251
+ const fenwick = new FenwickMapTree(itemCount, getter, { materialize: true, sampleRange: { from: 0, to: itemCount - 1 } })
252
+ const ranges = computeRenderingRanges(0, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
253
+ // 行 0-4 (150px) + 行 5 (0px) + 行 6-10 (150px) で 300px を充足する。
254
+ expect(ranges.visibleStartIndex).toBe(0)
255
+ expect(ranges.visibleEndIndex).toBe(10)
256
+ })
257
+
258
+ /**
259
+ * Ensures the backward fill continues across a zero-height row when scrolled to the tail.
260
+ *
261
+ * 末尾スクロール時の後方充填が高さ0行を跨いで継続することを確認 (ビューポート空白化回帰の再発防止)。
262
+ */
263
+ it("末尾スクロール時の後方充填が高さ0行を跨いで継続する", () => {
264
+ const itemCount = 20
265
+ const viewportSize = 300
266
+ const overscanCount = 0
267
+ const getter = (index: number) => (index === 15 ? 0 : 30)
268
+ const fenwick = new FenwickMapTree(itemCount, getter, { materialize: true, sampleRange: { from: 0, to: itemCount - 1 } })
269
+ // 総高さ 570px の最下部へスクロール。
270
+ const ranges = computeRenderingRanges(570, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
271
+ expect(ranges.visibleEndIndex).toBe(19)
272
+ // 高さ0の行15を跨いで行9まで埋め戻される (打ち切られると行15のみ=被覆0pxになる)。
273
+ expect(ranges.visibleStartIndex).toBe(9)
274
+ })
275
+
276
+ /**
277
+ * Ensures a huge contiguous run of zero-height rows is skipped via an O(log n) Fenwick jump.
278
+ *
279
+ * 大量連続の高さ0行を Fenwick 木の O(log n) ジャンプで飛び越えて可視域を充足することを確認。
280
+ */
281
+ it("大量連続の高さ0行は次の非0行へジャンプして可視域を充足する", () => {
282
+ const itemCount = 100_000
283
+ const viewportSize = 200
284
+ const overscanCount = 2
285
+ const getter = (index: number) => (index < 5 ? 40 : index < 90_000 ? 0 : 40)
286
+ // 木にも実際の高さを与える (ジャンプは木の累積和に基づくため)。
287
+ const fenwick = new FenwickMapTree(itemCount, getter, { materialize: true, sampleRange: { from: 0, to: itemCount - 1 } })
288
+ const ranges = computeRenderingRanges(200, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
289
+ // 行 5..89999 (高さ0) を飛び越え、行 90000..90004 (200px) で可視域を充足する。
290
+ expect(ranges.visibleStartIndex).toBe(5)
291
+ expect(ranges.visibleEndIndex).toBe(90_004)
292
+ })
293
+
294
+ /**
295
+ * Ensures the backward fill also jumps across a huge zero-height run via the Fenwick tree.
296
+ *
297
+ * 後方充填でも大量連続の高さ0行を Fenwick 木のジャンプで飛び越えることを確認。
298
+ */
299
+ it("後方充填でも大量連続の高さ0行を飛び越えて埋め戻す", () => {
300
+ const itemCount = 2010
301
+ const viewportSize = 300
302
+ const overscanCount = 0
303
+ // 行 0..4 と行 2005..2009 が 40px、その間の 2000 行が高さ0。総高さ 400px。
304
+ const getter = (index: number) => (index < 5 || index >= 2005 ? 40 : 0)
305
+ const fenwick = new FenwickMapTree(itemCount, getter, { materialize: true, sampleRange: { from: 0, to: itemCount - 1 } })
306
+ const ranges = computeRenderingRanges(400, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
307
+ expect(ranges.visibleEndIndex).toBe(2009)
308
+ // 0 行の連続 (5..2004) を後方ジャンプで跨ぎ、行 2 まで埋め戻される。
309
+ expect(ranges.visibleStartIndex).toBe(2)
310
+ })
311
+
312
+ /**
313
+ * Ensures the huge (bigint) path also continues across sporadic zero-height rows.
314
+ *
315
+ * 巨大サイズ (bigint) パスでも散発的な高さ0行を跨いで前方走査が継続することを確認。
316
+ */
317
+ it("超大規模データでも散発的な高さ0行を跨いで前方走査する", () => {
318
+ const itemCount = LARGE_ITEM_COUNT
319
+ const viewportSize = 120
320
+ const overscanCount = 0
321
+ const getter = (index: number) => (index === 2 ? 0 : 30)
322
+ const fenwick = new FenwickMapTree(itemCount, getter)
323
+ const ranges = computeRenderingRanges(0, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
324
+ expect(ranges.visibleStartIndex).toBe(0)
325
+ // 行 0,1,3,4 (計 120px) + 高さ0の行2 で充足する (行2で打ち切られない)。
326
+ expect(ranges.visibleEndIndex).toBe(4)
327
+ })
328
+
329
+ /**
330
+ * Ensures the huge (bigint) path backward fill continues across a zero-height row at the tail.
331
+ *
332
+ * 巨大サイズ (bigint) パスの後方充填が末尾付近の高さ0行を跨いで継続することを確認。
333
+ */
334
+ it("超大規模データでも末尾付近の高さ0行を跨いで後方充填する", () => {
335
+ const itemCount = LARGE_ITEM_COUNT
336
+ const viewportSize = 120
337
+ const overscanCount = 0
338
+ const getter = (index: number) => (index === itemCount - 3 ? 0 : 30)
339
+ const fenwick = new FenwickMapTree(itemCount, getter)
340
+ const ranges = computeRenderingRanges(fenwick.getTotal() * 1.01, viewportSize, overscanCount, itemCount, getter, fenwick, fenwick.getTotal())
341
+ expect(ranges.visibleEndIndex).toBe(itemCount - 1)
342
+ // 高さ0の行 (itemCount-3) を跨いで行 itemCount-5 まで埋め戻される。
343
+ expect(ranges.visibleStartIndex).toBe(itemCount - 5)
344
+ })
345
+ })
346
+
347
+ /**
348
+ * Regression tests for VirtualScroll DOM behaviors (empty list & coordinate system).
349
+ *
350
+ * VirtualScroll の DOM 挙動(空リスト・座標系)に関する回帰テスト。
351
+ */
352
+ describe("VirtualScroll component", () => {
353
+ it("itemCount=0 でも scrollToIndex/scrollTo/updateItemSize/focusItemAtIndex が例外を投げない", () => {
354
+ const ref = React.createRef<VirtualScrollHandle>()
355
+ render(
356
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
357
+ ref,
358
+ itemCount: 0,
359
+ getItem: (index: number) => `Item ${index}`,
360
+ getItemHeight: () => 40,
361
+ viewportSize: 200,
362
+ children: (item: string) => React.createElement("div", null, item),
363
+ }),
364
+ )
365
+ const handle = ref.current
366
+ expect(handle).not.toBeNull()
367
+ if (!handle) {
368
+ return
369
+ }
370
+ expect(() =>
371
+ act(() => {
372
+ handle.scrollToIndex(0)
373
+ handle.scrollTo(0)
374
+ handle.updateItemSize(0, 100)
375
+ handle.focusItemAtIndex(0)
376
+ }),
377
+ ).not.toThrow()
378
+ })
379
+
380
+ it("上部インセット指定時、行 top はコンテンツ絶対座標(=二重加算なし)でラッパーは transform で追従する", () => {
381
+ const { container } = render(
382
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
383
+ itemCount: 20,
384
+ getItem: (index: number) => `Item ${index}`,
385
+ getItemHeight: () => 40,
386
+ viewportSize: 200,
387
+ contentInsets: { top: 50, bottom: 30 },
388
+ children: (item: string) => React.createElement("div", { style: { height: 40 } }, item),
389
+ }),
390
+ )
391
+
392
+ // 先頭行 (index 0) の top はコンテンツ絶対座標 0(旧実装ではインセット二重適用で 50px になっていた)。
393
+ const firstItem = container.querySelector('[data-index="0"]') as HTMLElement | null
394
+ expect(firstItem).not.toBeNull()
395
+ expect(firstItem?.style.top).toBe("0px")
396
+
397
+ // ラッパーはインライン position:relative を持たず(CSS の position:absolute を活かす)、transform で移動する。
398
+ const wrapper = container.querySelector(".aqvs-items-wrapper") as HTMLElement | null
399
+ expect(wrapper).not.toBeNull()
400
+ expect(wrapper?.style.position).toBe("")
401
+ expect(wrapper?.style.transform).toContain("translateY")
402
+ })
403
+
404
+ /**
405
+ * Extracts the numeric translateY value from a wrapper transform string.
406
+ *
407
+ * ラッパーの transform 文字列から translateY の数値を取り出すユーティリティ。
408
+ */
409
+ const parseTranslateY = (transform: string): number => {
410
+ const match = /translateY\((-?[\d.]+)px\)/.exec(transform)
411
+ expect(match).not.toBeNull()
412
+ return Number(match?.[1])
413
+ }
414
+
415
+ it("深い initialScrollIndex でも行 top と translateY が量子化アンカーで小さな値に収まり視覚位置が保たれる", () => {
416
+ const itemCount = 100_000
417
+ const itemHeight = 40
418
+ const initialScrollIndex = 50_000
419
+ const { container } = render(
420
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
421
+ itemCount,
422
+ getItem: (index: number) => `Item ${index}`,
423
+ getItemHeight: () => itemHeight,
424
+ viewportSize: 200,
425
+ initialScrollIndex,
426
+ children: (item: string) => React.createElement("div", { style: { height: itemHeight } }, item),
427
+ }),
428
+ )
429
+
430
+ const wrapper = container.querySelector(".aqvs-items-wrapper") as HTMLElement | null
431
+ expect(wrapper).not.toBeNull()
432
+ if (!wrapper) {
433
+ return
434
+ }
435
+ const translateY = parseTranslateY(wrapper.style.transform)
436
+ const paneScrollPosition = initialScrollIndex * itemHeight // 2,000,000px (>2^20)
437
+
438
+ // ラッパーの translateY はアンカー基準の小さな値に収まる (アンカー無しでは -2,000,000px 級)。
439
+ expect(Math.abs(translateY)).toBeLessThan(2 ** 20)
440
+
441
+ const items = Array.from(container.querySelectorAll("[data-virtualscroll-item]")) as HTMLElement[]
442
+ expect(items.length).toBeGreaterThan(0)
443
+ for (const element of items) {
444
+ const index = Number(element.dataset.index)
445
+ const top = Number.parseFloat(element.style.top)
446
+ // 行 top はアンカー基準の小さな値 (アンカー無しでは約 2,000,000px でレイアウト精度を圧迫)。
447
+ expect(Math.abs(top)).toBeLessThan(2 ** 20)
448
+ // 視覚位置の恒等式: top + translateY = 絶対座標 - ペイン位置 (インセット 0)。
449
+ expect(top + translateY).toBeCloseTo(index * itemHeight - paneScrollPosition, 6)
450
+ }
451
+ })
452
+
453
+ it("可視域より上の行の updateItemSize 後、行 top が最新の prefix 和で再計算される (visibleItems の失効)", () => {
454
+ const itemCount = 1000
455
+ const itemHeight = 40
456
+ const ref = React.createRef<VirtualScrollHandle>()
457
+ const { container } = render(
458
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
459
+ ref,
460
+ itemCount,
461
+ getItem: (index: number) => `Item ${index}`,
462
+ getItemHeight: () => itemHeight,
463
+ viewportSize: 200,
464
+ children: (item: string) => React.createElement("div", { style: { height: itemHeight } }, item),
465
+ }),
466
+ )
467
+ const handle = ref.current
468
+ expect(handle).not.toBeNull()
469
+ if (!handle) {
470
+ return
471
+ }
472
+
473
+ act(() => {
474
+ handle.scrollToIndex(100)
475
+ })
476
+ const target = container.querySelector('[data-index="100"]') as HTMLElement | null
477
+ expect(target).not.toBeNull()
478
+ expect(target?.style.top).toBe("4000px")
479
+
480
+ // 可視先頭 (100) より上の行 50 を 40px→140px に拡大するとペインは +100 補正される。
481
+ // visibleItems メモが失効しないと行 top が古い prefix 和 (4000) のまま残り、全行が 100px ずれる。
482
+ act(() => {
483
+ handle.updateItemSize(50, 140)
484
+ })
485
+ const updated = container.querySelector('[data-index="100"]') as HTMLElement | null
486
+ expect(updated?.style.top).toBe("4100px")
487
+ })
488
+
489
+ it("最下部での updateItemSize 補正が旧 contentSize でクランプされても、二段補正でペイン位置と論理位置が収束する", () => {
490
+ const itemCount = 1000
491
+ const itemHeight = 40
492
+ const viewportSize = 200
493
+ const ref = React.createRef<VirtualScrollHandle>()
494
+ render(
495
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
496
+ ref,
497
+ itemCount,
498
+ getItem: (index: number) => `Item ${index}`,
499
+ getItemHeight: () => itemHeight,
500
+ viewportSize,
501
+ children: (item: string) => React.createElement("div", { style: { height: itemHeight } }, item),
502
+ }),
503
+ )
504
+ const handle = ref.current
505
+ expect(handle).not.toBeNull()
506
+ if (!handle) {
507
+ return
508
+ }
509
+
510
+ // 最下部へ移動 (max = 1000×40 - 200 = 39800)。
511
+ const oldMaxScrollPosition = itemCount * itemHeight - viewportSize
512
+ act(() => {
513
+ handle.scrollTo(oldMaxScrollPosition)
514
+ })
515
+ expect(handle.getScrollPosition()).toBe(oldMaxScrollPosition)
516
+
517
+ // 可視域より上の行 10 を 40px→140px に拡大 (delta +100)。補正 scrollTo(39900) は同一同期
518
+ // バッチ内では ScrollPane の旧 sizeRef (max 39800) でクランプされるが、contentSize コミット後の
519
+ // 二段補正で新 max (39900) へ再発行され、ペイン位置と論理位置が同値に収束する。
520
+ act(() => {
521
+ handle.updateItemSize(10, 140)
522
+ })
523
+
524
+ const newMaxScrollPosition = oldMaxScrollPosition + 100
525
+ expect(handle.getScrollPosition()).toBe(newMaxScrollPosition)
526
+ expect(handle.getRange().scrollPosition).toBe(newMaxScrollPosition)
527
+ })
528
+
529
+ it("マウント後の initialScrollIndex 変更で実測済み行高さが破棄されない", () => {
530
+ const itemCount = 1000
531
+ const getItemHeight = () => 40
532
+ const getItem = (index: number) => `Item ${index}`
533
+ const childrenRenderer = (item: string) => React.createElement("div", null, item)
534
+ const ref = React.createRef<VirtualScrollHandle>()
535
+ const buildProps = (initialScrollIndex: number) => ({
536
+ ref,
537
+ itemCount,
538
+ getItem,
539
+ getItemHeight,
540
+ viewportSize: 200,
541
+ initialScrollIndex,
542
+ children: childrenRenderer,
543
+ })
544
+ const { rerender } = render(React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, buildProps(0)))
545
+ const handle = ref.current
546
+ expect(handle).not.toBeNull()
547
+ if (!handle) {
548
+ return
549
+ }
550
+
551
+ act(() => {
552
+ handle.updateItemSize(500, 400)
553
+ })
554
+ expect(handle.getFenwickTreeTotalHeight()).toBe(40_360)
555
+
556
+ // "initial" prop の意味論: マウント後の変更は木の全面リセット (実測破棄) を引き起こさない。
557
+ rerender(React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, buildProps(10)))
558
+ expect(handle.getFenwickTreeTotalHeight()).toBe(40_360)
559
+ })
560
+
561
+ it("病的な描画範囲でも描画ノード数が安全上限 (2000) で有界化され警告が出る", () => {
562
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
563
+ try {
564
+ const { container } = render(
565
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
566
+ itemCount: 6000,
567
+ getItem: (index: number) => `Item ${index}`,
568
+ getItemHeight: () => 40,
569
+ viewportSize: 200,
570
+ overscanCount: 5000,
571
+ children: (item: string) => React.createElement("div", null, item),
572
+ }),
573
+ )
574
+ const items = container.querySelectorAll("[data-virtualscroll-item]")
575
+ expect(items.length).toBe(2000)
576
+ const capWarning = warnSpy.mock.calls.some((call) => call.some((argument) => typeof argument === "string" && argument.includes("safety cap")))
577
+ expect(capWarning).toBe(true)
578
+ } finally {
579
+ warnSpy.mockRestore()
580
+ }
581
+ })
582
+
583
+ it("初回マウントの高さ照合マイクロタスクが破棄されず総高さが実測へ収束する", async () => {
584
+ // React Testing Library の act はマイクロタスクより先に passive effect を同期実行してしまうため、
585
+ // ブラウザ実挙動 (レンダー由来マイクロタスク → passive effect の順) を再現するには act を使わず
586
+ // createRoot で直接マウントする。
587
+ const globalWithActFlag = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
588
+ const previousActEnvironment = globalWithActFlag.IS_REACT_ACT_ENVIRONMENT
589
+ globalWithActFlag.IS_REACT_ACT_ENVIRONMENT = false
590
+ const host = document.createElement("div")
591
+ document.body.appendChild(host)
592
+ const root = createRoot(host)
593
+ try {
594
+ const itemCount = 1000
595
+ // 行 51..70 のみ 35px、それ以外は 100px。初期 sampleRange (0..50) の外に実高と推定の差を作る。
596
+ const getItemHeight = (index: number) => (index >= 51 && index <= 70 ? 35 : 100)
597
+ const ref = React.createRef<VirtualScrollHandle>()
598
+ root.render(
599
+ React.createElement(VirtualScroll as (props: unknown) => React.ReactElement, {
600
+ ref,
601
+ itemCount,
602
+ getItem: (index: number) => `Item ${index}`,
603
+ getItemHeight,
604
+ viewportSize: 400,
605
+ // 行 51 以降が初回描画ウィンドウに入るオフセット (行 0..50 = 5100px)。
606
+ initialScrollOffset: 5100,
607
+ children: (item: string) => React.createElement("div", null, item),
608
+ }),
609
+ )
610
+ // レンダー・マイクロタスク・passive effect・setContentSize 再レンダーをすべて完了させる。
611
+ await new Promise((resolve) => setTimeout(resolve, 50))
612
+ const handle = ref.current
613
+ expect(handle).not.toBeNull()
614
+ // 行 51..70 (20 行 × -65px) の実測が反映される: 980×100 + 20×35 = 98,700。
615
+ // 初回マイクロタスクが破棄されると推定値 (約 100,000) のまま残る。
616
+ expect(handle?.getFenwickTreeTotalHeight()).toBe(98_700)
617
+ } finally {
618
+ root.unmount()
619
+ host.remove()
620
+ globalWithActFlag.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment
621
+ }
622
+ })
623
+ })