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.
@@ -1,37 +1,43 @@
1
- import { useCallback, useRef, useEffect, useState, } from 'react';
1
+ import { useCallback, useEffect, useRef, useState, } from "react";
2
2
  /**
3
- * Infinite Scroll Hook
3
+ * useInfiniteScroll
4
4
  *
5
- * Automatically triggers `onLoadMore`
6
- * when user scrolls near the bottom.
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 hasCalledRef = useRef(false);
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 || isLoading)
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 - scrollTop - clientHeight;
24
- const atBottom = distanceFromBottom <= threshold;
25
- setIsAtBottom(atBottom);
26
- if (atBottom &&
27
- !hasCalledRef.current &&
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
- hasCalledRef.current = true;
31
+ loadingRef.current = true;
30
32
  Promise.resolve(onLoadMoreRef.current()).finally(() => {
31
- hasCalledRef.current = false;
33
+ loadingRef.current = false;
32
34
  });
33
35
  }
34
- }, [threshold, isLoading, enabled]);
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('scroll', handleScroll, { passive: true });
50
+ container.addEventListener("scroll", handleScroll, { passive: true });
45
51
  // Initial check
46
52
  checkIfAtBottom();
47
53
  return () => {
48
- container.removeEventListener('scroll', handleScroll);
54
+ container.removeEventListener("scroll", handleScroll);
49
55
  };
50
- }, [checkIfAtBottom, enabled]);
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,SAAS,EACT,QAAQ,GACX,MAAM,OAAO,CAAC;AAmCf;;;;;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,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAEpD,MAAM,YAAY,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IACnC,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,IAAI,SAAS;YAAE,OAAO;QAElC,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,GAAG,SAAS,GAAG,YAAY,CAAC;QAE5C,MAAM,QAAQ,GAAG,kBAAkB,IAAI,SAAS,CAAC;QAEjD,aAAa,CAAC,QAAQ,CAAC,CAAC;QAExB,IACI,QAAQ;YACR,CAAC,YAAY,CAAC,OAAO;YACrB,CAAC,SAAS,EACZ,CAAC;YACC,YAAY,CAAC,OAAO,GAAG,IAAI,CAAC;YAE5B,OAAO,CAAC,OAAO,CACX,aAAa,CAAC,OAAO,EAAE,CAC1B,CAAC,OAAO,CAAC,GAAG,EAAE;gBACX,YAAY,CAAC,OAAO,GAAG,KAAK,CAAC;YACjC,CAAC,CAAC,CAAC;QACP,CAAC;IACL,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;IAEpC,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,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC,CAAC;IAE/B,OAAO;QACH,YAAY;QACZ,UAAU;QACV,SAAS;QACT,eAAe;KAClB,CAAC;AACN,CAAC"}
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
- interface UseVirtualListParams<T> {
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,EAKH,aAAa,EACb,gBAAgB,EACnB,MAAM,OAAO,CAAC;AAKf,OAAO,EACH,WAAW,EACX,eAAe,EAClB,MAAM,UAAU,CAAC;AAElB,UAAU,oBAAoB,CAAC,CAAC;IAC5B,KAAK,EAAE,CAAC,EAAE,CAAC;IAEX,UAAU,CAAC,EAAE,CACT,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,CAAC,KACN,MAAM,GAAG,MAAM,CAAC;IAErB,eAAe,EAAE,MAAM,CAAC;IAExB,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB,QAAQ,CAAC,EACP,MAAM,GACN,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAE9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,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;CAC5B;AAED,UAAU,oBAAoB;IAC1B,YAAY,EAAE,WAAW,EAAE,CAAC;IAE5B,WAAW,EAAE,OAAO,CAAC;IAErB,YAAY,EAAE,MAAM,CAAC;IAErB,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;CACtB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,CAAC,GAAG,GAAG,EAClC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAChC,oBAAoB,CAuZtB"}
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, scrollDirection = 'forward', estimatedItemSize = 35, onScroll, onScrollEnd, stickyIndices = [], } = params;
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
- * Core systems
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 engineRef = useRef(new VirtualizationEngine(estimatedItemSize));
13
- const measurementRef = useRef(new MeasurementSystem());
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, setScrollOffset] = useState(0);
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
- const scrollDirectionRef = useRef(scrollDirection);
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
- const visibleItems = engine.getVisibleItems(scrollOffset, containerHeight, items.length, overscan);
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: stickyIndices.includes(item.index),
119
+ isSticky: sticky.includes(item.index),
42
120
  }));
43
121
  /**
44
- * Prevent infinite re-renders
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.index ||
55
- prevItem.offset !==
56
- nextItem.offset ||
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
- containerHeight,
146
+ resolvedViewportSize,
74
147
  items.length,
75
148
  overscan,
76
- stickyIndices,
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 handleScroll = useCallback((e) => {
155
+ const handleScrollEvent = useCallback((e) => {
82
156
  const target = e.currentTarget;
83
- const newScrollOffset = target.scrollTop;
84
- /**
85
- * Skip duplicate updates
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
- scrollDirectionRef.current =
99
- direction;
100
- lastScrollOffsetRef.current =
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
- setTimeout(() => {
118
- setIsScrolling(false);
119
- if (onScrollEnd) {
120
- onScrollEnd();
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
- const listener = (e) => {
133
- handleScroll(e);
134
- };
135
- container.addEventListener('scroll', listener);
188
+ container.addEventListener('scroll', handleScrollEvent, {
189
+ passive: true,
190
+ });
136
191
  return () => {
137
- container.removeEventListener('scroll', listener);
192
+ container.removeEventListener('scroll', handleScrollEvent);
138
193
  };
139
- }, [handleScroll]);
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
- measurementRef.current.startMeasuring(container, handleItemMeasure);
218
+ measurement.startMeasuring(container, handleItemMeasure);
153
219
  }
154
220
  return () => {
155
- measurementRef.current.stopMeasuring();
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
- const offset = engineRef.current.getScrollOffsetForIndex(index, containerHeight, alignment);
163
- if (containerRef.current) {
164
- containerRef.current.scrollTop =
165
- offset;
228
+ if (!resolvedViewportSize) {
229
+ return;
166
230
  }
167
- }, [containerHeight]);
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
- if (containerRef.current) {
173
- containerRef.current.scrollTop =
174
- offset;
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 = stickyIndices.includes(index);
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
- position: isStickyItem
185
- ? 'sticky'
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
- }, [stickyIndices]);
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.destroy();
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