react-virtual-renderer 1.1.1 → 1.1.3
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/dist/components/VirtualGrid.d.ts.map +1 -1
- package/dist/components/VirtualGrid.js +10 -9
- package/dist/components/VirtualGrid.js.map +1 -1
- package/dist/core/MeasurementSystem.d.ts +54 -6
- package/dist/core/MeasurementSystem.d.ts.map +1 -1
- package/dist/core/MeasurementSystem.js +113 -9
- package/dist/core/MeasurementSystem.js.map +1 -1
- package/dist/core/VirtualizationEngine.d.ts +12 -0
- package/dist/core/VirtualizationEngine.d.ts.map +1 -1
- package/dist/core/VirtualizationEngine.js +44 -1
- package/dist/core/VirtualizationEngine.js.map +1 -1
- package/dist/hooks/useInfiniteScroll.d.ts +8 -9
- package/dist/hooks/useInfiniteScroll.d.ts.map +1 -1
- package/dist/hooks/useInfiniteScroll.js +26 -17
- package/dist/hooks/useInfiniteScroll.js.map +1 -1
- package/dist/hooks/useVirtualList.d.ts +82 -4
- package/dist/hooks/useVirtualList.d.ts.map +1 -1
- package/dist/hooks/useVirtualList.js +220 -92
- package/dist/hooks/useVirtualList.js.map +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,37 +1,43 @@
|
|
|
1
|
-
import { useCallback,
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, } from "react";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* useInfiniteScroll
|
|
4
4
|
*
|
|
5
|
-
* Automatically
|
|
6
|
-
*
|
|
5
|
+
* Automatically calls onLoadMore when the user
|
|
6
|
+
* scrolls near the bottom of a container.
|
|
7
7
|
*/
|
|
8
8
|
export function useInfiniteScroll({ onLoadMore, threshold = 500, isLoading = false, enabled = true, }) {
|
|
9
9
|
const containerRef = useRef(null);
|
|
10
10
|
const [isAtBottom, setIsAtBottom] = useState(false);
|
|
11
|
-
const
|
|
11
|
+
const loadingRef = useRef(false);
|
|
12
12
|
const onLoadMoreRef = useRef(onLoadMore);
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
onLoadMoreRef.current = onLoadMore;
|
|
15
15
|
}, [onLoadMore]);
|
|
16
16
|
const checkIfAtBottom = useCallback(() => {
|
|
17
|
-
if (!enabled
|
|
17
|
+
if (!enabled)
|
|
18
18
|
return;
|
|
19
19
|
const container = containerRef.current;
|
|
20
20
|
if (!container)
|
|
21
21
|
return;
|
|
22
22
|
const { scrollTop, scrollHeight, clientHeight, } = container;
|
|
23
|
-
const distanceFromBottom = scrollHeight -
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
const distanceFromBottom = scrollHeight -
|
|
24
|
+
scrollTop -
|
|
25
|
+
clientHeight;
|
|
26
|
+
const reachedBottom = distanceFromBottom <= threshold;
|
|
27
|
+
setIsAtBottom(reachedBottom);
|
|
28
|
+
if (reachedBottom &&
|
|
29
|
+
!loadingRef.current &&
|
|
28
30
|
!isLoading) {
|
|
29
|
-
|
|
31
|
+
loadingRef.current = true;
|
|
30
32
|
Promise.resolve(onLoadMoreRef.current()).finally(() => {
|
|
31
|
-
|
|
33
|
+
loadingRef.current = false;
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
|
-
}, [
|
|
36
|
+
}, [
|
|
37
|
+
threshold,
|
|
38
|
+
enabled,
|
|
39
|
+
isLoading,
|
|
40
|
+
]);
|
|
35
41
|
useEffect(() => {
|
|
36
42
|
if (!enabled)
|
|
37
43
|
return;
|
|
@@ -41,13 +47,16 @@ export function useInfiniteScroll({ onLoadMore, threshold = 500, isLoading = fal
|
|
|
41
47
|
const handleScroll = () => {
|
|
42
48
|
checkIfAtBottom();
|
|
43
49
|
};
|
|
44
|
-
container.addEventListener(
|
|
50
|
+
container.addEventListener("scroll", handleScroll, { passive: true });
|
|
45
51
|
// Initial check
|
|
46
52
|
checkIfAtBottom();
|
|
47
53
|
return () => {
|
|
48
|
-
container.removeEventListener(
|
|
54
|
+
container.removeEventListener("scroll", handleScroll);
|
|
49
55
|
};
|
|
50
|
-
}, [
|
|
56
|
+
}, [
|
|
57
|
+
enabled,
|
|
58
|
+
checkIfAtBottom,
|
|
59
|
+
]);
|
|
51
60
|
return {
|
|
52
61
|
containerRef,
|
|
53
62
|
isAtBottom,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useInfiniteScroll.js","sourceRoot":"","sources":["../../src/hooks/useInfiniteScroll.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,MAAM,EACN,
|
|
1
|
+
{"version":3,"file":"useInfiniteScroll.js","sourceRoot":"","sources":["../../src/hooks/useInfiniteScroll.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,SAAS,EACT,MAAM,EACN,QAAQ,GACX,MAAM,OAAO,CAAC;AAkCf;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAE/B,EACE,UAAU,EACV,SAAS,GAAG,GAAG,EACf,SAAS,GAAG,KAAK,EACjB,OAAO,GAAG,IAAI,GACQ;IACtB,MAAM,YAAY,GAAG,MAAM,CAAW,IAAI,CAAC,CAAC;IAE5C,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAC7B,QAAQ,CAAC,KAAK,CAAC,CAAC;IAEpB,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAEjC,MAAM,aAAa,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IAEzC,SAAS,CAAC,GAAG,EAAE;QACX,aAAa,CAAC,OAAO,GAAG,UAAU,CAAC;IACvC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;IAEjB,MAAM,eAAe,GAAG,WAAW,CAAC,GAAG,EAAE;QACrC,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC;QAEvC,IAAI,CAAC,SAAS;YAAE,OAAO;QAEvB,MAAM,EACF,SAAS,EACT,YAAY,EACZ,YAAY,GACf,GAAG,SAAS,CAAC;QAEd,MAAM,kBAAkB,GACpB,YAAY;YACZ,SAAS;YACT,YAAY,CAAC;QAEjB,MAAM,aAAa,GACf,kBAAkB,IAAI,SAAS,CAAC;QAEpC,aAAa,CAAC,aAAa,CAAC,CAAC;QAE7B,IACI,aAAa;YACb,CAAC,UAAU,CAAC,OAAO;YACnB,CAAC,SAAS,EACZ,CAAC;YACC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC;YAE1B,OAAO,CAAC,OAAO,CACX,aAAa,CAAC,OAAO,EAAE,CAC1B,CAAC,OAAO,CAAC,GAAG,EAAE;gBACX,UAAU,CAAC,OAAO,GAAG,KAAK,CAAC;YAC/B,CAAC,CAAC,CAAC;QACP,CAAC;IACL,CAAC,EAAE;QACC,SAAS;QACT,OAAO;QACP,SAAS;KACZ,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACX,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC;QAEvC,IAAI,CAAC,SAAS;YAAE,OAAO;QAEvB,MAAM,YAAY,GAAG,GAAG,EAAE;YACtB,eAAe,EAAE,CAAC;QACtB,CAAC,CAAC;QAEF,SAAS,CAAC,gBAAgB,CACtB,QAAQ,EACR,YAAY,EACZ,EAAE,OAAO,EAAE,IAAI,EAAE,CACpB,CAAC;QAEF,gBAAgB;QAChB,eAAe,EAAE,CAAC;QAElB,OAAO,GAAG,EAAE;YACR,SAAS,CAAC,mBAAmB,CACzB,QAAQ,EACR,YAAY,CACf,CAAC;QACN,CAAC,CAAC;IACN,CAAC,EAAE;QACC,OAAO;QACP,eAAe;KAClB,CAAC,CAAC;IAEH,OAAO;QACH,YAAY;QACZ,UAAU;QACV,SAAS;QACT,eAAe;KAClB,CAAC;AACN,CAAC"}
|
|
@@ -1,22 +1,80 @@
|
|
|
1
1
|
import { CSSProperties, MutableRefObject } from 'react';
|
|
2
2
|
import { VirtualItem, ScrollDirection } from '../types';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Axis the list scrolls along.
|
|
5
|
+
* 'vertical' -> uses scrollTop / height / top offset (default, unchanged behavior)
|
|
6
|
+
* 'horizontal' -> uses scrollLeft / width / left offset
|
|
7
|
+
*/
|
|
8
|
+
export type ScrollAxis = 'vertical' | 'horizontal';
|
|
9
|
+
export interface UseVirtualListParams<T> {
|
|
4
10
|
items: T[];
|
|
11
|
+
/**
|
|
12
|
+
* Stable key extractor. Falls back to index if omitted.
|
|
13
|
+
* (Previously accepted but unused - now wired up via
|
|
14
|
+
* getItemKeyForIndex in the return value.)
|
|
15
|
+
*/
|
|
5
16
|
getItemKey?: (index: number, item: T) => string | number;
|
|
17
|
+
/**
|
|
18
|
+
* Size of the viewport along the scroll axis.
|
|
19
|
+
* 'vertical' (default): height. 'horizontal': width.
|
|
20
|
+
*/
|
|
6
21
|
containerHeight: number;
|
|
22
|
+
/**
|
|
23
|
+
* Size of the viewport along the cross axis.
|
|
24
|
+
* 'vertical': width (rarely needed). 'horizontal': height.
|
|
25
|
+
*/
|
|
7
26
|
containerWidth?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Fixed size, or a function returning size per index. Note:
|
|
29
|
+
* the underlying VirtualizationEngine is measurement-driven
|
|
30
|
+
* (it learns sizes via ResizeObserver / ResizeObserver fallback
|
|
31
|
+
* rather than consulting a size function on every read). This
|
|
32
|
+
* value is used only as the *initial estimate* fed into the
|
|
33
|
+
* engine constructor when provided as a number; if provided as
|
|
34
|
+
* a function it is not yet consulted by the engine for offset
|
|
35
|
+
* math (kept for backward-compat / future engine support - do
|
|
36
|
+
* not rely on per-index function output overriding measured
|
|
37
|
+
* sizes today).
|
|
38
|
+
*/
|
|
8
39
|
itemSize?: number | ((index: number) => number);
|
|
9
40
|
overscan?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Scroll axis. Defaults to 'vertical' to preserve existing behavior.
|
|
43
|
+
*/
|
|
44
|
+
axis?: ScrollAxis;
|
|
45
|
+
/**
|
|
46
|
+
* @deprecated No longer consulted. Direction is now derived
|
|
47
|
+
* internally from scroll deltas and exposed via the returned
|
|
48
|
+
* `scrollDirection` value. Kept only so existing callers don't
|
|
49
|
+
* break when passing this; has no effect.
|
|
50
|
+
*/
|
|
10
51
|
scrollDirection?: ScrollDirection;
|
|
11
52
|
estimatedItemSize?: number;
|
|
12
53
|
onScroll?: (offset: number, direction: ScrollDirection) => void;
|
|
13
54
|
onScrollEnd?: () => void;
|
|
14
55
|
stickyIndices?: number[];
|
|
56
|
+
/**
|
|
57
|
+
* Debounce (ms) before isScrolling flips back to false and
|
|
58
|
+
* onScrollEnd fires. Defaults to 150 (previous hardcoded value).
|
|
59
|
+
*/
|
|
60
|
+
scrollEndDelay?: number;
|
|
61
|
+
/**
|
|
62
|
+
* If true (default), auto-measures the container's own
|
|
63
|
+
* clientHeight/clientWidth via ResizeObserver and uses that as
|
|
64
|
+
* a fallback whenever containerHeight/containerWidth are not
|
|
65
|
+
* provided or are <= 0.
|
|
66
|
+
*/
|
|
67
|
+
autoMeasureContainer?: boolean;
|
|
15
68
|
}
|
|
16
|
-
interface UseVirtualListReturn {
|
|
69
|
+
export interface UseVirtualListReturn {
|
|
17
70
|
virtualItems: VirtualItem[];
|
|
18
71
|
isScrolling: boolean;
|
|
19
72
|
scrollOffset: number;
|
|
73
|
+
/**
|
|
74
|
+
* NEW: direction of the most recent scroll movement. Previously
|
|
75
|
+
* tracked internally via a ref but never exposed.
|
|
76
|
+
*/
|
|
77
|
+
scrollDirection: ScrollDirection;
|
|
20
78
|
totalSize: number;
|
|
21
79
|
scrollToItem: (index: number, alignment?: 'start' | 'center' | 'end') => void;
|
|
22
80
|
scrollToOffset: (offset: number) => void;
|
|
@@ -24,10 +82,30 @@ interface UseVirtualListReturn {
|
|
|
24
82
|
containerRef: MutableRefObject<HTMLDivElement | null>;
|
|
25
83
|
innerRef: MutableRefObject<HTMLDivElement | null>;
|
|
26
84
|
getItemStyle: (index: number, size: number) => CSSProperties;
|
|
85
|
+
/**
|
|
86
|
+
* NEW: invalidates cached size measurements from the given
|
|
87
|
+
* index onward (or all items if omitted), forcing those indices
|
|
88
|
+
* to fall back to the estimated size until re-measured. Use
|
|
89
|
+
* when item content changes size after initial measurement
|
|
90
|
+
* (async image load, expand/collapse rows, etc).
|
|
91
|
+
*
|
|
92
|
+
* Backed by VirtualizationEngine.invalidateFrom().
|
|
93
|
+
*/
|
|
94
|
+
recalculate: (fromIndex?: number) => void;
|
|
95
|
+
/**
|
|
96
|
+
* NEW: resolves the stable key for a rendered index, using
|
|
97
|
+
* `getItemKey` when provided and falling back to index.
|
|
98
|
+
*/
|
|
99
|
+
getItemKeyForIndex: (index: number) => string | number;
|
|
27
100
|
}
|
|
28
101
|
/**
|
|
29
|
-
* Virtualized list hook
|
|
102
|
+
* Virtualized list hook. Supports vertical or horizontal axes,
|
|
103
|
+
* sticky items, measured/estimated sizing, and imperative
|
|
104
|
+
* scroll/recalculate controls.
|
|
105
|
+
*
|
|
106
|
+
* Backward compatible: every field in the original return value
|
|
107
|
+
* keeps its original name, type, and behavior. New fields
|
|
108
|
+
* (scrollDirection, recalculate, getItemKeyForIndex) are additive.
|
|
30
109
|
*/
|
|
31
110
|
export declare function useVirtualList<T = any>(params: UseVirtualListParams<T>): UseVirtualListReturn;
|
|
32
|
-
export {};
|
|
33
111
|
//# sourceMappingURL=useVirtualList.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useVirtualList.d.ts","sourceRoot":"","sources":["../../src/hooks/useVirtualList.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"useVirtualList.d.ts","sourceRoot":"","sources":["../../src/hooks/useVirtualList.ts"],"names":[],"mappings":"AAAA,OAAO,EAMH,aAAa,EACb,gBAAgB,EACnB,MAAM,OAAO,CAAC;AAKf,OAAO,EACH,WAAW,EACX,eAAe,EAClB,MAAM,UAAU,CAAC;AAElB;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,YAAY,CAAC;AAEnD,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACnC,KAAK,EAAE,CAAC,EAAE,CAAC;IAEX;;;;OAIG;IACH,UAAU,CAAC,EAAE,CACT,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,CAAC,KACN,MAAM,GAAG,MAAM,CAAC;IAErB;;;OAGG;IACH,eAAe,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EACP,MAAM,GACN,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAE9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;OAEG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAElC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B,QAAQ,CAAC,EAAE,CACP,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,eAAe,KACzB,IAAI,CAAC;IAEV,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IAEzB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACjC,YAAY,EAAE,WAAW,EAAE,CAAC;IAE5B,WAAW,EAAE,OAAO,CAAC;IAErB,YAAY,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,eAAe,EAAE,eAAe,CAAC;IAEjC,SAAS,EAAE,MAAM,CAAC;IAElB,YAAY,EAAE,CACV,KAAK,EAAE,MAAM,EACb,SAAS,CAAC,EACJ,OAAO,GACP,QAAQ,GACR,KAAK,KACV,IAAI,CAAC;IAEV,cAAc,EAAE,CACZ,MAAM,EAAE,MAAM,KACb,IAAI,CAAC;IAEV,eAAe,EAAE,CACb,MAAM,EAAE,MAAM,KACb,IAAI,CAAC;IAEV,YAAY,EAAE,gBAAgB,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAEtD,QAAQ,EAAE,gBAAgB,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAElD,YAAY,EAAE,CACV,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,KACX,aAAa,CAAC;IAEnB;;;;;;;;OAQG;IACH,WAAW,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAE1C;;;OAGG;IACH,kBAAkB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,MAAM,CAAC;CAC1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,CAAC,GAAG,GAAG,EAClC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAChC,oBAAoB,CAwdtB"}
|
|
@@ -1,29 +1,102 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect, } from 'react';
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useMemo, } from 'react';
|
|
2
2
|
import { VirtualizationEngine } from '../core/VirtualizationEngine';
|
|
3
3
|
import { MeasurementSystem } from '../core/MeasurementSystem';
|
|
4
|
+
const noop = () => { };
|
|
4
5
|
/**
|
|
5
|
-
* Virtualized list hook
|
|
6
|
+
* Virtualized list hook. Supports vertical or horizontal axes,
|
|
7
|
+
* sticky items, measured/estimated sizing, and imperative
|
|
8
|
+
* scroll/recalculate controls.
|
|
9
|
+
*
|
|
10
|
+
* Backward compatible: every field in the original return value
|
|
11
|
+
* keeps its original name, type, and behavior. New fields
|
|
12
|
+
* (scrollDirection, recalculate, getItemKeyForIndex) are additive.
|
|
6
13
|
*/
|
|
7
14
|
export function useVirtualList(params) {
|
|
8
|
-
const { items, containerHeight, overscan = 5,
|
|
15
|
+
const { items, getItemKey, containerHeight, containerWidth, overscan = 5, estimatedItemSize = 35, axis = 'vertical', onScroll = noop, onScrollEnd = noop, stickyIndices, scrollEndDelay = 150, autoMeasureContainer = true, } = params;
|
|
16
|
+
const isHorizontal = axis === 'horizontal';
|
|
9
17
|
/**
|
|
10
|
-
*
|
|
18
|
+
* Stable ref for stickyIndices so effects don't re-run every
|
|
19
|
+
* render when the caller passes a fresh inline array literal.
|
|
11
20
|
*/
|
|
12
|
-
const
|
|
13
|
-
const
|
|
21
|
+
const stickyIndicesRef = useRef(stickyIndices ?? []);
|
|
22
|
+
const stickyKey = stickyIndices ? stickyIndices.join(',') : '';
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
stickyIndicesRef.current = stickyIndices ?? [];
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [stickyKey]);
|
|
27
|
+
/**
|
|
28
|
+
* Core systems - constructed exactly once per hook instance.
|
|
29
|
+
* Lazy-init via ref guard instead of useRef(new X()), which
|
|
30
|
+
* would otherwise construct a throwaway instance on every
|
|
31
|
+
* render even though only the first one is kept.
|
|
32
|
+
*/
|
|
33
|
+
const engineRef = useRef(null);
|
|
34
|
+
if (engineRef.current === null) {
|
|
35
|
+
engineRef.current = new VirtualizationEngine(estimatedItemSize);
|
|
36
|
+
}
|
|
37
|
+
const measurementRef = useRef(null);
|
|
38
|
+
if (measurementRef.current === null) {
|
|
39
|
+
measurementRef.current = new MeasurementSystem();
|
|
40
|
+
}
|
|
14
41
|
/**
|
|
15
42
|
* DOM refs
|
|
16
43
|
*/
|
|
17
44
|
const containerRef = useRef(null);
|
|
18
45
|
const innerRef = useRef(null);
|
|
46
|
+
/**
|
|
47
|
+
* Auto-measured container size, used only as a fallback when
|
|
48
|
+
* containerHeight/containerWidth are not supplied or are <= 0.
|
|
49
|
+
*/
|
|
50
|
+
const [measuredViewport, setMeasuredViewport] = useState({ height: 0, width: 0 });
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!autoMeasureContainer) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const node = containerRef.current;
|
|
56
|
+
if (!node || typeof ResizeObserver === 'undefined') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const observer = new ResizeObserver((entries) => {
|
|
60
|
+
const entry = entries[0];
|
|
61
|
+
if (!entry)
|
|
62
|
+
return;
|
|
63
|
+
const { height, width } = entry.contentRect;
|
|
64
|
+
setMeasuredViewport((prev) => {
|
|
65
|
+
if (prev.height === height && prev.width === width) {
|
|
66
|
+
return prev;
|
|
67
|
+
}
|
|
68
|
+
return { height, width };
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
observer.observe(node);
|
|
72
|
+
return () => {
|
|
73
|
+
observer.disconnect();
|
|
74
|
+
};
|
|
75
|
+
}, [autoMeasureContainer]);
|
|
76
|
+
const resolvedViewportSize = containerHeight > 0
|
|
77
|
+
? containerHeight
|
|
78
|
+
: measuredViewport.height;
|
|
79
|
+
const resolvedCrossAxisSize = containerWidth && containerWidth > 0
|
|
80
|
+
? containerWidth
|
|
81
|
+
: measuredViewport.width;
|
|
19
82
|
/**
|
|
20
83
|
* Scroll state
|
|
21
84
|
*/
|
|
22
|
-
const [scrollOffset,
|
|
85
|
+
const [scrollOffset, setScrollOffsetState] = useState(0);
|
|
23
86
|
const [isScrolling, setIsScrolling] = useState(false);
|
|
87
|
+
const [scrollDirection, setScrollDirection] = useState('forward');
|
|
24
88
|
const scrollTimeoutRef = useRef(null);
|
|
25
89
|
const lastScrollOffsetRef = useRef(0);
|
|
26
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Bumped whenever a measurement comes in (or invalidateFrom is
|
|
92
|
+
* called), so totalSize / virtualItems recompute even though
|
|
93
|
+
* scrollOffset/containerHeight haven't changed. This fixes a
|
|
94
|
+
* real bug in the original: totalSize was read once per render
|
|
95
|
+
* body with no dependency tracking, so it went stale after
|
|
96
|
+
* async ResizeObserver measurements landed until some unrelated
|
|
97
|
+
* state update happened to trigger a re-render.
|
|
98
|
+
*/
|
|
99
|
+
const [measurementVersion, setMeasurementVersion] = useState(0);
|
|
27
100
|
/**
|
|
28
101
|
* Virtual items
|
|
29
102
|
*/
|
|
@@ -33,31 +106,31 @@ export function useVirtualList(params) {
|
|
|
33
106
|
*/
|
|
34
107
|
useEffect(() => {
|
|
35
108
|
const engine = engineRef.current;
|
|
36
|
-
|
|
109
|
+
if (!resolvedViewportSize || items.length === 0) {
|
|
110
|
+
setVirtualItems((prev) => (prev.length === 0 ? prev : []));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const visibleItems = engine.getVisibleItems(scrollOffset, resolvedViewportSize, items.length, overscan);
|
|
114
|
+
const sticky = stickyIndicesRef.current;
|
|
37
115
|
const nextVirtualItems = visibleItems.map((item) => ({
|
|
38
116
|
index: item.index,
|
|
39
117
|
offset: item.offset,
|
|
40
118
|
size: item.size,
|
|
41
|
-
isSticky:
|
|
119
|
+
isSticky: sticky.includes(item.index),
|
|
42
120
|
}));
|
|
43
121
|
/**
|
|
44
|
-
* Prevent
|
|
122
|
+
* Prevent unnecessary re-renders when content is unchanged
|
|
45
123
|
*/
|
|
46
124
|
setVirtualItems((prev) => {
|
|
47
|
-
if (prev.length ===
|
|
48
|
-
nextVirtualItems.length) {
|
|
125
|
+
if (prev.length === nextVirtualItems.length) {
|
|
49
126
|
let changed = false;
|
|
50
127
|
for (let i = 0; i < prev.length; i++) {
|
|
51
128
|
const prevItem = prev[i];
|
|
52
129
|
const nextItem = nextVirtualItems[i];
|
|
53
|
-
if (prevItem.index !==
|
|
54
|
-
nextItem.
|
|
55
|
-
prevItem.
|
|
56
|
-
|
|
57
|
-
prevItem.size !==
|
|
58
|
-
nextItem.size ||
|
|
59
|
-
prevItem.isSticky !==
|
|
60
|
-
nextItem.isSticky) {
|
|
130
|
+
if (prevItem.index !== nextItem.index ||
|
|
131
|
+
prevItem.offset !== nextItem.offset ||
|
|
132
|
+
prevItem.size !== nextItem.size ||
|
|
133
|
+
prevItem.isSticky !== nextItem.isSticky) {
|
|
61
134
|
changed = true;
|
|
62
135
|
break;
|
|
63
136
|
}
|
|
@@ -70,131 +143,179 @@ export function useVirtualList(params) {
|
|
|
70
143
|
});
|
|
71
144
|
}, [
|
|
72
145
|
scrollOffset,
|
|
73
|
-
|
|
146
|
+
resolvedViewportSize,
|
|
74
147
|
items.length,
|
|
75
148
|
overscan,
|
|
76
|
-
|
|
149
|
+
stickyKey,
|
|
150
|
+
measurementVersion,
|
|
77
151
|
]);
|
|
78
152
|
/**
|
|
79
|
-
* Handle scrolling
|
|
153
|
+
* Handle scrolling (DOM-native handler, attached via effect below)
|
|
80
154
|
*/
|
|
81
|
-
const
|
|
155
|
+
const handleScrollEvent = useCallback((e) => {
|
|
82
156
|
const target = e.currentTarget;
|
|
83
|
-
const newScrollOffset =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (newScrollOffset ===
|
|
88
|
-
lastScrollOffsetRef.current) {
|
|
157
|
+
const newScrollOffset = isHorizontal
|
|
158
|
+
? target.scrollLeft
|
|
159
|
+
: target.scrollTop;
|
|
160
|
+
if (newScrollOffset === lastScrollOffsetRef.current) {
|
|
89
161
|
return;
|
|
90
162
|
}
|
|
91
|
-
|
|
92
|
-
* Detect direction
|
|
93
|
-
*/
|
|
94
|
-
const direction = newScrollOffset >
|
|
95
|
-
lastScrollOffsetRef.current
|
|
163
|
+
const direction = newScrollOffset > lastScrollOffsetRef.current
|
|
96
164
|
? 'forward'
|
|
97
165
|
: 'reverse';
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
newScrollOffset;
|
|
102
|
-
setScrollOffset(newScrollOffset);
|
|
166
|
+
lastScrollOffsetRef.current = newScrollOffset;
|
|
167
|
+
setScrollDirection(direction);
|
|
168
|
+
setScrollOffsetState(newScrollOffset);
|
|
103
169
|
setIsScrolling(true);
|
|
104
|
-
|
|
105
|
-
* User callback
|
|
106
|
-
*/
|
|
107
|
-
if (onScroll) {
|
|
108
|
-
onScroll(newScrollOffset, direction);
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Debounce scroll end
|
|
112
|
-
*/
|
|
170
|
+
onScroll(newScrollOffset, direction);
|
|
113
171
|
if (scrollTimeoutRef.current) {
|
|
114
172
|
clearTimeout(scrollTimeoutRef.current);
|
|
115
173
|
}
|
|
116
|
-
scrollTimeoutRef.current =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
}, 150);
|
|
123
|
-
}, [onScroll, onScrollEnd]);
|
|
174
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
175
|
+
setIsScrolling(false);
|
|
176
|
+
onScrollEnd();
|
|
177
|
+
}, scrollEndDelay);
|
|
178
|
+
}, [isHorizontal, onScroll, onScrollEnd, scrollEndDelay]);
|
|
124
179
|
/**
|
|
125
|
-
* Attach scroll listener
|
|
180
|
+
* Attach scroll listener. Passive: we only read scrollTop/Left
|
|
181
|
+
* synchronously and never call preventDefault.
|
|
126
182
|
*/
|
|
127
183
|
useEffect(() => {
|
|
128
184
|
const container = containerRef.current;
|
|
129
185
|
if (!container) {
|
|
130
186
|
return;
|
|
131
187
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
135
|
-
container.addEventListener('scroll', listener);
|
|
188
|
+
container.addEventListener('scroll', handleScrollEvent, {
|
|
189
|
+
passive: true,
|
|
190
|
+
});
|
|
136
191
|
return () => {
|
|
137
|
-
container.removeEventListener('scroll',
|
|
192
|
+
container.removeEventListener('scroll', handleScrollEvent);
|
|
138
193
|
};
|
|
139
|
-
}, [
|
|
194
|
+
}, [handleScrollEvent]);
|
|
140
195
|
/**
|
|
141
|
-
* Measure item sizes
|
|
196
|
+
* Measure item sizes (height axis -> engine size; width axis ->
|
|
197
|
+
* separate width measurement, tracked by MeasurementSystem but
|
|
198
|
+
* not fed into the engine, since the engine is a 1-D size
|
|
199
|
+
* model along whichever axis is "size" for this list).
|
|
142
200
|
*/
|
|
143
201
|
const handleItemMeasure = useCallback((index, size) => {
|
|
144
202
|
engineRef.current.recordItemSize(index, size);
|
|
203
|
+
setMeasurementVersion((v) => v + 1);
|
|
145
204
|
}, []);
|
|
146
205
|
/**
|
|
147
|
-
* Start measuring
|
|
206
|
+
* Start measuring. For horizontal lists, width is the "size"
|
|
207
|
+
* axis the engine should track, so we tell MeasurementSystem
|
|
208
|
+
* to report height/width based on the resolved axis - this is
|
|
209
|
+
* wired through observeItem's axis param, set up by consumers
|
|
210
|
+
* (e.g. VirtualList) when they call observeItem directly, or
|
|
211
|
+
* defaults to 'height' here for plain startMeasuring use,
|
|
212
|
+
* preserving original vertical-only behavior exactly.
|
|
148
213
|
*/
|
|
149
214
|
useEffect(() => {
|
|
150
215
|
const container = containerRef.current;
|
|
216
|
+
const measurement = measurementRef.current;
|
|
151
217
|
if (container) {
|
|
152
|
-
|
|
218
|
+
measurement.startMeasuring(container, handleItemMeasure);
|
|
153
219
|
}
|
|
154
220
|
return () => {
|
|
155
|
-
|
|
221
|
+
measurement.stopMeasuring();
|
|
156
222
|
};
|
|
157
223
|
}, [handleItemMeasure]);
|
|
158
224
|
/**
|
|
159
225
|
* Scroll to item
|
|
160
226
|
*/
|
|
161
227
|
const scrollToItem = useCallback((index, alignment = 'start') => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
containerRef.current.scrollTop =
|
|
165
|
-
offset;
|
|
228
|
+
if (!resolvedViewportSize) {
|
|
229
|
+
return;
|
|
166
230
|
}
|
|
167
|
-
|
|
231
|
+
const offset = engineRef.current.getScrollOffsetForIndex(index, resolvedViewportSize, alignment);
|
|
232
|
+
const container = containerRef.current;
|
|
233
|
+
if (!container) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (isHorizontal) {
|
|
237
|
+
container.scrollLeft = offset;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
container.scrollTop = offset;
|
|
241
|
+
}
|
|
242
|
+
}, [resolvedViewportSize, isHorizontal]);
|
|
168
243
|
/**
|
|
169
|
-
* Scroll to offset
|
|
244
|
+
* Scroll to offset (imperative). Also updates internal state
|
|
245
|
+
* immediately so consumers calling this don't see a stale
|
|
246
|
+
* scrollOffset until the next native scroll event fires.
|
|
170
247
|
*/
|
|
171
248
|
const scrollToOffset = useCallback((offset) => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
249
|
+
const container = containerRef.current;
|
|
250
|
+
if (container) {
|
|
251
|
+
if (isHorizontal) {
|
|
252
|
+
container.scrollLeft = offset;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
container.scrollTop = offset;
|
|
256
|
+
}
|
|
175
257
|
}
|
|
258
|
+
lastScrollOffsetRef.current = offset;
|
|
259
|
+
setScrollOffsetState(offset);
|
|
260
|
+
}, [isHorizontal]);
|
|
261
|
+
/**
|
|
262
|
+
* Public setScrollOffset: state-only, mirrors original API
|
|
263
|
+
* exactly. Does not move the real scroll container - use
|
|
264
|
+
* scrollToOffset for that.
|
|
265
|
+
*/
|
|
266
|
+
const setScrollOffset = useCallback((offset) => {
|
|
267
|
+
lastScrollOffsetRef.current = offset;
|
|
268
|
+
setScrollOffsetState(offset);
|
|
176
269
|
}, []);
|
|
177
270
|
/**
|
|
178
271
|
* Get item style
|
|
179
272
|
*/
|
|
180
273
|
const getItemStyle = useCallback((index, size) => {
|
|
181
274
|
const offset = engineRef.current.getItemOffset(index);
|
|
182
|
-
const isStickyItem =
|
|
275
|
+
const isStickyItem = stickyIndicesRef.current.includes(index);
|
|
276
|
+
const base = {
|
|
277
|
+
position: isStickyItem ? 'sticky' : 'absolute',
|
|
278
|
+
zIndex: isStickyItem ? 10 : 0,
|
|
279
|
+
};
|
|
280
|
+
if (isHorizontal) {
|
|
281
|
+
return {
|
|
282
|
+
...base,
|
|
283
|
+
top: 0,
|
|
284
|
+
left: isStickyItem ? 0 : offset,
|
|
285
|
+
height: resolvedCrossAxisSize
|
|
286
|
+
? resolvedCrossAxisSize
|
|
287
|
+
: '100%',
|
|
288
|
+
width: size,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
183
291
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
: 'absolute',
|
|
187
|
-
top: isStickyItem
|
|
188
|
-
? 0
|
|
189
|
-
: offset,
|
|
292
|
+
...base,
|
|
293
|
+
top: isStickyItem ? 0 : offset,
|
|
190
294
|
left: 0,
|
|
191
295
|
width: '100%',
|
|
192
296
|
height: size,
|
|
193
|
-
zIndex: isStickyItem
|
|
194
|
-
? 10
|
|
195
|
-
: 0,
|
|
196
297
|
};
|
|
197
|
-
}, [
|
|
298
|
+
}, [isHorizontal, resolvedCrossAxisSize]);
|
|
299
|
+
/**
|
|
300
|
+
* Resolve a stable key for a given index
|
|
301
|
+
*/
|
|
302
|
+
const getItemKeyForIndex = useCallback((index) => {
|
|
303
|
+
if (getItemKey) {
|
|
304
|
+
const item = items[index];
|
|
305
|
+
if (item !== undefined) {
|
|
306
|
+
return getItemKey(index, item);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return index;
|
|
310
|
+
}, [getItemKey, items]);
|
|
311
|
+
/**
|
|
312
|
+
* Invalidate cached measurements from a given index onward.
|
|
313
|
+
* Backed by VirtualizationEngine.invalidateFrom().
|
|
314
|
+
*/
|
|
315
|
+
const recalculate = useCallback((fromIndex) => {
|
|
316
|
+
engineRef.current.invalidateFrom(fromIndex);
|
|
317
|
+
setMeasurementVersion((v) => v + 1);
|
|
318
|
+
}, []);
|
|
198
319
|
/**
|
|
199
320
|
* Cleanup
|
|
200
321
|
*/
|
|
@@ -203,17 +324,22 @@ export function useVirtualList(params) {
|
|
|
203
324
|
if (scrollTimeoutRef.current) {
|
|
204
325
|
clearTimeout(scrollTimeoutRef.current);
|
|
205
326
|
}
|
|
206
|
-
measurementRef.current
|
|
327
|
+
measurementRef.current?.destroy();
|
|
207
328
|
};
|
|
208
329
|
}, []);
|
|
209
330
|
/**
|
|
210
|
-
* Total size
|
|
331
|
+
* Total size - recomputed whenever item count, viewport, or
|
|
332
|
+
* measurements change (fixes the original bug where this was
|
|
333
|
+
* read once per render with no dependency tracking).
|
|
211
334
|
*/
|
|
212
|
-
const totalSize = engineRef.current.getTotalSize()
|
|
335
|
+
const totalSize = useMemo(() => engineRef.current.getTotalSize(),
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
+
[items.length, measurementVersion, resolvedViewportSize]);
|
|
213
338
|
return {
|
|
214
339
|
virtualItems,
|
|
215
340
|
isScrolling,
|
|
216
341
|
scrollOffset,
|
|
342
|
+
scrollDirection,
|
|
217
343
|
totalSize,
|
|
218
344
|
scrollToItem,
|
|
219
345
|
scrollToOffset,
|
|
@@ -221,6 +347,8 @@ export function useVirtualList(params) {
|
|
|
221
347
|
containerRef,
|
|
222
348
|
innerRef,
|
|
223
349
|
getItemStyle,
|
|
350
|
+
recalculate,
|
|
351
|
+
getItemKeyForIndex,
|
|
224
352
|
};
|
|
225
353
|
}
|
|
226
354
|
//# sourceMappingURL=useVirtualList.js.map
|