@pdanpdan/virtual-scroll 0.9.0 → 0.9.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pdanpdan/virtual-scroll",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "description": "A high-performance virtual scroll component for Vue 3",
6
6
  "author": "",
7
7
  "license": "MIT",
@@ -758,7 +758,10 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
758
758
  )
759
759
  : { x: 0, y: 0 };
760
760
 
761
- const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
761
+ const lastItemsMap = new Map<number, RenderedItem<T>>();
762
+ for (const item of lastRenderedItems) {
763
+ lastItemsMap.set(item.index, item);
764
+ }
762
765
 
763
766
  // Optimization: Cache sequential queries to avoid O(log N) tree traversal for every item
764
767
  let lastIndexX = -1;
@@ -795,6 +798,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
795
798
 
796
799
  const colRange = columnRange.value;
797
800
 
801
+ // Optimization: track sticky index pointer
802
+ let currentStickyIndexPtr = 0;
803
+
798
804
  for (const i of sortedIndices) {
799
805
  const item = props.value.items[ i ];
800
806
  if (item === undefined) {
@@ -821,6 +827,12 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
821
827
  const originalX = x;
822
828
  const originalY = y;
823
829
 
830
+ // Find next sticky index for optimization
831
+ while (currentStickyIndexPtr < stickyIndices.length && stickyIndices[ currentStickyIndexPtr ]! <= i) {
832
+ currentStickyIndexPtr++;
833
+ }
834
+ const nextStickyIndex = currentStickyIndexPtr < stickyIndices.length ? stickyIndices[ currentStickyIndexPtr ] : undefined;
835
+
824
836
  const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({
825
837
  index: i,
826
838
  isSticky,
@@ -836,8 +848,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
836
848
  fixedWidth: fixedColumnWidth.value,
837
849
  gap: props.value.gap || 0,
838
850
  columnGap: props.value.columnGap || 0,
839
- getItemQueryY: queryYCached,
840
- getItemQueryX: queryXCached,
851
+ getItemQueryY: (idx) => itemSizesY.query(idx),
852
+ getItemQueryX: (idx) => itemSizesX.query(idx),
853
+ nextStickyIndex,
841
854
  });
842
855
 
843
856
  const offsetX = isHydrated.value
@@ -125,105 +125,144 @@ export function useVirtualScrollSizes<T>(
125
125
  }
126
126
  };
127
127
 
128
+ /**
129
+ * Helper to initialize measurements for a single axis.
130
+ */
131
+ const initializeAxis = (
132
+ count: number,
133
+ tree: FenwickTree,
134
+ measured: Uint8Array,
135
+ sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
136
+ defaultSize: number,
137
+ gap: number,
138
+ isDynamic: boolean,
139
+ isX: boolean,
140
+ shouldReset: boolean,
141
+ ) => {
142
+ let needsRebuild = false;
143
+
144
+ if (shouldReset) {
145
+ for (let i = 0; i < count; i++) {
146
+ if (tree.get(i) !== 0) {
147
+ tree.set(i, 0);
148
+ measured[ i ] = 0;
149
+ needsRebuild = true;
150
+ }
151
+ }
152
+ return needsRebuild;
153
+ }
154
+
155
+ for (let i = 0; i < count; i++) {
156
+ const current = tree.get(i);
157
+ const isMeasured = measured[ i ] === 1;
158
+
159
+ if (!isDynamic || (!isMeasured && current === 0)) {
160
+ const baseSize = getSizeAt(i, sizeProp, defaultSize, gap, tree, isX) + gap;
161
+
162
+ if (Math.abs(current - baseSize) > 0.5) {
163
+ tree.set(i, baseSize);
164
+ measured[ i ] = isDynamic ? 0 : 1;
165
+ needsRebuild = true;
166
+ } else if (!isDynamic) {
167
+ measured[ i ] = 1;
168
+ }
169
+ }
170
+ }
171
+ return needsRebuild;
172
+ };
173
+
128
174
  /**
129
175
  * Initializes prefix sum trees from props (fixed sizes, width arrays, or functions).
130
176
  */
131
177
  const initializeMeasurements = () => {
132
178
  const propsVal = props.value.props;
133
- const newItems = propsVal.items;
134
- const len = newItems.length;
179
+ const len = propsVal.items.length;
135
180
  const colCount = propsVal.columnCount || 0;
136
181
  const gap = propsVal.gap || 0;
137
182
  const columnGap = propsVal.columnGap || 0;
138
183
  const cw = propsVal.columnWidth;
139
-
140
- let colNeedsRebuild = false;
141
- let itemsNeedRebuild = false;
184
+ const itemSize = propsVal.itemSize;
185
+ const defaultColWidth = propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
186
+ const defaultItemSize = propsVal.defaultItemSize || props.value.defaultSize;
142
187
 
143
188
  // Initialize columns
144
- if (colCount > 0) {
145
- for (let i = 0; i < colCount; i++) {
146
- const currentW = columnSizes.get(i);
147
- const isMeasured = measuredColumns.value[ i ] === 1;
148
-
149
- if (!props.value.isDynamicColumnWidth || (!isMeasured && currentW === 0)) {
150
- let baseWidth = 0;
151
- if (typeof cw === 'number' && cw > 0) {
152
- baseWidth = cw;
153
- } else if (Array.isArray(cw) && cw.length > 0) {
154
- baseWidth = cw[ i % cw.length ] || propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
155
- } else if (typeof cw === 'function') {
156
- baseWidth = cw(i);
157
- } else {
158
- baseWidth = propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
159
- }
160
-
161
- const targetW = baseWidth + columnGap;
162
- if (Math.abs(currentW - targetW) > 0.5) {
163
- columnSizes.set(i, targetW);
164
- measuredColumns.value[ i ] = props.value.isDynamicColumnWidth ? 0 : 1;
165
- colNeedsRebuild = true;
166
- } else if (!props.value.isDynamicColumnWidth) {
167
- measuredColumns.value[ i ] = 1;
168
- }
169
- }
170
- }
171
- }
172
-
173
- // Initialize items
174
- for (let i = 0; i < len; i++) {
175
- const item = propsVal.items[ i ];
176
- const currentX = itemSizesX.get(i);
177
- const currentY = itemSizesY.get(i);
178
- const isMeasuredX = measuredItemsX.value[ i ] === 1;
179
- const isMeasuredY = measuredItemsY.value[ i ] === 1;
180
-
181
- if (props.value.direction === 'horizontal') {
182
- if (!props.value.isDynamicItemSize || (!isMeasuredX && currentX === 0)) {
183
- const baseSize = getItemBaseSize(item as T, i);
184
- const targetX = baseSize + columnGap;
185
- if (Math.abs(currentX - targetX) > 0.5) {
186
- itemSizesX.set(i, targetX);
187
- measuredItemsX.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
188
- itemsNeedRebuild = true;
189
- } else if (!props.value.isDynamicItemSize) {
190
- measuredItemsX.value[ i ] = 1;
191
- }
192
- }
193
- } else if (currentX !== 0) {
194
- itemSizesX.set(i, 0);
195
- measuredItemsX.value[ i ] = 0;
196
- itemsNeedRebuild = true;
197
- }
198
-
199
- if (props.value.direction !== 'horizontal') {
200
- if (!props.value.isDynamicItemSize || (!isMeasuredY && currentY === 0)) {
201
- const baseSize = getItemBaseSize(item as T, i);
202
- const targetY = baseSize + gap;
203
- if (Math.abs(currentY - targetY) > 0.5) {
204
- itemSizesY.set(i, targetY);
205
- measuredItemsY.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
206
- itemsNeedRebuild = true;
207
- } else if (!props.value.isDynamicItemSize) {
208
- measuredItemsY.value[ i ] = 1;
209
- }
210
- }
211
- } else if (currentY !== 0) {
212
- itemSizesY.set(i, 0);
213
- measuredItemsY.value[ i ] = 0;
214
- itemsNeedRebuild = true;
215
- }
216
- }
189
+ const colNeedsRebuild = initializeAxis(
190
+ colCount,
191
+ columnSizes,
192
+ measuredColumns.value,
193
+ cw,
194
+ defaultColWidth,
195
+ columnGap,
196
+ props.value.isDynamicColumnWidth,
197
+ true,
198
+ false,
199
+ );
200
+
201
+ // Initialize items X
202
+ const itemsXNeedsRebuild = initializeAxis(
203
+ len,
204
+ itemSizesX,
205
+ measuredItemsX.value,
206
+ itemSize,
207
+ defaultItemSize,
208
+ columnGap,
209
+ props.value.isDynamicItemSize,
210
+ true,
211
+ props.value.direction !== 'horizontal',
212
+ );
213
+
214
+ // Initialize items Y
215
+ const itemsYNeedsRebuild = initializeAxis(
216
+ len,
217
+ itemSizesY,
218
+ measuredItemsY.value,
219
+ itemSize,
220
+ defaultItemSize,
221
+ gap,
222
+ props.value.isDynamicItemSize,
223
+ false,
224
+ props.value.direction === 'horizontal',
225
+ );
217
226
 
218
227
  if (colNeedsRebuild) {
219
228
  columnSizes.rebuild();
220
229
  }
221
- if (itemsNeedRebuild) {
230
+ if (itemsXNeedsRebuild) {
222
231
  itemSizesX.rebuild();
232
+ }
233
+ if (itemsYNeedsRebuild) {
223
234
  itemSizesY.rebuild();
224
235
  }
225
236
  };
226
237
 
238
+ /**
239
+ * Helper to update a single size in the tree.
240
+ */
241
+ const updateAxis = (
242
+ index: number,
243
+ newSize: number,
244
+ tree: FenwickTree,
245
+ measured: Uint8Array,
246
+ gap: number,
247
+ firstIndex: number,
248
+ accumulatedDelta: { val: number; },
249
+ ) => {
250
+ const oldSize = tree.get(index);
251
+ const targetSize = newSize + gap;
252
+ let updated = false;
253
+
254
+ if (!measured[ index ] || Math.abs(targetSize - oldSize) > 0.1) {
255
+ const d = targetSize - oldSize;
256
+ tree.update(index, d);
257
+ measured[ index ] = 1;
258
+ updated = true;
259
+ if (index < firstIndex && oldSize > 0) {
260
+ accumulatedDelta.val += d;
261
+ }
262
+ }
263
+ return updated;
264
+ };
265
+
227
266
  /**
228
267
  * Initializes or updates sizes based on current props and items.
229
268
  * Handles prepending of items by shifting existing measurements.
@@ -297,8 +336,8 @@ export function useVirtualScrollSizes<T>(
297
336
  onScrollCorrection: (deltaX: number, deltaY: number) => void,
298
337
  ) => {
299
338
  let needUpdate = false;
300
- let deltaX = 0;
301
- let deltaY = 0;
339
+ const deltaX = { val: 0 };
340
+ const deltaY = { val: 0 };
302
341
  const propsVal = props.value.props;
303
342
  const gap = propsVal.gap || 0;
304
343
  const columnGap = propsVal.columnGap || 0;
@@ -315,19 +354,8 @@ export function useVirtualScrollSizes<T>(
315
354
  const tryUpdateColumn = (colIdx: number, width: number) => {
316
355
  if (colIdx >= 0 && colIdx < (propsVal.columnCount || 0) && !processedCols.has(colIdx)) {
317
356
  processedCols.add(colIdx);
318
- const oldW = columnSizes.get(colIdx);
319
- const targetW = width + columnGap;
320
-
321
- if (!measuredColumns.value[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
322
- const d = targetW - oldW;
323
- if (Math.abs(d) > 0.1) {
324
- columnSizes.update(colIdx, d);
325
- needUpdate = true;
326
- if (colIdx < firstColIndex && oldW > 0) {
327
- deltaX += d;
328
- }
329
- }
330
- measuredColumns.value[ colIdx ] = 1;
357
+ if (updateAxis(colIdx, width, columnSizes, measuredColumns.value, columnGap, firstColIndex, deltaX)) {
358
+ needUpdate = true;
331
359
  }
332
360
  }
333
361
  };
@@ -342,30 +370,13 @@ export function useVirtualScrollSizes<T>(
342
370
  if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
343
371
  processedRows.add(index);
344
372
  if (isHorizontalMode && inlineSize > 0) {
345
- const oldWidth = itemSizesX.get(index);
346
- const targetWidth = inlineSize + columnGap;
347
- if (!measuredItemsX.value[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
348
- const d = targetWidth - oldWidth;
349
- itemSizesX.update(index, d);
350
- measuredItemsX.value[ index ] = 1;
373
+ if (updateAxis(index, inlineSize, itemSizesX, measuredItemsX.value, columnGap, firstRowIndex, deltaX)) {
351
374
  needUpdate = true;
352
- if (index < firstRowIndex && oldWidth > 0) {
353
- deltaX += d;
354
- }
355
375
  }
356
376
  }
357
377
  if (!isHorizontalMode) {
358
- const oldHeight = itemSizesY.get(index);
359
- const targetHeight = blockSize + gap;
360
-
361
- if (!measuredItemsY.value[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
362
- const d = targetHeight - oldHeight;
363
- itemSizesY.update(index, d);
364
- measuredItemsY.value[ index ] = 1;
378
+ if (updateAxis(index, blockSize, itemSizesY, measuredItemsY.value, gap, firstRowIndex, deltaY)) {
365
379
  needUpdate = true;
366
- if (index < firstRowIndex && oldHeight > 0) {
367
- deltaY += d;
368
- }
369
380
  }
370
381
  }
371
382
  }
@@ -396,8 +407,8 @@ export function useVirtualScrollSizes<T>(
396
407
 
397
408
  if (needUpdate) {
398
409
  treeUpdateFlag.value++;
399
- if (deltaX !== 0 || deltaY !== 0) {
400
- onScrollCorrection(deltaX, deltaY);
410
+ if (deltaX.val !== 0 || deltaY.val !== 0) {
411
+ onScrollCorrection(deltaX.val, deltaY.val);
401
412
  }
402
413
  }
403
414
  };
@@ -361,6 +361,7 @@ function calculateAxisTarget({
361
361
  * @param index - Item index.
362
362
  * @param stickyIndices - All sticky indices.
363
363
  * @param getNextStickyPos - Resolver for the next sticky item's position.
364
+ * @param nextStickyIndex - Pre-calculated next sticky index (optional).
364
365
  * @returns Sticky state for this axis.
365
366
  */
366
367
  function calculateAxisSticky(
@@ -370,12 +371,16 @@ function calculateAxisSticky(
370
371
  index: number,
371
372
  stickyIndices: number[],
372
373
  getNextStickyPos: (idx: number) => number,
374
+ nextStickyIndex?: number,
373
375
  ) {
374
376
  if (scrollPos <= originalPos) {
375
377
  return { isActive: false, offset: 0 };
376
378
  }
377
379
 
378
- const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
380
+ const nextStickyIdx = nextStickyIndex !== undefined
381
+ ? nextStickyIndex
382
+ : findNextStickyIndex(stickyIndices, index);
383
+
379
384
  if (nextStickyIdx === undefined) {
380
385
  return { isActive: true, offset: 0 };
381
386
  }
@@ -770,6 +775,7 @@ export function calculateColumnRange({
770
775
  * @param params.columnGap - Column gap (VU).
771
776
  * @param params.getItemQueryY - Resolver for vertical offset (VU).
772
777
  * @param params.getItemQueryX - Resolver for horizontal offset (VU).
778
+ * @param params.nextStickyIndex - Optional pre-calculated next sticky index.
773
779
  * @returns Sticky state and offset (VU).
774
780
  * @see StickyParams
775
781
  */
@@ -789,7 +795,8 @@ export function calculateStickyItem({
789
795
  columnGap,
790
796
  getItemQueryY,
791
797
  getItemQueryX,
792
- }: StickyParams) {
798
+ nextStickyIndex,
799
+ }: StickyParams & { nextStickyIndex?: number | undefined; }) {
793
800
  let isStickyActiveX = false;
794
801
  let isStickyActiveY = false;
795
802
  const stickyOffset = { x: 0, y: 0 };
@@ -807,6 +814,7 @@ export function calculateStickyItem({
807
814
  index,
808
815
  stickyIndices,
809
816
  (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + gap) : getItemQueryY(nextIdx)),
817
+ nextStickyIndex,
810
818
  );
811
819
  isStickyActiveY = res.isActive;
812
820
  stickyOffset.y = res.offset;
@@ -821,6 +829,7 @@ export function calculateStickyItem({
821
829
  index,
822
830
  stickyIndices,
823
831
  (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + columnGap) : getItemQueryX(nextIdx)),
832
+ nextStickyIndex,
824
833
  );
825
834
 
826
835
  if (res.isActive) {