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
8 changes: 2 additions & 6 deletions packages/solid-virtual/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
onCleanup,
onMount,
} from 'solid-js'
import { createStore, reconcile } from 'solid-js/store'
import { createStore } from 'solid-js/store'
import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core'

export * from '@tanstack/virtual-core'
Expand Down Expand Up @@ -71,11 +71,7 @@ function createVirtualizerBase<
sync: boolean,
) => {
instance._willUpdate()
setVirtualItems(
reconcile(instance.getVirtualItems(), {
key: 'index',
}),
)
setVirtualItems(instance.getVirtualItems())
setTotalSize(instance.getTotalSize())
options.onChange?.(instance, sync)
},
Expand Down
51 changes: 51 additions & 0 deletions packages/solid-virtual/tests/filter-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from 'vitest'
import { createRoot, createSignal } from 'solid-js'

import { createVirtualizer } from '../src/index'

test('virtual items correctly index into filtered data after reactive shrink', () => {
createRoot((dispose) => {
const all = ['apple', 'banana', 'cherry', 'date', 'fig', 'grape']
const [query, setQuery] = createSignal('')

const filtered = () => {
const q = query()
if (!q) return all
return all.filter(s => s.includes(q))
}

const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return filtered().length
},
getScrollElement: () => null,
estimateSize: () => 60,
initialRect: { width: 800, height: 600 },
})

expect(virtualizer.getVirtualItems().length).toBe(6)
expect(virtualizer.getVirtualItems().map(v => filtered()[v.index]))
.toEqual(['apple', 'banana', 'cherry', 'date', 'fig', 'grape'])

setQuery('e')

expect(virtualizer.getVirtualItems().length).toBe(4)
const items = virtualizer.getVirtualItems()
expect(items.map(v => filtered()[v.index]))
.toEqual(['apple', 'cherry', 'date', 'grape'])

setQuery('zz')

expect(virtualizer.getVirtualItems().length).toBe(0)
expect(virtualizer.getVirtualItems().map(v => filtered()[v.index]))
.toEqual([])

setQuery('cherry')

expect(virtualizer.getVirtualItems().length).toBe(1)
expect(virtualizer.getVirtualItems().map(v => filtered()[v.index]))
.toEqual(['cherry'])

dispose()
})
})
44 changes: 44 additions & 0 deletions packages/solid-virtual/tests/reactivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { createRoot, createSignal } from 'solid-js'
import { unwrap } from 'solid-js/store'
import { createVirtualizer } from '../src/index'

describe('reactivity: unaffected slots keep stable references', () => {
it('does not recreate VirtualItem objects for slots whose data did not change', () => {
createRoot((dispose) => {
const data = Array.from({ length: 50 }, (_, i) => `item-${i}`)
const [filtered, setFiltered] = createSignal(data)

const virtualizer = createVirtualizer({
get count() {
return filtered().length
},
getScrollElement: () => document.createElement('div'),
estimateSize: () => 30,
overscan: 0,
})
Comment on lines +12 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚑ Quick win

Test can pass with zero assertions executed.

getScrollElement returns a fresh, unattached <div> with zero layout size, so the computed visible virtual-item range may end up empty (or all slots could end up "affected"). The subsequent loop only calls expect(...).toBe(...) inside a conditional guard (Lines 32-38), so if unaffectedCount is 0 or no index/start/end triple matches, the test finishes with no assertions ever run and still passes β€” silently defeating the purpose of this regression test.

Two independent fixes are needed:

  1. Give the scroll element a stable, non-zero measured size (or stub getBoundingClientRect) and reuse the same element reference on each call.
  2. Guard against a vacuous pass by requiring at least one assertion.
πŸ› Proposed fix
+import { beforeEach, describe, expect, it } from 'vitest'
+
 describe('reactivity: unaffected slots keep stable references', () => {
   it('does not recreate VirtualItem objects for slots whose data did not change', () => {
     createRoot((dispose) => {
       const data = Array.from({ length: 50 }, (_, i) => `item-${i}`)
       const [filtered, setFiltered] = createSignal(data)
 
+      const scrollElement = document.createElement('div')
+      Object.defineProperty(scrollElement, 'clientHeight', { value: 300 })
+      scrollElement.getBoundingClientRect = () =>
+        ({ height: 300, width: 300 }) as DOMRect
+
       const virtualizer = createVirtualizer({
         get count() {
           return filtered().length
         },
-        getScrollElement: () => document.createElement('div'),
+        getScrollElement: () => scrollElement,
         estimateSize: () => 30,
         overscan: 0,
       })
 
       const before = virtualizer.getVirtualItems()
       const beforeRefs = before.map((item) => unwrap(item))
+      expect(beforeRefs.length).toBeGreaterThan(0)
 
       // Shrink the array; leaves the first visible rows' index/start/end/size untouched.
       setFiltered(data.slice(0, 40))
 
       const after = virtualizer.getVirtualItems()
       const afterRefs = after.map((item) => unwrap(item))
 
       const unaffectedCount = Math.min(beforeRefs.length, afterRefs.length)
+      let checkedAtLeastOne = false
       for (let i = 0; i < unaffectedCount; i++) {
         if (
           beforeRefs[i].start === afterRefs[i].start &&
           beforeRefs[i].end === afterRefs[i].end &&
           beforeRefs[i].index === afterRefs[i].index
         ) {
+          checkedAtLeastOne = true
           expect(afterRefs[i]).toBe(beforeRefs[i])
         }
       }
+      expect(checkedAtLeastOne).toBe(true)
 
       dispose()
     })
   })
 })

Also applies to: 30-39

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/solid-virtual/tests/reactivity.test.ts` around lines 12 - 19, The
regression test in createVirtualizer is vulnerable to a vacuous pass because
getScrollElement returns a new unattached div with no layout size, which can
make the virtual range empty and skip all expects in the loop. Update the test
to use a stable scroll element reference with a non-zero measured size (or stub
its layout measurement) so the virtualizer computes a meaningful range, and add
an assertion in the test body to require that at least one expectation actually
runs before the test can pass.


const before = virtualizer.getVirtualItems()
const beforeRefs = before.map((item) => unwrap(item))

// Shrink the array; leaves the first visible rows' index/start/end/size untouched.
setFiltered(data.slice(0, 40))

const after = virtualizer.getVirtualItems()
const afterRefs = after.map((item) => unwrap(item))

const unaffectedCount = Math.min(beforeRefs.length, afterRefs.length)
for (let i = 0; i < unaffectedCount; i++) {
if (
beforeRefs[i].start === afterRefs[i].start &&
beforeRefs[i].end === afterRefs[i].end &&
beforeRefs[i].index === afterRefs[i].index
) {
expect(afterRefs[i]).toBe(beforeRefs[i])
}
}

dispose()
})
})
})