@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,32 @@
1
+ /**
2
+ * @module useHeightCache
3
+ * @description This module provides a hook for caching item heights using an LRU (Least Recently Used) cache.
4
+ * It is designed to be used in virtual scrolling components to optimize performance by storing and retrieving the heights of rendered items.
5
+ *
6
+ * @description このモジュールは、LRU (Least Recently Used) キャッシュを使用してアイテムの高さをキャッシュするためのフックを提供します。
7
+ * 仮想スクロールコンポーネントで使用されることを想定しており、レンダリングされたアイテムの高さを保存および取得することでパフォーマンスを最適化します。
8
+ */
9
+ import { useMemo } from "react"
10
+ import { useLruCache } from "./useLruCache.ts"
11
+
12
+ // LRU キャッシュに保存する高さの測定値の最大数
13
+ const HEIGHT_CACHE_CAPACITY = 10000
14
+
15
+ /**
16
+ * A custom hook that provides a cache for storing the heights of items, backed by an LRU cache.
17
+ * This helps in optimizing virtual scrolling by avoiding re-computation of item heights.
18
+ * The key is the item's index (number), and the value is its height (number).
19
+ *
20
+ * LRU キャッシュを利用して、アイテムの高さを保存するためのキャッシュを提供するカスタムフック。
21
+ * これにより、アイテムの高さの再計算を回避し、仮想スクロールを最適化します。
22
+ * キーはアイテムのインデックス (number)、値はその高さ (number) です。
23
+ */
24
+ export const useHeightCache = () => {
25
+ // useLruCache フックを初期化し、指定された容量でキャッシュインスタンスを作成
26
+ const { get, set, has, clear } = useLruCache<number, number>(HEIGHT_CACHE_CAPACITY)
27
+
28
+ // キャッシュを操作するための関数 (get, set, has, clear) を安定参照のオブジェクトで返す。
29
+ // 毎レンダー新しいオブジェクトを返すと、戻り値を依存配列に入れた呼び出し側のメモ化が無効化されるため useMemo で安定化する。
30
+ // Return a memoized object so consumers depending on it in dep arrays are not invalidated every render.
31
+ return useMemo(() => ({ get, set, has, clear }), [get, set, has, clear])
32
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @fileoverview Tests for useLruCache hook.
3
+ * useLruCache フックのテスト。
4
+ */
5
+
6
+ import { act, renderHook } from "@testing-library/react"
7
+ import { describe, expect, it, vi } from "vitest"
8
+ import { useLruCache } from "./useLruCache"
9
+
10
+ describe("useLruCache フック", () => {
11
+ it("値を追加して取得できる", () => {
12
+ const { result } = renderHook(() => useLruCache<string, number>(2))
13
+
14
+ act(() => {
15
+ result.current.set("alpha", 1)
16
+ result.current.set("beta", 2)
17
+ })
18
+
19
+ expect(result.current.get("alpha")).toBe(1)
20
+ expect(result.current.get("beta")).toBe(2)
21
+ expect(result.current.has("alpha")).toBe(true)
22
+ expect(result.current.has("beta")).toBe(true)
23
+ })
24
+
25
+ it("容量を超えると最も古い要素を削除する", () => {
26
+ const { result } = renderHook(() => useLruCache<string, number>(2))
27
+
28
+ act(() => {
29
+ result.current.set("first", 1)
30
+ result.current.set("second", 2)
31
+ result.current.set("third", 3)
32
+ })
33
+
34
+ expect(result.current.has("first")).toBe(false)
35
+ expect(result.current.has("second")).toBe(true)
36
+ expect(result.current.has("third")).toBe(true)
37
+ })
38
+
39
+ it("get の呼び出しで要素を最新扱いにする", () => {
40
+ const { result } = renderHook(() => useLruCache<string, number>(2))
41
+
42
+ act(() => {
43
+ result.current.set("first", 1)
44
+ result.current.set("second", 2)
45
+ result.current.get("first")
46
+ result.current.set("third", 3)
47
+ })
48
+
49
+ expect(result.current.has("first")).toBe(true)
50
+ expect(result.current.has("second")).toBe(false)
51
+ expect(result.current.has("third")).toBe(true)
52
+ })
53
+
54
+ it("remove で指定キーを削除する", () => {
55
+ const { result } = renderHook(() => useLruCache<string, number>(2))
56
+
57
+ act(() => {
58
+ result.current.set("alpha", 1)
59
+ result.current.set("beta", 2)
60
+ result.current.remove("alpha")
61
+ })
62
+
63
+ expect(result.current.has("alpha")).toBe(false)
64
+ expect(result.current.get("alpha")).toBeUndefined()
65
+ expect(result.current.has("beta")).toBe(true)
66
+ })
67
+
68
+ it("clear ですべての要素を削除する", () => {
69
+ const { result } = renderHook(() => useLruCache<string, number>(2))
70
+
71
+ act(() => {
72
+ result.current.set("first", 1)
73
+ result.current.set("second", 2)
74
+ result.current.clear()
75
+ })
76
+
77
+ expect(result.current.has("first")).toBe(false)
78
+ expect(result.current.has("second")).toBe(false)
79
+ expect(result.current.get("first")).toBeUndefined()
80
+ expect(result.current.get("second")).toBeUndefined()
81
+ })
82
+
83
+ it("capacity に NaN を渡しても無制限に成長しない (eviction 無効化を防ぐ)", () => {
84
+ const { result } = renderHook(() => useLruCache<number, number>(Number.NaN))
85
+
86
+ act(() => {
87
+ for (let key = 0; key < 100; key++) {
88
+ result.current.set(key, key)
89
+ }
90
+ })
91
+
92
+ // NaN は有効な容量ではないため、キャッシュには何も保持されない (無制限成長を防止)
93
+ for (let key = 0; key < 100; key++) {
94
+ expect(result.current.has(key)).toBe(false)
95
+ }
96
+ })
97
+
98
+ it("capacity = Infinity は無制限キャッシュとして格納でき evict されない", () => {
99
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
100
+ try {
101
+ const { result } = renderHook(() => useLruCache<number, number>(Number.POSITIVE_INFINITY))
102
+
103
+ act(() => {
104
+ for (let key = 0; key < 500; key++) {
105
+ result.current.set(key, key * 10)
106
+ }
107
+ })
108
+
109
+ // Infinity は「無制限」なので全エントリが evict されずに保持される
110
+ for (let key = 0; key < 500; key++) {
111
+ expect(result.current.has(key)).toBe(true)
112
+ expect(result.current.get(key)).toBe(key * 10)
113
+ }
114
+ // 有効な容量なので警告は出ない
115
+ expect(warnSpy).not.toHaveBeenCalled()
116
+ } finally {
117
+ warnSpy.mockRestore()
118
+ }
119
+ })
120
+
121
+ it("capacity に NaN を渡すと格納せず警告をログする", () => {
122
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
123
+ try {
124
+ const { result } = renderHook(() => useLruCache<string, number>(Number.NaN))
125
+
126
+ act(() => {
127
+ result.current.set("alpha", 1)
128
+ })
129
+
130
+ // 無効容量なので格納されない
131
+ expect(result.current.has("alpha")).toBe(false)
132
+ expect(result.current.get("alpha")).toBeUndefined()
133
+ // capacity 変更 (マウント) につき 1 回だけ警告が出る
134
+ expect(warnSpy).toHaveBeenCalledTimes(1)
135
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("invalid capacity"))
136
+ } finally {
137
+ warnSpy.mockRestore()
138
+ }
139
+ })
140
+
141
+ it("capacity に負値を渡すと格納せず警告をログする", () => {
142
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
143
+ try {
144
+ const { result } = renderHook(() => useLruCache<string, number>(-5))
145
+
146
+ act(() => {
147
+ result.current.set("alpha", 1)
148
+ result.current.set("beta", 2)
149
+ })
150
+
151
+ // 無効容量なので格納されない
152
+ expect(result.current.has("alpha")).toBe(false)
153
+ expect(result.current.has("beta")).toBe(false)
154
+ // set の回数によらず、capacity 変更 (マウント) につき 1 回だけ警告が出る
155
+ expect(warnSpy).toHaveBeenCalledTimes(1)
156
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("invalid capacity"))
157
+ } finally {
158
+ warnSpy.mockRestore()
159
+ }
160
+ })
161
+
162
+ it("小数 capacity は floor に正規化され set の eviction が実効容量を守る", () => {
163
+ const { result } = renderHook(() => useLruCache<string, number>(2.5))
164
+
165
+ act(() => {
166
+ result.current.set("a", 1)
167
+ result.current.set("b", 2)
168
+ result.current.set("c", 3)
169
+ })
170
+
171
+ // floor(2.5) = 2 が実効容量なので、3 件目の追加で最古の "a" が evict される
172
+ expect(result.current.has("a")).toBe(false)
173
+ expect(result.current.has("b")).toBe(true)
174
+ expect(result.current.has("c")).toBe(true)
175
+ })
176
+
177
+ it("小数 capacity への縮小でも刈り込み effect が floor 値まで刈り込む (set と同一の実効値)", () => {
178
+ const { result, rerender } = renderHook(({ capacity }) => useLruCache<number, number>(capacity), {
179
+ initialProps: { capacity: 5 },
180
+ })
181
+
182
+ act(() => {
183
+ for (let key = 0; key < 5; key++) {
184
+ result.current.set(key, key)
185
+ }
186
+ })
187
+
188
+ // capacity を 5 → 2.5 に縮小: floor(2.5) = 2 件まで刈り込まれる
189
+ rerender({ capacity: 2.5 })
190
+
191
+ let count = 0
192
+ for (let key = 0; key < 5; key++) {
193
+ if (result.current.has(key)) {
194
+ count++
195
+ }
196
+ }
197
+ expect(count).toBe(2)
198
+
199
+ // 以後の set も同じ実効容量 2 を守る
200
+ act(() => {
201
+ result.current.set(100, 100)
202
+ })
203
+ let countAfterSet = 0
204
+ for (let key = 0; key <= 100; key++) {
205
+ if (result.current.has(key)) {
206
+ countAfterSet++
207
+ }
208
+ }
209
+ expect(countAfterSet).toBe(2)
210
+ })
211
+
212
+ it("capacity が有効値から NaN に変わると既存エントリを全消去し警告をログする", () => {
213
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
214
+ try {
215
+ const { result, rerender } = renderHook(({ capacity }) => useLruCache<string, number>(capacity), {
216
+ initialProps: { capacity: 10 },
217
+ })
218
+
219
+ act(() => {
220
+ result.current.set("alpha", 1)
221
+ result.current.set("beta", 2)
222
+ })
223
+ expect(result.current.has("alpha")).toBe(true)
224
+
225
+ // 有効値 → NaN への動的遷移
226
+ rerender({ capacity: Number.NaN })
227
+
228
+ // 警告文言「the cache will not store any entries」どおり、既存エントリも保持されない
229
+ expect(result.current.has("alpha")).toBe(false)
230
+ expect(result.current.has("beta")).toBe(false)
231
+ expect(result.current.get("alpha")).toBeUndefined()
232
+ expect(warnSpy).toHaveBeenCalledTimes(1)
233
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("invalid capacity"))
234
+ } finally {
235
+ warnSpy.mockRestore()
236
+ }
237
+ })
238
+
239
+ it("capacity が有効値から負値に変わると既存エントリを全消去する", () => {
240
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
241
+ try {
242
+ const { result, rerender } = renderHook(({ capacity }) => useLruCache<string, number>(capacity), {
243
+ initialProps: { capacity: 10 },
244
+ })
245
+
246
+ act(() => {
247
+ result.current.set("alpha", 1)
248
+ })
249
+ expect(result.current.has("alpha")).toBe(true)
250
+
251
+ // 有効値 → 負値への動的遷移
252
+ rerender({ capacity: -1 })
253
+
254
+ expect(result.current.has("alpha")).toBe(false)
255
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("invalid capacity"))
256
+
257
+ // 無効容量のままの set は no-op
258
+ act(() => {
259
+ result.current.set("beta", 2)
260
+ })
261
+ expect(result.current.has("beta")).toBe(false)
262
+ } finally {
263
+ warnSpy.mockRestore()
264
+ }
265
+ })
266
+
267
+ it("capacity を有限値から Infinity に変更すると以後は無制限に格納できる (仕様維持)", () => {
268
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
269
+ try {
270
+ const { result, rerender } = renderHook(({ capacity }) => useLruCache<number, number>(capacity), {
271
+ initialProps: { capacity: 2 },
272
+ })
273
+
274
+ act(() => {
275
+ result.current.set(0, 0)
276
+ result.current.set(1, 1)
277
+ result.current.set(2, 2)
278
+ })
279
+ // 有限容量 2 の間は evict される
280
+ expect(result.current.has(0)).toBe(false)
281
+
282
+ // Infinity へ動的変更: 無制限キャッシュとして格納・保持できる
283
+ rerender({ capacity: Number.POSITIVE_INFINITY })
284
+ act(() => {
285
+ for (let key = 10; key < 110; key++) {
286
+ result.current.set(key, key)
287
+ }
288
+ })
289
+ for (let key = 10; key < 110; key++) {
290
+ expect(result.current.has(key)).toBe(true)
291
+ }
292
+ // Infinity は有効な容量なので警告は出ない
293
+ expect(warnSpy).not.toHaveBeenCalled()
294
+ } finally {
295
+ warnSpy.mockRestore()
296
+ }
297
+ })
298
+
299
+ it("handler の識別子がマウント後の再レンダーで安定している", () => {
300
+ const { result, rerender } = renderHook(() => useLruCache<string, number>(2))
301
+
302
+ const firstHandler = result.current
303
+ rerender()
304
+
305
+ // useMemo により余計な再生成が起きないので同一参照であること
306
+ expect(result.current).toBe(firstHandler)
307
+ expect(result.current.get).toBe(firstHandler.get)
308
+ expect(result.current.set).toBe(firstHandler.set)
309
+ })
310
+
311
+ it("capacity を縮小すると以後の set が新容量を守る (stale set 公開を防ぐ)", () => {
312
+ const { result, rerender } = renderHook(({ capacity }) => useLruCache<number, number>(capacity), {
313
+ initialProps: { capacity: 100 },
314
+ })
315
+
316
+ act(() => {
317
+ for (let key = 0; key < 11; key++) {
318
+ result.current.set(key, key)
319
+ }
320
+ })
321
+ expect(result.current.has(0)).toBe(true)
322
+
323
+ // capacity を 100 → 5 に縮小
324
+ rerender({ capacity: 5 })
325
+
326
+ act(() => {
327
+ result.current.set(100, 100)
328
+ })
329
+
330
+ // 刈り込み effect と最新 capacity を参照する set により、サイズは新容量 5 を超えない
331
+ let count = 0
332
+ for (let key = 0; key <= 100; key++) {
333
+ if (result.current.has(key)) {
334
+ count++
335
+ }
336
+ }
337
+ expect(count).toBe(5)
338
+ })
339
+
340
+ it("Number.MAX_SAFE_INTEGER 回の追加後でも値取得と全削除ができる", () => {
341
+ const capacity = 3
342
+ const { result } = renderHook(() => useLruCache<number, string>(capacity))
343
+
344
+ act(() => {
345
+ result.current.set(-1, "initial")
346
+ // LRU の性質上、末尾の capacity 件の追加で全体と同じ最終状態になる
347
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
348
+ result.current.set(key, `value-${key}`)
349
+ }
350
+ })
351
+
352
+ expect(result.current.has(-1)).toBe(false)
353
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
354
+ expect(result.current.has(key)).toBe(true)
355
+ expect(result.current.get(key)).toBe(`value-${key}`)
356
+ }
357
+
358
+ act(() => {
359
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
360
+ result.current.remove(key)
361
+ }
362
+ })
363
+
364
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
365
+ expect(result.current.has(key)).toBe(false)
366
+ expect(result.current.get(key)).toBeUndefined()
367
+ }
368
+
369
+ act(() => {
370
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
371
+ result.current.set(key, `value-${key}`)
372
+ }
373
+ result.current.clear()
374
+ })
375
+
376
+ expect(result.current.has(-1)).toBe(false)
377
+ for (let key = Number.MAX_SAFE_INTEGER - capacity + 1; key <= Number.MAX_SAFE_INTEGER; key++) {
378
+ expect(result.current.has(key)).toBe(false)
379
+ expect(result.current.get(key)).toBeUndefined()
380
+ }
381
+ })
382
+ })