From 62672467a0c7dd6a79b3bfc489e8b1bf3872b585 Mon Sep 17 00:00:00 2001 From: Andrew Hoddinott Date: Sat, 6 Dec 2025 07:45:27 +0900 Subject: [PATCH 1/2] fix: when itemCount decreases clamp lastMeasuredIndex to prevent range error --- src/lib/SizeAndPositionManager.js | 5 +++++ src/lib/SizeAndPositionManager.test.js | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/lib/SizeAndPositionManager.js b/src/lib/SizeAndPositionManager.js index 33b590e..483ac21 100644 --- a/src/lib/SizeAndPositionManager.js +++ b/src/lib/SizeAndPositionManager.js @@ -90,6 +90,11 @@ export default class SizeAndPositionManager { this.checkForMismatchItemSizeAndItemCount(); + // Ensure lastMeasuredIndex in range after itemCount change + if (this.lastMeasuredIndex >= itemCount) { + this.lastMeasuredIndex = itemCount - 1; + } + if (this.justInTime && this.totalSize != null) { this.totalSize = undefined; } else { diff --git a/src/lib/SizeAndPositionManager.test.js b/src/lib/SizeAndPositionManager.test.js index 01abd1e..71f068d 100644 --- a/src/lib/SizeAndPositionManager.test.js +++ b/src/lib/SizeAndPositionManager.test.js @@ -424,6 +424,27 @@ describe('SizeAndPositionManager', () => { }); }); + describe('updateConfig with decreased itemCount', () => { + it('should handle getVisibleRange when offset exceeds new size', () => { + // 1. List has 100 items, user scrolls to offset 4800 (near the end) + const { sizeAndPositionManager } = getItemSizeAndPositionManager(100); + sizeAndPositionManager.getVisibleRange(50, 900, 0); + + // 2. itemCount decreases to 10 (total size now 100px with itemSize=10) + sizeAndPositionManager.updateConfig(() => ITEM_SIZE, 10, 15); + + // 3. getVisibleRange is called with the stale offset 4800 + // findNearestItem should clamp to the last valid item (index 9) + // Without proper handling, this throws "Requested index X is outside of range 0..10" + const { start, end } = sizeAndPositionManager.getVisibleRange(50, 900, 0); + + expect(start).toBeDefined(); + expect(end).toBeDefined(); + expect(start).toBeLessThan(10); + expect(end).toBeLessThan(10); + }); + }); + describe('resetItem', () => { it('should clear size and position metadata for the specified index and all items after it', () => { const { sizeAndPositionManager } = getItemSizeAndPositionManager(); From 7aaca2c12e19e0d8f078b15107a92b79494e41cd Mon Sep 17 00:00:00 2001 From: Andrew Hoddinott Date: Sat, 6 Dec 2025 09:56:32 +0900 Subject: [PATCH 2/2] fix: when itemCount decreases prevent rendering stale items --- src/lib/VirtualList.svelte | 19 +++++++++++++++---- src/lib/VirtualList.svelte.test.js | 26 ++++++++++++++++++++++++++ src/lib/VirtualListTestWrapper.svelte | 14 ++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/lib/VirtualList.svelte.test.js create mode 100644 src/lib/VirtualListTestWrapper.svelte diff --git a/src/lib/VirtualList.svelte b/src/lib/VirtualList.svelte index 7795553..42b91a4 100644 --- a/src/lib/VirtualList.svelte +++ b/src/lib/VirtualList.svelte @@ -58,6 +58,7 @@ let wrapperWidth = $state(400); /** @type {{ index: number, style: string }[]} */ let items = $state.raw([]); + let validItems = $derived(items.filter((item) => item.index < itemCount)); /** @type {{ offset: number, changeReason: number }} */ let scroll = $state.raw({ @@ -144,6 +145,12 @@ if (itemPropsHaveChanged) { sizeAndPositionManager.updateConfig(itemSize, itemCount, estimatedItemSize); + // Clear items when itemCount decreases + // Prevents stale indices rendering before refresh() + if (itemCount < prevProps.itemCount) { + items = []; + } + forceRecomputeSizes = true; } @@ -206,16 +213,20 @@ * Recomputes the sizes of the items and updates the visible items. */ function refresh() { + const containerSize = scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber; + const totalSize = sizeAndPositionManager.getTotalSize(); + const maxOffset = Math.max(0, totalSize - containerSize); + const clampedOffset = Math.min(scroll.offset, maxOffset); + const { start, end } = sizeAndPositionManager.getVisibleRange( - scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber, - scroll.offset, + containerSize, + clampedOffset, overscanCount ); /** @type {{ index: number, style: string }[]} */ const visibleItems = []; - const totalSize = sizeAndPositionManager.getTotalSize(); const heightUnit = typeof height === 'number' ? 'px' : ''; const widthUnit = typeof width === 'number' ? 'px' : ''; @@ -339,7 +350,7 @@ {/if}
- {#each items as item (getKey ? getKey(item.index) : item.index)} + {#each validItems as item (getKey ? getKey(item.index) : item.index)} {@render (childrenSnippet || itemSnippet)({ style: item.style, index: item.index })} {/each}
diff --git a/src/lib/VirtualList.svelte.test.js b/src/lib/VirtualList.svelte.test.js new file mode 100644 index 0000000..9719074 --- /dev/null +++ b/src/lib/VirtualList.svelte.test.js @@ -0,0 +1,26 @@ +import { tick } from 'svelte'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import Wrapper from './VirtualListTestWrapper.svelte'; + +const data = (n) => Array.from({ length: n }, (_, i) => ({ id: i, label: `Item ${i}` })); + +describe('VirtualList', () => { + it('should not render stale items when itemCount decreases', async () => { + const rendered = []; + const { rerender } = render(Wrapper, { + data: data(100), + scrollOffset: 4800, + onItemRender: (i) => rendered.push(i) + }); + + await tick(); + rendered.length = 0; + + await rerender({ data: data(10), onItemRender: (i) => rendered.push(i) }); + await tick(); + + expect(rendered.filter((i) => i >= 10)).toEqual([]); + }); +}); diff --git a/src/lib/VirtualListTestWrapper.svelte b/src/lib/VirtualListTestWrapper.svelte new file mode 100644 index 0000000..f77d84b --- /dev/null +++ b/src/lib/VirtualListTestWrapper.svelte @@ -0,0 +1,14 @@ + + +
+ + {#snippet item({ index, style })} + {(onItemRender(index), '')} +
{data[index]?.label}
+ {/snippet} +
+