@humanspeak/svelte-virtual-list 0.2.5 → 0.2.6-beta.1

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.
@@ -76,53 +76,75 @@
76
76
  * - Height caching and estimation system
77
77
  * - Progressive size adjustment system
78
78
  */
79
- import type { SvelteVirtualListProps } from './types.js';
79
+ import { type SvelteVirtualListProps, type SvelteVirtualListScrollOptions } from './types.js';
80
80
  /**
81
- * A high-performance virtualized list component that efficiently renders large datasets
82
- * by only mounting DOM nodes for visible items and a small buffer. Optimized for handling
83
- * lists of 10k+ items through chunked processing and progressive initialization.
81
+ * SvelteVirtualList
84
82
  *
85
- * Props:
86
- * - `items` - Array of items to render
87
- * - `defaultEstimatedItemHeight` - Initial height estimate for items (default: 40px)
88
- * - `mode` - Scroll direction: 'topToBottom' or 'bottomToTop' (default: 'topToBottom')
89
- * - `debug` - Enable debug logging (default: false)
90
- * - `bufferSize` - Number of items to render outside visible area (default: 20)
91
- * - `containerClass` - Custom class for container element
92
- * - `viewportClass` - Custom class for viewport element
93
- * - `contentClass` - Custom class for content wrapper
94
- * - `itemsClass` - Custom class for items wrapper
95
- * - `debugFunction` - Custom debug logging function
96
- * - `testId` - Base test ID for component elements
83
+ * A high-performance, memory-efficient virtualized list component for Svelte 5.
84
+ * Renders only visible items plus a buffer, supporting dynamic item heights,
85
+ * bi-directional (top-to-bottom and bottom-to-top) scrolling, and programmatic control.
97
86
  *
98
- * Usage:
87
+ * =============================
88
+ * == Key Features ==
89
+ * =============================
90
+ * - Dynamic item height support (no fixed height required)
91
+ * - Top-to-bottom and bottom-to-top (chat-style) scrolling
92
+ * - Programmatic scrolling with flexible alignment (top, bottom, auto)
93
+ * - Smooth scrolling and buffer size configuration
94
+ * - SSR compatible and hydration-friendly
95
+ * - TypeScript and Svelte 5 runes/snippets support
96
+ * - Customizable styling via class props
97
+ * - Debug mode for development and testing
98
+ * - Optimized for large lists (10k+ items)
99
+ * - Comprehensive test coverage (unit and E2E)
100
+ *
101
+ * =============================
102
+ * == Usage Example ==
103
+ * =============================
99
104
  * ```svelte
100
105
  * <SvelteVirtualList
101
106
  * items={data}
102
- * defaultEstimatedItemHeight={40}
103
- * mode="topToBottom"
107
+ * mode="bottomToTop"
108
+ * bind:this={listRef}
104
109
  * >
105
- * {#snippet renderItem(item, index)}
106
- * <div class="item">{item.text}</div>
110
+ * {#snippet renderItem(item)}
111
+ * <div>{item.text}</div>
107
112
  * {/snippet}
108
113
  * </SvelteVirtualList>
109
114
  * ```
110
115
  *
111
- * Features:
112
- * - Dynamic height calculation
113
- * - Bidirectional scrolling
114
- * - Configurable buffer size
115
- * - Debug mode
116
- * - Custom styling
117
- * - Progressive initialization for large datasets
118
- * - Memory-optimized for 10k+ items
119
- * - Chunked processing for smooth performance
120
- * - Progress tracking during initialization
116
+ * =============================
117
+ * == Architecture Notes ==
118
+ * =============================
119
+ * - Uses a four-layer DOM structure for optimal performance
120
+ * - Only visible items + buffer are mounted in the DOM
121
+ * - Height caching and estimation for dynamic content
122
+ * - Handles resize events and dynamic content changes
123
+ * - Supports chunked initialization for very large lists
124
+ * - All scrolling logic is centralized in the scroll() method
125
+ * - Bi-directional support: mode="topToBottom" or "bottomToTop"
126
+ * - Designed for extensibility and easy debugging
127
+ *
128
+ * =============================
129
+ * == For Contributors ==
130
+ * =============================
131
+ * - Please keep all scrolling logic in the scroll() method
132
+ * - Add new features behind feature flags or as optional props
133
+ * - Write tests for all new features (see /test and /tests/scroll)
134
+ * - Use TypeScript and Svelte 5 runes for all new code
135
+ * - Document all exported functions and props with JSDoc
136
+ * - See README.md for API and usage details
137
+ * - For questions, open an issue or discussion on GitHub
138
+ *
139
+ * MIT License © Humanspeak, Inc.
121
140
  */
122
141
  declare const SvelteVirtualList: import("svelte").Component<SvelteVirtualListProps, {
123
142
  /**
124
143
  * Scrolls the virtual list to the item at the given index.
125
144
  *
145
+ * @deprecated This function is deprecated and will be removed in a future version.
146
+ * Use the new scroll method from the component instance instead.
147
+ *
126
148
  * @function scrollToIndex
127
149
  * @param index The index of the item to scroll to.
128
150
  * @param smoothScroll (default: true) Whether to use smooth scrolling.
@@ -148,6 +170,31 @@ declare const SvelteVirtualList: import("svelte").Component<SvelteVirtualListPro
148
170
  * @returns {void}
149
171
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
150
172
  */ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => void;
173
+ /**
174
+ * Scrolls the virtual list to the item at the given index using a type-based options approach.
175
+ *
176
+ * @function scroll
177
+ * @param options Configuration options for scrolling behavior.
178
+ *
179
+ * @example
180
+ * // Svelte usage:
181
+ * // In your <script> block:
182
+ * import SvelteVirtualList from './index.js';
183
+ * let virtualList;
184
+ * const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
185
+ *
186
+ * <button onclick={() => virtualList.scroll({ index: 5000 })}>
187
+ * Scroll to 5000
188
+ * </button>
189
+ * <SvelteVirtualList {items} bind:this={virtualList}>
190
+ * {#snippet renderItem(item)}
191
+ * <div>{item.text}</div>
192
+ * {/snippet}
193
+ * </SvelteVirtualList>
194
+ *
195
+ * @returns {void}
196
+ * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
197
+ */ scroll: (options: SvelteVirtualListScrollOptions) => void;
151
198
  }, "">;
152
199
  type SvelteVirtualList = ReturnType<typeof SvelteVirtualList>;
153
200
  export default SvelteVirtualList;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import SvelteVirtualList from './SvelteVirtualList.svelte';
2
- import type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps } from './types.js';
2
+ import type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps, SvelteVirtualListScrollAlign, SvelteVirtualListScrollOptions } from './types.js';
3
3
  export default SvelteVirtualList;
4
- export type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps };
4
+ export type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps, SvelteVirtualListScrollAlign, SvelteVirtualListScrollOptions };
package/dist/types.d.ts CHANGED
@@ -82,3 +82,33 @@ export type SvelteVirtualListDebugInfo = {
82
82
  processedItems: number;
83
83
  averageItemHeight: number;
84
84
  };
85
+ /**
86
+ * Alignment options for programmatic scrolling.
87
+ */
88
+ export type SvelteVirtualListScrollAlign = 'auto' | 'top' | 'bottom' | 'nearest';
89
+ /**
90
+ * Options for scrolling to a specific index in the virtual list.
91
+ */
92
+ export interface SvelteVirtualListScrollOptions {
93
+ /** The index of the item to scroll to. */
94
+ index: number;
95
+ /** Whether to use smooth scrolling animation. Default: true */
96
+ smoothScroll?: boolean;
97
+ /** Whether to throw an error if the index is out of bounds. Default: true */
98
+ shouldThrowOnBounds?: boolean;
99
+ /** Alignment for the scrolled item: 'auto', 'top', or 'bottom'. Default: 'auto' */
100
+ align?: SvelteVirtualListScrollAlign;
101
+ }
102
+ /**
103
+ * Default options for scrolling.
104
+ */
105
+ export declare const DEFAULT_SCROLL_OPTIONS: Partial<SvelteVirtualListScrollOptions>;
106
+ export type SvelteVirtualListHeightCacheItem = {
107
+ currentHeight: number;
108
+ dirty: boolean;
109
+ };
110
+ export type SvelteVirtualListHeightCache = Record<number, SvelteVirtualListHeightCacheItem>;
111
+ export type SvelteVirtualListPreviousVisibleRange = {
112
+ start: number;
113
+ end: number;
114
+ };
package/dist/types.js CHANGED
@@ -1 +1,8 @@
1
- export {};
1
+ /**
2
+ * Default options for scrolling.
3
+ */
4
+ export const DEFAULT_SCROLL_OPTIONS = {
5
+ smoothScroll: true,
6
+ shouldThrowOnBounds: true,
7
+ align: 'auto'
8
+ };
@@ -1,24 +1,23 @@
1
- import type { HeightCache } from './types.js';
2
1
  /**
3
2
  * Calculates and updates the average height of visible items with debouncing.
4
3
  *
5
4
  * This function optimizes performance by:
6
5
  * - Debouncing calculations to prevent excessive DOM reads (200ms default)
7
- * - Caching item heights to minimize recalculations
6
+ * - Caching item heights with dirty tracking to minimize recalculations
8
7
  * - Only updating when significant changes are detected (>1px difference)
9
8
  * - Early returns to prevent unnecessary processing
10
9
  *
11
10
  * Implementation details:
12
11
  * - Uses a debounce timeout to batch height calculations
13
12
  * - Tracks calculation state to prevent concurrent updates
14
- * - Caches heights in heightCache for reuse
13
+ * - Caches heights in heightCache with currentHeight and dirty flags for reuse
15
14
  * - Validates browser environment and calculation state
16
15
  * - Checks for meaningful height changes before updates
17
16
  *
18
17
  * State interactions:
19
18
  * - Updates calculatedItemHeight when significant changes occur
20
19
  * - Updates lastMeasuredIndex to track progress
21
- * - Modifies heightCache to store measured heights
20
+ * - Modifies heightCache to store measured heights with dirty tracking
22
21
  * - Uses isCalculatingHeight flag for concurrency control
23
22
  *
24
23
  * Guard clauses:
@@ -54,13 +53,14 @@ import type { HeightCache } from './types.js';
54
53
  * - Enhanced debounce timing precision
55
54
  * - Added proper cleanup for timeouts
56
55
  * - Documented all edge cases and failure modes
56
+ * - Updated to work with new HeightCache structure with dirty tracking
57
57
  *
58
58
  *
59
59
  * @param isCalculatingHeight - Flag to prevent concurrent calculations
60
60
  * @param heightUpdateTimeout - Reference to existing update timeout
61
61
  * @param visibleItemsGetter - Function to get current visible range
62
62
  * @param itemElements - Array of DOM elements to measure
63
- * @param heightCache - Cache of previously measured heights
63
+ * @param heightCache - Cache of previously measured heights with dirty tracking
64
64
  * @param lastMeasuredIndex - Index of last measured element
65
65
  * @param calculatedItemHeight - Current average height
66
66
  * @param onUpdate - Callback for height updates
@@ -70,8 +70,9 @@ import type { HeightCache } from './types.js';
70
70
  export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null, visibleItemsGetter: () => {
71
71
  start: number;
72
72
  end: number;
73
- }, itemElements: HTMLElement[], heightCache: HeightCache, lastMeasuredIndex: number, calculatedItemHeight: number, onUpdate: (result: {
73
+ }, itemElements: HTMLElement[], heightCache: Record<number, number>, lastMeasuredIndex: number, calculatedItemHeight: number, onUpdate: (result: {
74
74
  newHeight: number;
75
75
  newLastMeasuredIndex: number;
76
- updatedHeightCache: HeightCache;
77
- }) => void, debounceTime?: number) => NodeJS.Timeout | null;
76
+ updatedHeightCache: Record<number, number>;
77
+ clearedDirtyItems: Set<number>;
78
+ }) => void, debounceTime: number, dirtyItems: Set<number>) => NodeJS.Timeout | null;
@@ -1,25 +1,25 @@
1
- import { BROWSER } from 'esm-env';
2
1
  import { calculateAverageHeight } from './virtualList.js';
2
+ import { BROWSER } from 'esm-env';
3
3
  /**
4
4
  * Calculates and updates the average height of visible items with debouncing.
5
5
  *
6
6
  * This function optimizes performance by:
7
7
  * - Debouncing calculations to prevent excessive DOM reads (200ms default)
8
- * - Caching item heights to minimize recalculations
8
+ * - Caching item heights with dirty tracking to minimize recalculations
9
9
  * - Only updating when significant changes are detected (>1px difference)
10
10
  * - Early returns to prevent unnecessary processing
11
11
  *
12
12
  * Implementation details:
13
13
  * - Uses a debounce timeout to batch height calculations
14
14
  * - Tracks calculation state to prevent concurrent updates
15
- * - Caches heights in heightCache for reuse
15
+ * - Caches heights in heightCache with currentHeight and dirty flags for reuse
16
16
  * - Validates browser environment and calculation state
17
17
  * - Checks for meaningful height changes before updates
18
18
  *
19
19
  * State interactions:
20
20
  * - Updates calculatedItemHeight when significant changes occur
21
21
  * - Updates lastMeasuredIndex to track progress
22
- * - Modifies heightCache to store measured heights
22
+ * - Modifies heightCache to store measured heights with dirty tracking
23
23
  * - Uses isCalculatingHeight flag for concurrency control
24
24
  *
25
25
  * Guard clauses:
@@ -55,13 +55,14 @@ import { calculateAverageHeight } from './virtualList.js';
55
55
  * - Enhanced debounce timing precision
56
56
  * - Added proper cleanup for timeouts
57
57
  * - Documented all edge cases and failure modes
58
+ * - Updated to work with new HeightCache structure with dirty tracking
58
59
  *
59
60
  *
60
61
  * @param isCalculatingHeight - Flag to prevent concurrent calculations
61
62
  * @param heightUpdateTimeout - Reference to existing update timeout
62
63
  * @param visibleItemsGetter - Function to get current visible range
63
64
  * @param itemElements - Array of DOM elements to measure
64
- * @param heightCache - Cache of previously measured heights
65
+ * @param heightCache - Cache of previously measured heights with dirty tracking
65
66
  * @param lastMeasuredIndex - Index of last measured element
66
67
  * @param calculatedItemHeight - Current average height
67
68
  * @param onUpdate - Callback for height updates
@@ -70,20 +71,23 @@ import { calculateAverageHeight } from './virtualList.js';
70
71
  */
71
72
  export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItemsGetter, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
72
73
  /* trunk-ignore(eslint/no-unused-vars) */
73
- onUpdate, debounceTime = 200) => {
74
- if (!BROWSER || isCalculatingHeight || heightUpdateTimeout)
74
+ onUpdate, debounceTime, dirtyItems) => {
75
+ if (!BROWSER || isCalculatingHeight)
75
76
  return null;
76
77
  const visibleRange = visibleItemsGetter();
77
78
  const currentIndex = visibleRange.start;
78
79
  if (currentIndex === lastMeasuredIndex)
79
80
  return null;
81
+ if (heightUpdateTimeout)
82
+ clearTimeout(heightUpdateTimeout);
80
83
  return setTimeout(() => {
81
- const { newHeight, newLastMeasuredIndex, updatedHeightCache } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight);
82
- if (Math.abs(newHeight - calculatedItemHeight) > 1) {
84
+ const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight, dirtyItems);
85
+ if (Math.abs(newHeight - calculatedItemHeight) > 1 || dirtyItems.size > 0) {
83
86
  onUpdate({
84
87
  newHeight,
85
88
  newLastMeasuredIndex,
86
- updatedHeightCache
89
+ updatedHeightCache,
90
+ clearedDirtyItems
87
91
  });
88
92
  }
89
93
  }, debounceTime);
@@ -39,9 +39,3 @@ export type VirtualListSetters = {
39
39
  setScrollTop: (scrollTop: number) => void;
40
40
  setInitialized: (initialized: boolean) => void;
41
41
  };
42
- /**
43
- * Cache for storing measured item heights
44
- * - Key: Item index in the list
45
- * - Value: Measured height in pixels
46
- */
47
- export type HeightCache = Record<number, number>;
@@ -1,4 +1,4 @@
1
- import type { SvelteVirtualListMode } from '../types.js';
1
+ import type { SvelteVirtualListMode, SvelteVirtualListPreviousVisibleRange } from '../types.js';
2
2
  import type { VirtualListSetters, VirtualListState } from './types.js';
3
3
  /**
4
4
  * Calculates the maximum scroll position for a virtual list.
@@ -26,12 +26,9 @@ export declare const calculateScrollPosition: (totalItems: number, itemHeight: n
26
26
  * @param {number} totalItems - Total number of items in the list
27
27
  * @param {number} bufferSize - Number of items to render outside the visible area
28
28
  * @param {SvelteVirtualListMode} mode - Scroll direction mode
29
- * @returns {{ start: number, end: number }} Range of indices to render
29
+ * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
30
30
  */
31
- export declare const calculateVisibleRange: (scrollTop: number, viewportHeight: number, itemHeight: number, totalItems: number, bufferSize: number, mode: SvelteVirtualListMode) => {
32
- start: number;
33
- end: number;
34
- };
31
+ export declare const calculateVisibleRange: (scrollTop: number, viewportHeight: number, itemHeight: number, totalItems: number, bufferSize: number, mode: SvelteVirtualListMode) => SvelteVirtualListPreviousVisibleRange;
35
32
  /**
36
33
  * Calculates the CSS transform value for positioning the virtual list items.
37
34
  *
@@ -64,19 +61,19 @@ export declare const updateHeightAndScroll: (state: VirtualListState, setters: V
64
61
  * Calculates the average height of visible items in a virtual list.
65
62
  *
66
63
  * This function optimizes performance by:
67
- * 1. Using a height cache to store measured item heights
64
+ * 1. Using a height cache to store measured item heights with dirty tracking
68
65
  * 2. Only measuring new items not in the cache
69
66
  * 3. Calculating a running average of all measured heights
70
67
  *
71
68
  * @param {HTMLElement[]} itemElements - Array of currently rendered item elements
72
69
  * @param {{ start: number }} visibleRange - Object containing the start index of visible items
73
- * @param {Record<number, number>} heightCache - Cache of previously measured item heights
70
+ * @param {HeightCache} heightCache - Cache of previously measured item heights with dirty tracking
74
71
  * @param {number} currentItemHeight - Current average item height being used
75
72
  *
76
73
  * @returns {{
77
74
  * newHeight: number,
78
75
  * newLastMeasuredIndex: number,
79
- * updatedHeightCache: Record<number, number>
76
+ * updatedHeightCache: HeightCache
80
77
  * }} Object containing new calculated height, last measured index, and updated cache
81
78
  *
82
79
  * @example
@@ -89,10 +86,11 @@ export declare const updateHeightAndScroll: (state: VirtualListState, setters: V
89
86
  */
90
87
  export declare const calculateAverageHeight: (itemElements: HTMLElement[], visibleRange: {
91
88
  start: number;
92
- }, heightCache: Record<number, number>, currentItemHeight: number) => {
89
+ }, heightCache: Record<number, number>, currentItemHeight: number, dirtyItems: Set<number>) => {
93
90
  newHeight: number;
94
91
  newLastMeasuredIndex: number;
95
92
  updatedHeightCache: Record<number, number>;
93
+ clearedDirtyItems: Set<number>;
96
94
  };
97
95
  /**
98
96
  * Processes large arrays in chunks to prevent UI blocking.
@@ -125,7 +123,7 @@ onComplete: () => void) => Promise<void>;
125
123
  * Builds a block sum array for fast offset calculation in large virtual lists.
126
124
  * Each entry in the array is the total height up to the end of that block (exclusive).
127
125
  *
128
- * @param {Record<number, number>} heightCache - Map of measured item heights
126
+ * @param {HeightCache} heightCache - Map of measured item heights with dirty tracking
129
127
  * @param {number} calculatedItemHeight - Estimated height for unmeasured items
130
128
  * @param {number} totalItems - Total number of items in the list
131
129
  * @param {number} blockSize - Number of items per block
@@ -141,7 +139,7 @@ export declare const buildBlockSums: (heightCache: Record<number, number>, calcu
141
139
  * - For indices >= blockSize, sums the block prefix, then only iterates the tail within the block.
142
140
  * - For small indices, falls back to the original logic.
143
141
  *
144
- * @param {Record<number, number>} heightCache - Map of measured item heights
142
+ * @param {HeightCache} heightCache - Map of measured item heights with dirty tracking
145
143
  * @param {number} calculatedItemHeight - Estimated height for unmeasured items
146
144
  * @param {number} idx - The index to scroll to (exclusive)
147
145
  * @param {number[]} [blockSums] - Optional precomputed block sums (for repeated queries)
@@ -29,12 +29,23 @@ export const calculateScrollPosition = (totalItems, itemHeight, containerHeight)
29
29
  * @param {number} totalItems - Total number of items in the list
30
30
  * @param {number} bufferSize - Number of items to render outside the visible area
31
31
  * @param {SvelteVirtualListMode} mode - Scroll direction mode
32
- * @returns {{ start: number, end: number }} Range of indices to render
32
+ * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
33
33
  */
34
34
  export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, totalItems, bufferSize, mode) => {
35
35
  if (mode === 'bottomToTop') {
36
36
  const visibleCount = Math.ceil(viewportHeight / itemHeight) + 1;
37
37
  const bottomIndex = totalItems - Math.floor(scrollTop / itemHeight);
38
+ // Safeguard: if bottomIndex is negative, it means scrollTop is too large for current itemHeight
39
+ // This can happen when itemHeight changes but scrollTop hasn't been corrected yet
40
+ if (bottomIndex < 0) {
41
+ // Calculate what scrollTop should be and use that for visible range
42
+ const totalHeight = totalItems * itemHeight;
43
+ const correctedScrollTop = Math.max(0, totalHeight - viewportHeight);
44
+ const correctedBottomIndex = totalItems - Math.floor(correctedScrollTop / itemHeight);
45
+ const start = Math.max(0, correctedBottomIndex - visibleCount - bufferSize);
46
+ const end = Math.min(totalItems, correctedBottomIndex + bufferSize);
47
+ return { start, end };
48
+ }
38
49
  // Add buffer to both ends
39
50
  const start = Math.max(0, bottomIndex - visibleCount - bufferSize);
40
51
  const end = Math.min(totalItems, bottomIndex + bufferSize);
@@ -43,6 +54,23 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
43
54
  else {
44
55
  const start = Math.floor(scrollTop / itemHeight);
45
56
  const end = Math.min(totalItems, start + Math.ceil(viewportHeight / itemHeight) + 1);
57
+ // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
58
+ const totalHeight = totalItems * itemHeight;
59
+ const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
60
+ // Add dynamic tolerance based on item height for browser rendering precision
61
+ const tolerance = Math.max(itemHeight, 10); // At least one full item height or 10px minimum
62
+ const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
63
+ if (isAtBottom) {
64
+ // When at the bottom, ensure we include all items up to the end
65
+ const adjustedEnd = totalItems;
66
+ const visibleItemCount = Math.ceil(viewportHeight / itemHeight) + bufferSize + 1;
67
+ const adjustedStart = Math.max(0, adjustedEnd - visibleItemCount);
68
+ // TopToBottom safeguard is now active
69
+ return {
70
+ start: adjustedStart,
71
+ end: adjustedEnd
72
+ };
73
+ }
46
74
  // Add buffer to both ends
47
75
  return {
48
76
  start: Math.max(0, start - bufferSize),
@@ -65,9 +93,15 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
65
93
  * @returns {number} The calculated transform Y value in pixels
66
94
  */
67
95
  export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart, itemHeight) => {
68
- return mode === 'bottomToTop'
69
- ? (totalItems - visibleEnd) * itemHeight
70
- : visibleStart * itemHeight;
96
+ if (mode === 'bottomToTop') {
97
+ // In bottomToTop mode, we need to position the container so that
98
+ // the first visible item (visibleStart) aligns with its correct position
99
+ // from the bottom of the total content
100
+ return (totalItems - visibleEnd) * itemHeight;
101
+ }
102
+ else {
103
+ return visibleStart * itemHeight;
104
+ }
71
105
  };
72
106
  /**
73
107
  * Updates the virtual list's height and scroll position when necessary.
@@ -101,19 +135,19 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
101
135
  * Calculates the average height of visible items in a virtual list.
102
136
  *
103
137
  * This function optimizes performance by:
104
- * 1. Using a height cache to store measured item heights
138
+ * 1. Using a height cache to store measured item heights with dirty tracking
105
139
  * 2. Only measuring new items not in the cache
106
140
  * 3. Calculating a running average of all measured heights
107
141
  *
108
142
  * @param {HTMLElement[]} itemElements - Array of currently rendered item elements
109
143
  * @param {{ start: number }} visibleRange - Object containing the start index of visible items
110
- * @param {Record<number, number>} heightCache - Cache of previously measured item heights
144
+ * @param {HeightCache} heightCache - Cache of previously measured item heights with dirty tracking
111
145
  * @param {number} currentItemHeight - Current average item height being used
112
146
  *
113
147
  * @returns {{
114
148
  * newHeight: number,
115
149
  * newLastMeasuredIndex: number,
116
- * updatedHeightCache: Record<number, number>
150
+ * updatedHeightCache: HeightCache
117
151
  * }} Object containing new calculated height, last measured index, and updated cache
118
152
  *
119
153
  * @example
@@ -124,39 +158,89 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
124
158
  * 40
125
159
  * )
126
160
  */
127
- export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight) => {
161
+ export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems) => {
128
162
  const validElements = itemElements.filter((el) => el);
129
163
  if (validElements.length === 0) {
130
164
  return {
131
165
  newHeight: currentItemHeight,
132
166
  newLastMeasuredIndex: visibleRange.start,
133
- updatedHeightCache: heightCache
167
+ updatedHeightCache: heightCache,
168
+ clearedDirtyItems: new Set()
134
169
  };
135
170
  }
136
171
  const newHeightCache = { ...heightCache };
137
- // Cache heights for new items
138
- validElements.forEach((el, i) => {
139
- const itemIndex = visibleRange.start + i;
140
- if (!newHeightCache[itemIndex]) {
141
- try {
142
- const height = el.getBoundingClientRect().height;
143
- if (Number.isFinite(height) && height > 0) {
144
- newHeightCache[itemIndex] = height;
172
+ const clearedDirtyItems = new Set();
173
+ // Initialize running totals for O(1) average calculation
174
+ let totalValidHeight = 0;
175
+ let validHeightCount = 0;
176
+ // Calculate initial totals from existing cache
177
+ for (const height of Object.values(heightCache)) {
178
+ if (Number.isFinite(height) && height > 0) {
179
+ totalValidHeight += height;
180
+ validHeightCount++;
181
+ }
182
+ }
183
+ // Process only dirty items if they exist, otherwise process all visible items
184
+ if (dirtyItems.size > 0) {
185
+ // Process only dirty items
186
+ dirtyItems.forEach((itemIndex) => {
187
+ const elementIndex = itemIndex - visibleRange.start;
188
+ const element = validElements[elementIndex];
189
+ if (element && elementIndex >= 0 && elementIndex < validElements.length) {
190
+ try {
191
+ const height = element.getBoundingClientRect().height;
192
+ if (Number.isFinite(height) && height > 0) {
193
+ const oldHeight = newHeightCache[itemIndex];
194
+ // Only update if height actually changed (use smaller tolerance for precision)
195
+ if (!oldHeight || Math.abs(oldHeight - height) >= 0.1) {
196
+ // Update running totals
197
+ if (oldHeight && Number.isFinite(oldHeight) && oldHeight > 0) {
198
+ // Replace old height with new height in running total
199
+ totalValidHeight = totalValidHeight - oldHeight + height;
200
+ }
201
+ else {
202
+ // Add new height to running total
203
+ totalValidHeight += height;
204
+ validHeightCount++;
205
+ }
206
+ newHeightCache[itemIndex] = height;
207
+ }
208
+ }
209
+ clearedDirtyItems.add(itemIndex);
210
+ }
211
+ catch {
212
+ // Skip invalid measurements but still clear from dirty
213
+ clearedDirtyItems.add(itemIndex);
145
214
  }
146
215
  }
147
- catch {
148
- // Skip invalid measurements
216
+ });
217
+ }
218
+ else {
219
+ // Original behavior: process all visible items
220
+ validElements.forEach((el, i) => {
221
+ const itemIndex = visibleRange.start + i;
222
+ if (!newHeightCache[itemIndex]) {
223
+ try {
224
+ const height = el.getBoundingClientRect().height;
225
+ if (Number.isFinite(height) && height > 0) {
226
+ // Add new height to running totals
227
+ totalValidHeight += height;
228
+ validHeightCount++;
229
+ newHeightCache[itemIndex] = height;
230
+ }
231
+ }
232
+ catch {
233
+ // Skip invalid measurements
234
+ }
149
235
  }
150
- }
151
- });
152
- // Calculate average from valid cached heights
153
- const validHeights = Object.values(newHeightCache).filter((h) => Number.isFinite(h) && h > 0);
236
+ });
237
+ }
238
+ // O(1) average calculation using running totals!
154
239
  return {
155
- newHeight: validHeights.length > 0
156
- ? validHeights.reduce((sum, h) => sum + h, 0) / validHeights.length
157
- : currentItemHeight,
240
+ newHeight: validHeightCount > 0 ? totalValidHeight / validHeightCount : currentItemHeight,
158
241
  newLastMeasuredIndex: visibleRange.start,
159
- updatedHeightCache: newHeightCache
242
+ updatedHeightCache: newHeightCache,
243
+ clearedDirtyItems
160
244
  };
161
245
  };
162
246
  /**
@@ -206,7 +290,7 @@ onComplete) => {
206
290
  * Builds a block sum array for fast offset calculation in large virtual lists.
207
291
  * Each entry in the array is the total height up to the end of that block (exclusive).
208
292
  *
209
- * @param {Record<number, number>} heightCache - Map of measured item heights
293
+ * @param {HeightCache} heightCache - Map of measured item heights with dirty tracking
210
294
  * @param {number} calculatedItemHeight - Estimated height for unmeasured items
211
295
  * @param {number} totalItems - Total number of items in the list
212
296
  * @param {number} blockSize - Number of items per block
@@ -236,7 +320,7 @@ export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, bl
236
320
  * - For indices >= blockSize, sums the block prefix, then only iterates the tail within the block.
237
321
  * - For small indices, falls back to the original logic.
238
322
  *
239
- * @param {Record<number, number>} heightCache - Map of measured item heights
323
+ * @param {HeightCache} heightCache - Map of measured item heights with dirty tracking
240
324
  * @param {number} calculatedItemHeight - Estimated height for unmeasured items
241
325
  * @param {number} idx - The index to scroll to (exclusive)
242
326
  * @param {number[]} [blockSums] - Optional precomputed block sums (for repeated queries)