clear-virtualizer 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Bubnov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # clear-virtualizer
2
+
3
+ One hook for vertical virtualized lists and tables with dynamic row heights. Rows are measured automatically via `ResizeObserver` — no manual measuring, no fixed heights. Works for an unlimited number of rows.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm i clear-virtualizer
9
+ ```
10
+
11
+ ## Exports
12
+
13
+ ```ts
14
+ import { useVirtualizer, type Options, type ScrollAlign, type VirtualItem } from 'clear-virtualizer';
15
+ ```
16
+
17
+ ## API
18
+
19
+ ```ts
20
+ const { virtualItems, scrollHeight, scrollRef, getMeasureRef, scrollToIndex } = useVirtualizer({
21
+ count: 100000,
22
+ estimateSize: () => 44,
23
+ overscan: 10,
24
+ });
25
+ ```
26
+
27
+ | Option | Type | Description |
28
+ | -------------- |--------------------------------------------| -------------------------------------------------------- |
29
+ | `count` | `number` | Total number of rows |
30
+ | `estimateSize` | `((index: number) => number) \| undefined` | Approximate row height in px, used until a row is measured |
31
+ | `overscan` | `number \| undefined` | Extra rows rendered above/below the viewport (default: 3) |
32
+
33
+ | Return value | Type | Description |
34
+ | --------------- | --------------------------------------------- | ------------------------------------------------------- |
35
+ | `virtualItems` | `VirtualItem[]` | Rows to render: `{ index, start, size, end }` (px) |
36
+ | `scrollHeight` | `number` | Total scrollable height (px) |
37
+ | `scrollRef` | `(el: HTMLElement \| null) => void` | Callback ref — attach to the scroll container |
38
+ | `getMeasureRef` | `(index: number) => (el: HTMLElement \| null) => void` | Attach the returned ref to each row for measuring |
39
+ | `scrollToIndex` | `(index: number, options?: { align?: ScrollAlign }) => void` | Scroll to a row |
40
+
41
+ Do not set a fixed `height` on rows — let them size naturally so `getMeasureRef` measures the real height. Position rows with `transform: translateY(...)`.
42
+
43
+ ## List example
44
+
45
+ ```tsx
46
+ import { CSSProperties, ReactNode } from 'react';
47
+ import { useVirtualizer } from 'clear-virtualizer';
48
+
49
+ export type VirtualizeListProps = {
50
+ count: number;
51
+ renderItem: (index: number) => ReactNode;
52
+ height: CSSProperties['height'];
53
+ estimateSize?: (index: number) => number;
54
+ className?: string;
55
+ overscan?: number;
56
+ width?: CSSProperties['width'];
57
+ style?: CSSProperties;
58
+ };
59
+
60
+ export const VirtualizedList = ({
61
+ count,
62
+ renderItem,
63
+ height,
64
+ className,
65
+ style,
66
+ overscan,
67
+ estimateSize,
68
+ width = 'auto',
69
+ }: VirtualizeListProps) => {
70
+ const { virtualItems, scrollHeight, scrollRef, getMeasureRef } = useVirtualizer({
71
+ count,
72
+ estimateSize,
73
+ overscan,
74
+ });
75
+
76
+ return (
77
+ <div
78
+ ref={scrollRef}
79
+ style={{ height, width, overflow: 'auto', lineHeight: 1.5, ...style }}
80
+ className={className}
81
+ >
82
+ <div style={{ position: 'relative', height: scrollHeight }}>
83
+ {virtualItems.map(item => (
84
+ <div
85
+ key={item.index}
86
+ ref={getMeasureRef(item.index)}
87
+ style={{
88
+ position: 'absolute',
89
+ top: 0,
90
+ left: 0,
91
+ width: '100%',
92
+ transform: `translateY(${item.start}px)`,
93
+ }}
94
+ >
95
+ {renderItem(item.index)}
96
+ </div>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ );
101
+ };
102
+ ```
103
+
104
+ ## Table example (with @tanstack/react-table and expandable rows)
105
+
106
+ Column widths must be set on both header and body cells (`header.getSize()` / `cell.column.getSize()` with `flexShrink: 0`) so columns stay aligned. The header is `position: sticky`. Expanding a row changes its height — it gets re-measured automatically.
107
+
108
+ ```tsx
109
+ import { useState } from 'react';
110
+ import {
111
+ useReactTable,
112
+ getCoreRowModel,
113
+ getExpandedRowModel,
114
+ flexRender,
115
+ createColumnHelper,
116
+ } from '@tanstack/react-table';
117
+ import { useVirtualizer } from 'clear-virtualizer';
118
+
119
+ type User = { id: number; name: string; email: string; role: string };
120
+
121
+ const data: User[] = Array.from({ length: 100_000 }, (_, i) => ({
122
+ id: i + 1,
123
+ name: `User ${i + 1}`,
124
+ email: `user${i + 1}@example.com`,
125
+ role: ['Admin', 'Editor', 'Viewer'][i % 3],
126
+ }));
127
+
128
+ const columnHelper = createColumnHelper<User>();
129
+
130
+ const columns = [
131
+ columnHelper.display({
132
+ id: 'expand',
133
+ size: 40,
134
+ cell: ({ row }) => (
135
+ <button onClick={() => row.toggleExpanded()}>{row.getIsExpanded() ? '▼' : '▶'}</button>
136
+ ),
137
+ }),
138
+ columnHelper.accessor('id', { header: 'ID', size: 60 }),
139
+ columnHelper.accessor('name', { header: 'Name', size: 150 }),
140
+ columnHelper.accessor('email', { header: 'Email', size: 200 }),
141
+ columnHelper.accessor('role', { header: 'Role', size: 100 }),
142
+ ];
143
+
144
+ export const VirtualTable = () => {
145
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
146
+ const table = useReactTable({
147
+ data,
148
+ columns,
149
+ state: { expanded },
150
+ onExpandedChange: old => setExpanded(old as Record<string, boolean>),
151
+ getExpandedRowModel: getExpandedRowModel(),
152
+ getCoreRowModel: getCoreRowModel(),
153
+ });
154
+ const rows = table.getRowModel().rows;
155
+ const { virtualItems, scrollHeight, scrollRef, getMeasureRef } = useVirtualizer({
156
+ count: rows.length,
157
+ estimateSize: () => 44,
158
+ overscan: 10,
159
+ });
160
+
161
+ return (
162
+ <div ref={scrollRef} style={{ height: 550, overflow: 'auto' }}>
163
+ <div style={{ position: 'relative', height: scrollHeight }}>
164
+ {table.getHeaderGroups().map(headerGroup => (
165
+ <div
166
+ key={headerGroup.id}
167
+ style={{ position: 'sticky', top: 0, zIndex: 1, display: 'flex', background: '#f5f5f5' }}
168
+ >
169
+ {headerGroup.headers.map(header => (
170
+ <div
171
+ key={header.id}
172
+ style={{ width: header.getSize(), padding: '8px 12px', flexShrink: 0 }}
173
+ >
174
+ {flexRender(header.column.columnDef.header, header.getContext())}
175
+ </div>
176
+ ))}
177
+ </div>
178
+ ))}
179
+ {virtualItems.map(virtualRow => {
180
+ const row = rows[virtualRow.index];
181
+ return (
182
+ <div
183
+ key={row.id}
184
+ ref={getMeasureRef(virtualRow.index)}
185
+ style={{
186
+ position: 'absolute',
187
+ top: 0,
188
+ left: 0,
189
+ width: '100%',
190
+ transform: `translateY(${virtualRow.start}px)`,
191
+ }}
192
+ >
193
+ <div style={{ display: 'flex' }}>
194
+ {row.getVisibleCells().map(cell => (
195
+ <div
196
+ key={cell.id}
197
+ style={{ width: cell.column.getSize(), padding: '8px 12px', flexShrink: 0 }}
198
+ >
199
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
200
+ </div>
201
+ ))}
202
+ </div>
203
+ {row.getIsExpanded() && (
204
+ <div style={{ padding: '8px 16px' }}>Details for {row.original.name}</div>
205
+ )}
206
+ </div>
207
+ );
208
+ })}
209
+ </div>
210
+ </div>
211
+ );
212
+ };
213
+ ```
214
+
215
+ ## scrollToIndex
216
+
217
+ ```ts
218
+ scrollToIndex(34567); // align defaults to 'start'
219
+ scrollToIndex(34567, { align: 'center' });
220
+ scrollToIndex(34567, { align: 'end' });
221
+ ```
222
+
223
+ | `align` | Positions the row... |
224
+ | -------- | --------------------------------- |
225
+ | `start` | At the top of the viewport (default) |
226
+ | `center` | In the middle of the viewport |
227
+ | `end` | At the bottom of the viewport |
228
+
229
+ If the row is far away and its neighbors were never measured, the hook first jumps to the estimated position, measures the rows around the target, and corrects the position. A manual scroll during that correction cancels it and hands control back to the user.
230
+
231
+ ## estimateSize
232
+
233
+ `estimateSize` is only a starting guess until real measurements arrive. The closer it is to reality, the less rows visually shift on fast scroll through unmeasured regions: underestimated rows overlap until measured, overestimated ones leave temporary gaps. Pass a value near your average row height.
234
+
235
+ ## Limitations
236
+
237
+ - Vertical scrolling only — no horizontal virtualization, no sticky columns.
238
+ - The internal offset tree keeps one `float64` per row (~8 MB per million rows).
239
+ - Server-side rendering is not covered by tests.
240
+
241
+ ## License
242
+
243
+ MIT
@@ -0,0 +1,5 @@
1
+ export declare const DEFAULT_OVERSCAN = 3;
2
+ export declare const SCROLL_CONVERGED_THRESHOLD = 0.5;
3
+ export declare const SETTLE_QUIET_FRAMES = 3;
4
+ export declare const SETTLE_MAX_FRAMES = 180;
5
+ export declare const SETTLE_WINDOW = 40;
@@ -0,0 +1,10 @@
1
+ export declare class FenwickTree {
2
+ private tree;
3
+ private n;
4
+ private logn;
5
+ constructor(sizes: Float64Array);
6
+ update(i: number, delta: number): void;
7
+ prefixSum(i: number): number;
8
+ total(): number;
9
+ findByPrefixSum(target: number): number;
10
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="react" />
2
+ export declare const useLatest: <T>(value: T) => import("react").MutableRefObject<T>;
@@ -0,0 +1,21 @@
1
+ export type VirtualItem = {
2
+ index: number;
3
+ start: number;
4
+ size: number;
5
+ end: number;
6
+ };
7
+ export type Options = {
8
+ count: number;
9
+ estimateSize?: (index: number) => number;
10
+ overscan?: number;
11
+ };
12
+ export type ScrollAlign = 'start' | 'center' | 'end';
13
+ export declare function useVirtualizer({ count, estimateSize, overscan }: Options): {
14
+ virtualItems: VirtualItem[];
15
+ scrollHeight: number;
16
+ scrollToIndex: (index: number, options?: {
17
+ align?: ScrollAlign;
18
+ }) => void;
19
+ scrollRef: (element: HTMLElement | null) => void;
20
+ getMeasureRef: (index: number) => (el: HTMLElement | null) => void;
21
+ };
package/lib/index.cjs ADDED
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
4
+ var __publicField = (obj, key, value) => {
5
+ __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
+ return value;
7
+ };
8
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
9
+ const react = require("react");
10
+ const useLatest = (value) => {
11
+ const ref = react.useRef(value);
12
+ react.useEffect(() => {
13
+ ref.current = value;
14
+ }, [value]);
15
+ return ref;
16
+ };
17
+ class FenwickTree {
18
+ constructor(sizes) {
19
+ __publicField(this, "tree");
20
+ __publicField(this, "n");
21
+ __publicField(this, "logn");
22
+ this.n = sizes.length;
23
+ this.tree = new Float64Array(this.n + 1);
24
+ for (let i = 0; i < this.n; i++)
25
+ this.tree[i + 1] = sizes[i];
26
+ for (let x = 1; x <= this.n; x++) {
27
+ const parent = x + (x & -x);
28
+ if (parent <= this.n)
29
+ this.tree[parent] += this.tree[x];
30
+ }
31
+ this.logn = this.n > 0 ? Math.floor(Math.log2(this.n)) : 0;
32
+ }
33
+ update(i, delta) {
34
+ for (let x = i + 1; x <= this.n; x += x & -x)
35
+ this.tree[x] += delta;
36
+ }
37
+ prefixSum(i) {
38
+ let sum = 0;
39
+ for (let x = i; x > 0; x -= x & -x)
40
+ sum += this.tree[x];
41
+ return sum;
42
+ }
43
+ total() {
44
+ return this.prefixSum(this.n);
45
+ }
46
+ findByPrefixSum(target) {
47
+ let pos = 0;
48
+ let remaining = target;
49
+ const EPS = 1e-6;
50
+ for (let pw = 1 << this.logn; pw > 0; pw >>= 1) {
51
+ const next = pos + pw;
52
+ if (next <= this.n && this.tree[next] <= remaining + EPS) {
53
+ pos = next;
54
+ remaining -= this.tree[next];
55
+ }
56
+ }
57
+ return pos;
58
+ }
59
+ }
60
+ const DEFAULT_OVERSCAN = 3;
61
+ const SCROLL_CONVERGED_THRESHOLD = 0.5;
62
+ const SETTLE_QUIET_FRAMES = 3;
63
+ const SETTLE_MAX_FRAMES = 180;
64
+ const SETTLE_WINDOW = 40;
65
+ const DEFAULT_SIZE = 24;
66
+ const SAFE_MAX_HEIGHT = 15e6;
67
+ const getScale = (logicalTotal) => {
68
+ if (logicalTotal <= SAFE_MAX_HEIGHT)
69
+ return { scale: 1, physicalTotal: logicalTotal };
70
+ return { scale: SAFE_MAX_HEIGHT / logicalTotal, physicalTotal: SAFE_MAX_HEIGHT };
71
+ };
72
+ const getFenwickTree = (count, estimateSize) => {
73
+ const sizes = new Float64Array(count);
74
+ for (let i = 0; i < count; i++)
75
+ sizes[i] = estimateSize?.(i) || DEFAULT_SIZE;
76
+ return new FenwickTree(sizes);
77
+ };
78
+ const classifySettleScroll = (scrollTop, targetScrollTop, version, baseVersion) => {
79
+ if (Math.abs(scrollTop - targetScrollTop) <= SCROLL_CONVERGED_THRESHOLD)
80
+ return "converged";
81
+ return version !== baseVersion ? "stale-tree" : "user";
82
+ };
83
+ const getViewportRange = (tree, logicalScrollOffset, containerHeight, scale, overscanValue, countValue) => {
84
+ const viewportStart = tree.findByPrefixSum(logicalScrollOffset);
85
+ const viewportEnd = tree.findByPrefixSum(logicalScrollOffset + containerHeight / scale);
86
+ return {
87
+ startIndex: Math.max(viewportStart - overscanValue, 0),
88
+ endIndex: Math.min(viewportEnd + overscanValue + 1, countValue),
89
+ viewportStart,
90
+ viewportEnd
91
+ };
92
+ };
93
+ const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count) => {
94
+ const targetIndex = Math.max(0, Math.min(index, count - 1));
95
+ const targetOffset = tree.prefixSum(targetIndex);
96
+ const itemLogicalSize = tree.prefixSum(targetIndex + 1) - targetOffset;
97
+ let logicalScrollOffset;
98
+ switch (align) {
99
+ case "center":
100
+ logicalScrollOffset = targetOffset + (itemLogicalSize - containerHeight / scale) / 2;
101
+ break;
102
+ case "end":
103
+ logicalScrollOffset = targetOffset + itemLogicalSize - containerHeight / scale;
104
+ break;
105
+ default:
106
+ logicalScrollOffset = targetOffset;
107
+ }
108
+ return { targetScrollTop: Math.max(0, logicalScrollOffset * scale), logicalScrollOffset };
109
+ };
110
+ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
111
+ const [scrollOffset, setScrollOffset] = react.useState({ value: 0 });
112
+ const [forcedRange, setForcedRange] = react.useState(null);
113
+ const scrollElementRef = react.useRef(null);
114
+ const rafRef = react.useRef(null);
115
+ const observersRef = react.useRef(/* @__PURE__ */ new Map());
116
+ const containerObserverRef = react.useRef(null);
117
+ const prevCountRef = react.useRef(count);
118
+ const fenwickRef = react.useRef(null);
119
+ const refCacheRef = react.useRef(/* @__PURE__ */ new Map());
120
+ const countRef = useLatest(count);
121
+ const overscanRef = useLatest(overscan);
122
+ const pendingTargetRef = react.useRef(null);
123
+ const tickRafRef = react.useRef(null);
124
+ const settleFramesRef = react.useRef(0);
125
+ const quietFramesRef = react.useRef(0);
126
+ const treeVersionRef = react.useRef(0);
127
+ const versionAtJumpRef = react.useRef(0);
128
+ if (fenwickRef.current === null || !fenwickRef.current.total())
129
+ fenwickRef.current = getFenwickTree(count, estimateSize);
130
+ if (prevCountRef.current !== count) {
131
+ fenwickRef.current = getFenwickTree(count, estimateSize);
132
+ prevCountRef.current = count;
133
+ }
134
+ const computeItems = react.useCallback(
135
+ (physicalScrollOffset) => {
136
+ const el = scrollElementRef.current;
137
+ const tree = fenwickRef.current;
138
+ if (!el || count === 0 || !tree)
139
+ return { virtualItems: [], scrollHeight: 0 };
140
+ const containerHeight = el.clientHeight;
141
+ const logicalTotal = tree.total();
142
+ const { scale, physicalTotal } = getScale(logicalTotal);
143
+ const pendingTarget = pendingTargetRef.current;
144
+ let logicalScrollOffset;
145
+ if (pendingTarget !== null) {
146
+ const { targetScrollTop, logicalScrollOffset: targetLogicalOffset } = computeTargetScrollTop(
147
+ tree,
148
+ pendingTarget.index,
149
+ pendingTarget.align,
150
+ containerHeight,
151
+ scale,
152
+ count
153
+ );
154
+ logicalScrollOffset = targetLogicalOffset;
155
+ if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
156
+ el.scrollTop = targetScrollTop;
157
+ }
158
+ } else {
159
+ logicalScrollOffset = physicalScrollOffset / scale;
160
+ }
161
+ let startIndex;
162
+ let endIndex;
163
+ if (forcedRange) {
164
+ startIndex = forcedRange.start;
165
+ endIndex = forcedRange.end;
166
+ } else {
167
+ const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
168
+ tree,
169
+ logicalScrollOffset,
170
+ containerHeight,
171
+ scale,
172
+ overscan,
173
+ count
174
+ );
175
+ startIndex = viewportStartIndex;
176
+ endIndex = viewportEndIndex;
177
+ }
178
+ const startOffsetLogical = tree.prefixSum(startIndex);
179
+ const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
180
+ const virtualItems2 = [];
181
+ let offset = startOffsetPhysical;
182
+ for (let i = startIndex; i < endIndex; i++) {
183
+ const size = (tree.prefixSum(i + 1) - tree.prefixSum(i)) * scale;
184
+ virtualItems2.push({ index: i, start: offset, size, end: offset + size });
185
+ offset += size;
186
+ }
187
+ return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
188
+ },
189
+ [count, forcedRange, overscan]
190
+ );
191
+ const measureElement = react.useCallback((element, index) => {
192
+ if (!element) {
193
+ const entry = observersRef.current.get(index);
194
+ if (entry) {
195
+ entry.observer.disconnect();
196
+ observersRef.current.delete(index);
197
+ }
198
+ return;
199
+ }
200
+ const existing = observersRef.current.get(index);
201
+ if (existing && existing.element === element)
202
+ return;
203
+ if (existing) {
204
+ existing.observer.disconnect();
205
+ observersRef.current.delete(index);
206
+ }
207
+ const observer = new ResizeObserver(([entry]) => {
208
+ const height = entry?.borderBoxSize[0]?.blockSize;
209
+ if (height == null || !fenwickRef.current)
210
+ return;
211
+ const tree = fenwickRef.current;
212
+ const { scale } = getScale(tree.total());
213
+ const logicalHeight = height / scale;
214
+ const prevSize = tree.prefixSum(index + 1) - tree.prefixSum(index);
215
+ if (Math.abs(prevSize - logicalHeight) < 0.5)
216
+ return;
217
+ tree.update(index, logicalHeight - prevSize);
218
+ treeVersionRef.current += 1;
219
+ setScrollOffset((prevState) => ({ ...prevState }));
220
+ });
221
+ observer.observe(element);
222
+ observersRef.current.set(index, { observer, element });
223
+ }, []);
224
+ const getMeasureRef = react.useCallback(
225
+ (index) => {
226
+ let fn = refCacheRef.current.get(index);
227
+ if (!fn) {
228
+ fn = (el) => {
229
+ measureElement(el, index);
230
+ if (el === null)
231
+ refCacheRef.current.delete(index);
232
+ };
233
+ refCacheRef.current.set(index, fn);
234
+ }
235
+ return fn;
236
+ },
237
+ [measureElement]
238
+ );
239
+ const releaseSettle = react.useCallback((scrollTop) => {
240
+ if (tickRafRef.current !== null) {
241
+ cancelAnimationFrame(tickRafRef.current);
242
+ tickRafRef.current = null;
243
+ }
244
+ pendingTargetRef.current = null;
245
+ setForcedRange(null);
246
+ setScrollOffset({ value: scrollTop });
247
+ }, []);
248
+ const scheduleTick = react.useCallback(() => {
249
+ if (tickRafRef.current !== null)
250
+ return;
251
+ tickRafRef.current = requestAnimationFrame(() => {
252
+ tickRafRef.current = null;
253
+ const el = scrollElementRef.current;
254
+ const tree = fenwickRef.current;
255
+ const pending = pendingTargetRef.current;
256
+ if (!el || !tree || !pending)
257
+ return;
258
+ const countValue = countRef.current;
259
+ const overscanValue = overscanRef.current;
260
+ settleFramesRef.current += 1;
261
+ if (settleFramesRef.current > SETTLE_MAX_FRAMES) {
262
+ releaseSettle(el.scrollTop);
263
+ return;
264
+ }
265
+ const { scale } = getScale(tree.total());
266
+ const { targetScrollTop } = computeTargetScrollTop(
267
+ tree,
268
+ pending.index,
269
+ pending.align,
270
+ el.clientHeight,
271
+ scale,
272
+ countValue
273
+ );
274
+ const kind = classifySettleScroll(
275
+ el.scrollTop,
276
+ targetScrollTop,
277
+ treeVersionRef.current,
278
+ versionAtJumpRef.current
279
+ );
280
+ if (kind === "user") {
281
+ releaseSettle(el.scrollTop);
282
+ return;
283
+ }
284
+ if (kind === "stale-tree") {
285
+ versionAtJumpRef.current = treeVersionRef.current;
286
+ quietFramesRef.current = 0;
287
+ setScrollOffset((prevState) => ({ ...prevState }));
288
+ scheduleTick();
289
+ return;
290
+ }
291
+ if (treeVersionRef.current !== versionAtJumpRef.current) {
292
+ versionAtJumpRef.current = treeVersionRef.current;
293
+ quietFramesRef.current = 0;
294
+ } else {
295
+ quietFramesRef.current += 1;
296
+ }
297
+ if (quietFramesRef.current < SETTLE_QUIET_FRAMES) {
298
+ scheduleTick();
299
+ return;
300
+ }
301
+ const { startIndex, endIndex } = getViewportRange(
302
+ tree,
303
+ el.scrollTop / scale,
304
+ el.clientHeight,
305
+ scale,
306
+ overscanValue,
307
+ countValue
308
+ );
309
+ const targetIndex = Math.max(0, Math.min(pending.index, countValue - 1));
310
+ if (targetIndex < startIndex || targetIndex > endIndex) {
311
+ releaseSettle(el.scrollTop);
312
+ return;
313
+ }
314
+ releaseSettle(el.scrollTop);
315
+ });
316
+ }, [releaseSettle, countRef, overscanRef]);
317
+ const handleScroll = react.useCallback(() => {
318
+ if (rafRef.current !== null)
319
+ cancelAnimationFrame(rafRef.current);
320
+ rafRef.current = requestAnimationFrame(() => {
321
+ rafRef.current = null;
322
+ const el = scrollElementRef.current;
323
+ const tree = fenwickRef.current;
324
+ if (!el || !tree)
325
+ return;
326
+ const pending = pendingTargetRef.current;
327
+ if (pending !== null) {
328
+ const { scale } = getScale(tree.total());
329
+ const { targetScrollTop } = computeTargetScrollTop(
330
+ tree,
331
+ pending.index,
332
+ pending.align,
333
+ el.clientHeight,
334
+ scale,
335
+ countRef.current
336
+ );
337
+ const kind = classifySettleScroll(
338
+ el.scrollTop,
339
+ targetScrollTop,
340
+ treeVersionRef.current,
341
+ versionAtJumpRef.current
342
+ );
343
+ if (kind === "user") {
344
+ releaseSettle(el.scrollTop);
345
+ return;
346
+ }
347
+ if (kind === "stale-tree")
348
+ versionAtJumpRef.current = treeVersionRef.current;
349
+ }
350
+ setScrollOffset({ value: el.scrollTop });
351
+ });
352
+ }, [releaseSettle, countRef]);
353
+ const scrollRef = react.useCallback(
354
+ (element) => {
355
+ if (scrollElementRef.current)
356
+ scrollElementRef.current.removeEventListener("scroll", handleScroll);
357
+ containerObserverRef.current?.disconnect();
358
+ containerObserverRef.current = null;
359
+ scrollElementRef.current = element;
360
+ if (element) {
361
+ element.addEventListener("scroll", handleScroll, { passive: true });
362
+ if (typeof ResizeObserver !== "undefined") {
363
+ const observer = new ResizeObserver(() => {
364
+ setScrollOffset((prevState) => ({ ...prevState }));
365
+ });
366
+ observer.observe(element);
367
+ containerObserverRef.current = observer;
368
+ }
369
+ }
370
+ setScrollOffset(element ? { value: element?.scrollTop } : { value: 0 });
371
+ },
372
+ [handleScroll]
373
+ );
374
+ const { virtualItems, scrollHeight } = react.useMemo(
375
+ () => computeItems(scrollOffset.value),
376
+ [computeItems, scrollOffset]
377
+ );
378
+ const jumpToIndex = react.useCallback(
379
+ (index, align) => {
380
+ if (rafRef.current !== null) {
381
+ cancelAnimationFrame(rafRef.current);
382
+ rafRef.current = null;
383
+ }
384
+ if (tickRafRef.current !== null) {
385
+ cancelAnimationFrame(tickRafRef.current);
386
+ tickRafRef.current = null;
387
+ }
388
+ settleFramesRef.current = 0;
389
+ quietFramesRef.current = 0;
390
+ pendingTargetRef.current = { index, align };
391
+ versionAtJumpRef.current = treeVersionRef.current;
392
+ const rangeStart = Math.max(index - SETTLE_WINDOW, 0);
393
+ const rangeEnd = Math.min(index + SETTLE_WINDOW + 1, count);
394
+ setForcedRange({ start: rangeStart, end: rangeEnd });
395
+ setScrollOffset((prevState) => ({ ...prevState }));
396
+ scheduleTick();
397
+ },
398
+ [count, scheduleTick]
399
+ );
400
+ const scrollToIndex = react.useCallback(
401
+ (index, options) => {
402
+ if (index < 0 || index >= count)
403
+ return;
404
+ jumpToIndex(index, options?.align ?? "start");
405
+ },
406
+ [count, jumpToIndex]
407
+ );
408
+ return { virtualItems: virtualItems ?? [], scrollHeight, scrollToIndex, scrollRef, getMeasureRef };
409
+ }
410
+ exports.useVirtualizer = useVirtualizer;
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { useVirtualizer, type Options, type ScrollAlign, type VirtualItem } from './hooks/useVirtualizer';
package/lib/index.js ADDED
@@ -0,0 +1,410 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => {
4
+ __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
5
+ return value;
6
+ };
7
+ import { useRef, useEffect, useState, useCallback, useMemo } from "react";
8
+ const useLatest = (value) => {
9
+ const ref = useRef(value);
10
+ useEffect(() => {
11
+ ref.current = value;
12
+ }, [value]);
13
+ return ref;
14
+ };
15
+ class FenwickTree {
16
+ constructor(sizes) {
17
+ __publicField(this, "tree");
18
+ __publicField(this, "n");
19
+ __publicField(this, "logn");
20
+ this.n = sizes.length;
21
+ this.tree = new Float64Array(this.n + 1);
22
+ for (let i = 0; i < this.n; i++)
23
+ this.tree[i + 1] = sizes[i];
24
+ for (let x = 1; x <= this.n; x++) {
25
+ const parent = x + (x & -x);
26
+ if (parent <= this.n)
27
+ this.tree[parent] += this.tree[x];
28
+ }
29
+ this.logn = this.n > 0 ? Math.floor(Math.log2(this.n)) : 0;
30
+ }
31
+ update(i, delta) {
32
+ for (let x = i + 1; x <= this.n; x += x & -x)
33
+ this.tree[x] += delta;
34
+ }
35
+ prefixSum(i) {
36
+ let sum = 0;
37
+ for (let x = i; x > 0; x -= x & -x)
38
+ sum += this.tree[x];
39
+ return sum;
40
+ }
41
+ total() {
42
+ return this.prefixSum(this.n);
43
+ }
44
+ findByPrefixSum(target) {
45
+ let pos = 0;
46
+ let remaining = target;
47
+ const EPS = 1e-6;
48
+ for (let pw = 1 << this.logn; pw > 0; pw >>= 1) {
49
+ const next = pos + pw;
50
+ if (next <= this.n && this.tree[next] <= remaining + EPS) {
51
+ pos = next;
52
+ remaining -= this.tree[next];
53
+ }
54
+ }
55
+ return pos;
56
+ }
57
+ }
58
+ const DEFAULT_OVERSCAN = 3;
59
+ const SCROLL_CONVERGED_THRESHOLD = 0.5;
60
+ const SETTLE_QUIET_FRAMES = 3;
61
+ const SETTLE_MAX_FRAMES = 180;
62
+ const SETTLE_WINDOW = 40;
63
+ const DEFAULT_SIZE = 24;
64
+ const SAFE_MAX_HEIGHT = 15e6;
65
+ const getScale = (logicalTotal) => {
66
+ if (logicalTotal <= SAFE_MAX_HEIGHT)
67
+ return { scale: 1, physicalTotal: logicalTotal };
68
+ return { scale: SAFE_MAX_HEIGHT / logicalTotal, physicalTotal: SAFE_MAX_HEIGHT };
69
+ };
70
+ const getFenwickTree = (count, estimateSize) => {
71
+ const sizes = new Float64Array(count);
72
+ for (let i = 0; i < count; i++)
73
+ sizes[i] = estimateSize?.(i) || DEFAULT_SIZE;
74
+ return new FenwickTree(sizes);
75
+ };
76
+ const classifySettleScroll = (scrollTop, targetScrollTop, version, baseVersion) => {
77
+ if (Math.abs(scrollTop - targetScrollTop) <= SCROLL_CONVERGED_THRESHOLD)
78
+ return "converged";
79
+ return version !== baseVersion ? "stale-tree" : "user";
80
+ };
81
+ const getViewportRange = (tree, logicalScrollOffset, containerHeight, scale, overscanValue, countValue) => {
82
+ const viewportStart = tree.findByPrefixSum(logicalScrollOffset);
83
+ const viewportEnd = tree.findByPrefixSum(logicalScrollOffset + containerHeight / scale);
84
+ return {
85
+ startIndex: Math.max(viewportStart - overscanValue, 0),
86
+ endIndex: Math.min(viewportEnd + overscanValue + 1, countValue),
87
+ viewportStart,
88
+ viewportEnd
89
+ };
90
+ };
91
+ const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count) => {
92
+ const targetIndex = Math.max(0, Math.min(index, count - 1));
93
+ const targetOffset = tree.prefixSum(targetIndex);
94
+ const itemLogicalSize = tree.prefixSum(targetIndex + 1) - targetOffset;
95
+ let logicalScrollOffset;
96
+ switch (align) {
97
+ case "center":
98
+ logicalScrollOffset = targetOffset + (itemLogicalSize - containerHeight / scale) / 2;
99
+ break;
100
+ case "end":
101
+ logicalScrollOffset = targetOffset + itemLogicalSize - containerHeight / scale;
102
+ break;
103
+ default:
104
+ logicalScrollOffset = targetOffset;
105
+ }
106
+ return { targetScrollTop: Math.max(0, logicalScrollOffset * scale), logicalScrollOffset };
107
+ };
108
+ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
109
+ const [scrollOffset, setScrollOffset] = useState({ value: 0 });
110
+ const [forcedRange, setForcedRange] = useState(null);
111
+ const scrollElementRef = useRef(null);
112
+ const rafRef = useRef(null);
113
+ const observersRef = useRef(/* @__PURE__ */ new Map());
114
+ const containerObserverRef = useRef(null);
115
+ const prevCountRef = useRef(count);
116
+ const fenwickRef = useRef(null);
117
+ const refCacheRef = useRef(/* @__PURE__ */ new Map());
118
+ const countRef = useLatest(count);
119
+ const overscanRef = useLatest(overscan);
120
+ const pendingTargetRef = useRef(null);
121
+ const tickRafRef = useRef(null);
122
+ const settleFramesRef = useRef(0);
123
+ const quietFramesRef = useRef(0);
124
+ const treeVersionRef = useRef(0);
125
+ const versionAtJumpRef = useRef(0);
126
+ if (fenwickRef.current === null || !fenwickRef.current.total())
127
+ fenwickRef.current = getFenwickTree(count, estimateSize);
128
+ if (prevCountRef.current !== count) {
129
+ fenwickRef.current = getFenwickTree(count, estimateSize);
130
+ prevCountRef.current = count;
131
+ }
132
+ const computeItems = useCallback(
133
+ (physicalScrollOffset) => {
134
+ const el = scrollElementRef.current;
135
+ const tree = fenwickRef.current;
136
+ if (!el || count === 0 || !tree)
137
+ return { virtualItems: [], scrollHeight: 0 };
138
+ const containerHeight = el.clientHeight;
139
+ const logicalTotal = tree.total();
140
+ const { scale, physicalTotal } = getScale(logicalTotal);
141
+ const pendingTarget = pendingTargetRef.current;
142
+ let logicalScrollOffset;
143
+ if (pendingTarget !== null) {
144
+ const { targetScrollTop, logicalScrollOffset: targetLogicalOffset } = computeTargetScrollTop(
145
+ tree,
146
+ pendingTarget.index,
147
+ pendingTarget.align,
148
+ containerHeight,
149
+ scale,
150
+ count
151
+ );
152
+ logicalScrollOffset = targetLogicalOffset;
153
+ if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
154
+ el.scrollTop = targetScrollTop;
155
+ }
156
+ } else {
157
+ logicalScrollOffset = physicalScrollOffset / scale;
158
+ }
159
+ let startIndex;
160
+ let endIndex;
161
+ if (forcedRange) {
162
+ startIndex = forcedRange.start;
163
+ endIndex = forcedRange.end;
164
+ } else {
165
+ const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
166
+ tree,
167
+ logicalScrollOffset,
168
+ containerHeight,
169
+ scale,
170
+ overscan,
171
+ count
172
+ );
173
+ startIndex = viewportStartIndex;
174
+ endIndex = viewportEndIndex;
175
+ }
176
+ const startOffsetLogical = tree.prefixSum(startIndex);
177
+ const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
178
+ const virtualItems2 = [];
179
+ let offset = startOffsetPhysical;
180
+ for (let i = startIndex; i < endIndex; i++) {
181
+ const size = (tree.prefixSum(i + 1) - tree.prefixSum(i)) * scale;
182
+ virtualItems2.push({ index: i, start: offset, size, end: offset + size });
183
+ offset += size;
184
+ }
185
+ return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
186
+ },
187
+ [count, forcedRange, overscan]
188
+ );
189
+ const measureElement = useCallback((element, index) => {
190
+ if (!element) {
191
+ const entry = observersRef.current.get(index);
192
+ if (entry) {
193
+ entry.observer.disconnect();
194
+ observersRef.current.delete(index);
195
+ }
196
+ return;
197
+ }
198
+ const existing = observersRef.current.get(index);
199
+ if (existing && existing.element === element)
200
+ return;
201
+ if (existing) {
202
+ existing.observer.disconnect();
203
+ observersRef.current.delete(index);
204
+ }
205
+ const observer = new ResizeObserver(([entry]) => {
206
+ const height = entry?.borderBoxSize[0]?.blockSize;
207
+ if (height == null || !fenwickRef.current)
208
+ return;
209
+ const tree = fenwickRef.current;
210
+ const { scale } = getScale(tree.total());
211
+ const logicalHeight = height / scale;
212
+ const prevSize = tree.prefixSum(index + 1) - tree.prefixSum(index);
213
+ if (Math.abs(prevSize - logicalHeight) < 0.5)
214
+ return;
215
+ tree.update(index, logicalHeight - prevSize);
216
+ treeVersionRef.current += 1;
217
+ setScrollOffset((prevState) => ({ ...prevState }));
218
+ });
219
+ observer.observe(element);
220
+ observersRef.current.set(index, { observer, element });
221
+ }, []);
222
+ const getMeasureRef = useCallback(
223
+ (index) => {
224
+ let fn = refCacheRef.current.get(index);
225
+ if (!fn) {
226
+ fn = (el) => {
227
+ measureElement(el, index);
228
+ if (el === null)
229
+ refCacheRef.current.delete(index);
230
+ };
231
+ refCacheRef.current.set(index, fn);
232
+ }
233
+ return fn;
234
+ },
235
+ [measureElement]
236
+ );
237
+ const releaseSettle = useCallback((scrollTop) => {
238
+ if (tickRafRef.current !== null) {
239
+ cancelAnimationFrame(tickRafRef.current);
240
+ tickRafRef.current = null;
241
+ }
242
+ pendingTargetRef.current = null;
243
+ setForcedRange(null);
244
+ setScrollOffset({ value: scrollTop });
245
+ }, []);
246
+ const scheduleTick = useCallback(() => {
247
+ if (tickRafRef.current !== null)
248
+ return;
249
+ tickRafRef.current = requestAnimationFrame(() => {
250
+ tickRafRef.current = null;
251
+ const el = scrollElementRef.current;
252
+ const tree = fenwickRef.current;
253
+ const pending = pendingTargetRef.current;
254
+ if (!el || !tree || !pending)
255
+ return;
256
+ const countValue = countRef.current;
257
+ const overscanValue = overscanRef.current;
258
+ settleFramesRef.current += 1;
259
+ if (settleFramesRef.current > SETTLE_MAX_FRAMES) {
260
+ releaseSettle(el.scrollTop);
261
+ return;
262
+ }
263
+ const { scale } = getScale(tree.total());
264
+ const { targetScrollTop } = computeTargetScrollTop(
265
+ tree,
266
+ pending.index,
267
+ pending.align,
268
+ el.clientHeight,
269
+ scale,
270
+ countValue
271
+ );
272
+ const kind = classifySettleScroll(
273
+ el.scrollTop,
274
+ targetScrollTop,
275
+ treeVersionRef.current,
276
+ versionAtJumpRef.current
277
+ );
278
+ if (kind === "user") {
279
+ releaseSettle(el.scrollTop);
280
+ return;
281
+ }
282
+ if (kind === "stale-tree") {
283
+ versionAtJumpRef.current = treeVersionRef.current;
284
+ quietFramesRef.current = 0;
285
+ setScrollOffset((prevState) => ({ ...prevState }));
286
+ scheduleTick();
287
+ return;
288
+ }
289
+ if (treeVersionRef.current !== versionAtJumpRef.current) {
290
+ versionAtJumpRef.current = treeVersionRef.current;
291
+ quietFramesRef.current = 0;
292
+ } else {
293
+ quietFramesRef.current += 1;
294
+ }
295
+ if (quietFramesRef.current < SETTLE_QUIET_FRAMES) {
296
+ scheduleTick();
297
+ return;
298
+ }
299
+ const { startIndex, endIndex } = getViewportRange(
300
+ tree,
301
+ el.scrollTop / scale,
302
+ el.clientHeight,
303
+ scale,
304
+ overscanValue,
305
+ countValue
306
+ );
307
+ const targetIndex = Math.max(0, Math.min(pending.index, countValue - 1));
308
+ if (targetIndex < startIndex || targetIndex > endIndex) {
309
+ releaseSettle(el.scrollTop);
310
+ return;
311
+ }
312
+ releaseSettle(el.scrollTop);
313
+ });
314
+ }, [releaseSettle, countRef, overscanRef]);
315
+ const handleScroll = useCallback(() => {
316
+ if (rafRef.current !== null)
317
+ cancelAnimationFrame(rafRef.current);
318
+ rafRef.current = requestAnimationFrame(() => {
319
+ rafRef.current = null;
320
+ const el = scrollElementRef.current;
321
+ const tree = fenwickRef.current;
322
+ if (!el || !tree)
323
+ return;
324
+ const pending = pendingTargetRef.current;
325
+ if (pending !== null) {
326
+ const { scale } = getScale(tree.total());
327
+ const { targetScrollTop } = computeTargetScrollTop(
328
+ tree,
329
+ pending.index,
330
+ pending.align,
331
+ el.clientHeight,
332
+ scale,
333
+ countRef.current
334
+ );
335
+ const kind = classifySettleScroll(
336
+ el.scrollTop,
337
+ targetScrollTop,
338
+ treeVersionRef.current,
339
+ versionAtJumpRef.current
340
+ );
341
+ if (kind === "user") {
342
+ releaseSettle(el.scrollTop);
343
+ return;
344
+ }
345
+ if (kind === "stale-tree")
346
+ versionAtJumpRef.current = treeVersionRef.current;
347
+ }
348
+ setScrollOffset({ value: el.scrollTop });
349
+ });
350
+ }, [releaseSettle, countRef]);
351
+ const scrollRef = useCallback(
352
+ (element) => {
353
+ if (scrollElementRef.current)
354
+ scrollElementRef.current.removeEventListener("scroll", handleScroll);
355
+ containerObserverRef.current?.disconnect();
356
+ containerObserverRef.current = null;
357
+ scrollElementRef.current = element;
358
+ if (element) {
359
+ element.addEventListener("scroll", handleScroll, { passive: true });
360
+ if (typeof ResizeObserver !== "undefined") {
361
+ const observer = new ResizeObserver(() => {
362
+ setScrollOffset((prevState) => ({ ...prevState }));
363
+ });
364
+ observer.observe(element);
365
+ containerObserverRef.current = observer;
366
+ }
367
+ }
368
+ setScrollOffset(element ? { value: element?.scrollTop } : { value: 0 });
369
+ },
370
+ [handleScroll]
371
+ );
372
+ const { virtualItems, scrollHeight } = useMemo(
373
+ () => computeItems(scrollOffset.value),
374
+ [computeItems, scrollOffset]
375
+ );
376
+ const jumpToIndex = useCallback(
377
+ (index, align) => {
378
+ if (rafRef.current !== null) {
379
+ cancelAnimationFrame(rafRef.current);
380
+ rafRef.current = null;
381
+ }
382
+ if (tickRafRef.current !== null) {
383
+ cancelAnimationFrame(tickRafRef.current);
384
+ tickRafRef.current = null;
385
+ }
386
+ settleFramesRef.current = 0;
387
+ quietFramesRef.current = 0;
388
+ pendingTargetRef.current = { index, align };
389
+ versionAtJumpRef.current = treeVersionRef.current;
390
+ const rangeStart = Math.max(index - SETTLE_WINDOW, 0);
391
+ const rangeEnd = Math.min(index + SETTLE_WINDOW + 1, count);
392
+ setForcedRange({ start: rangeStart, end: rangeEnd });
393
+ setScrollOffset((prevState) => ({ ...prevState }));
394
+ scheduleTick();
395
+ },
396
+ [count, scheduleTick]
397
+ );
398
+ const scrollToIndex = useCallback(
399
+ (index, options) => {
400
+ if (index < 0 || index >= count)
401
+ return;
402
+ jumpToIndex(index, options?.align ?? "start");
403
+ },
404
+ [count, jumpToIndex]
405
+ );
406
+ return { virtualItems: virtualItems ?? [], scrollHeight, scrollToIndex, scrollRef, getMeasureRef };
407
+ }
408
+ export {
409
+ useVirtualizer
410
+ };
package/lib/utils.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { FenwickTree } from './fenwickTree';
2
+ type SettleScroll = 'converged' | 'stale-tree' | 'user';
3
+ export declare const getScale: (logicalTotal: number) => {
4
+ scale: number;
5
+ physicalTotal: number;
6
+ };
7
+ export declare const getFenwickTree: (count: number, estimateSize?: ((index: number) => number) | undefined) => FenwickTree;
8
+ export declare const classifySettleScroll: (scrollTop: number, targetScrollTop: number, version: number, baseVersion: number) => SettleScroll;
9
+ export declare const getViewportRange: (tree: FenwickTree, logicalScrollOffset: number, containerHeight: number, scale: number, overscanValue: number, countValue: number) => {
10
+ startIndex: number;
11
+ endIndex: number;
12
+ viewportStart: number;
13
+ viewportEnd: number;
14
+ };
15
+ export declare const computeTargetScrollTop: (tree: FenwickTree, index: number, align: 'start' | 'center' | 'end', containerHeight: number, scale: number, count: number) => {
16
+ targetScrollTop: number;
17
+ logicalScrollOffset: number;
18
+ };
19
+ export {};
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "clear-virtualizer",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight React hook for virtualized lists and tables with dynamic row heights, measured automatically",
5
+ "type": "module",
6
+ "main": "./lib/index.cjs",
7
+ "module": "./lib/index.js",
8
+ "types": "./lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "import": "./lib/index.js",
13
+ "require": "./lib/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "sideEffects": false,
22
+ "author": {
23
+ "name": "Andrew Bubnov"
24
+ },
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/AndrewBubnov/virtualize#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/AndrewBubnov/virtualize/issues"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/AndrewBubnov/virtualize.git"
33
+ },
34
+ "keywords": [
35
+ "react",
36
+ "virtualized",
37
+ "virtualization",
38
+ "virtual-list",
39
+ "react-virtualized",
40
+ "react-window",
41
+ "windowing",
42
+ "infinite-scroll",
43
+ "large-lists",
44
+ "performance",
45
+ "scroll",
46
+ "dom-virtualization",
47
+ "resize-observer",
48
+ "dynamic-heights",
49
+ "layout-engine",
50
+ "ui-performance",
51
+ "frontend",
52
+ "react-components",
53
+ "list-rendering",
54
+ "overscan",
55
+ "table"
56
+ ],
57
+ "scripts": {
58
+ "dev": "vite",
59
+ "build": "tsc && vite build",
60
+ "build:lib": "vite build --config src/vite.config.ts && tsc --emitDeclarationOnly -p tsconfig.lib.json",
61
+ "prepublishOnly": "npm run build:lib",
62
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
63
+ "preview": "vite preview",
64
+ "test": "vitest run",
65
+ "test:watch": "vitest",
66
+ "test:e2e": "node src/__tests__/e2e/repro-scroll.mjs"
67
+ },
68
+ "peerDependencies": {
69
+ "react": ">=16.8.0",
70
+ "react-dom": ">=16.8.0"
71
+ },
72
+ "devDependencies": {
73
+ "@playwright/test": "^1.63.0",
74
+ "@tanstack/react-table": "^8.21.3",
75
+ "@testing-library/jest-dom": "^7.0.1",
76
+ "@testing-library/react": "^16.3.3",
77
+ "@testing-library/user-event": "^14.6.7",
78
+ "@types/node": "^26.6.2",
79
+ "@types/react": "^18.2.15",
80
+ "@types/react-dom": "^18.2.7",
81
+ "@typescript-eslint/eslint-plugin": "^6.0.0",
82
+ "@typescript-eslint/parser": "^6.0.0",
83
+ "@vitejs/plugin-react": "^4.0.3",
84
+ "eslint": "^8.45.0",
85
+ "eslint-plugin-react-hooks": "^4.6.0",
86
+ "eslint-plugin-react-refresh": "^0.4.3",
87
+ "jsdom": "^30.0.1",
88
+ "lorem-ipsum": "^2.0.8",
89
+ "react": "^18.2.0",
90
+ "react-dom": "^18.2.0",
91
+ "typescript": "^5.0.2",
92
+ "vite": "^4.4.5",
93
+ "vite-plugin-eslint": "^1.8.1",
94
+ "vite-tsconfig-paths": "^4.2.0",
95
+ "vitest": "^1.6.1"
96
+ }
97
+ }