Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/lib/SizeAndPositionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions src/lib/SizeAndPositionManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 15 additions & 4 deletions src/lib/VirtualList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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' : '';

Expand Down Expand Up @@ -339,7 +350,7 @@
{/if}

<div class="virtual-list-inner" style={innerStyle}>
{#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}
</div>
Expand Down
26 changes: 26 additions & 0 deletions src/lib/VirtualList.svelte.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
14 changes: 14 additions & 0 deletions src/lib/VirtualListTestWrapper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
import VirtualList from './VirtualList.svelte';

let { data = [], scrollOffset, onItemRender = () => {} } = $props();
</script>

<div style="height: 400px; width: 400px;">
<VirtualList height="200px" width="300px" itemCount={data.length} itemSize={50} {scrollOffset}>
{#snippet item({ index, style })}
{(onItemRender(index), '')}
<div {style}>{data[index]?.label}</div>
{/snippet}
</VirtualList>
</div>