Skip to content

Commit ff49fea

Browse files
committed
fix(useRecycleScroller): prevent blank rows during transient cache states, fix #906
1 parent cac4e52 commit ff49fea

3 files changed

Lines changed: 109 additions & 5 deletions

File tree

packages/vue-virtual-scroller/src/composables/useRecycleScroller.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,70 @@ describe('useRecycleScroller', () => {
11541154
expect(recoveryRange[1]).toBeGreaterThanOrEqual(baselineRange[1])
11551155
})
11561156

1157+
it('still assigns a view for an in-range index whose cached size is 0', async () => {
1158+
// Regression for issue #906: the step-2 loop in updateVisibleItems used
1159+
// to early-return when `sizesValue[i].size` was 0 (or `sizesValue[i]`
1160+
// was undefined), skipping view assignment for that index. Combined
1161+
// with step 1 having already recycled every prior view (on the
1162+
// itemsChanged / non-continuous paths), the DOM slot was left blank
1163+
// until the next reconciliation tick. The fix falls back to
1164+
// `_computedMinItemSize` so every index in the resolved range claims
1165+
// a pooled view regardless of cache transients.
1166+
const { vm } = mountHarness({
1167+
items: Array.from({ length: 5 }, (_, id) => ({ id, size: 20 })),
1168+
itemSize: null,
1169+
minItemSize: 20,
1170+
clientHeight: 100,
1171+
})
1172+
1173+
await nextTick()
1174+
await nextTick()
1175+
1176+
// Sanity: all five items should be in the visible pool to start with.
1177+
const initialIndices = vm.visiblePool.map((view: View) => view.nr.index)
1178+
expect(initialIndices).toEqual([0, 1, 2, 3, 4])
1179+
1180+
// Punch size=0 into the middle of the cache. The accumulators stay
1181+
// populated so the binary search still includes index 2 in the range —
1182+
// we want to exercise the per-index assignment loop, not the gate.
1183+
const sizesRef = vm.sizes as Array<{ accumulator: number, size: number | undefined } | undefined>
1184+
const savedSize = sizesRef[2]!.size
1185+
sizesRef[2]!.size = 0
1186+
1187+
vm.updateVisibleItems(true)
1188+
1189+
sizesRef[2]!.size = savedSize
1190+
1191+
const recoveryIndices = vm.visiblePool.map((view: View) => view.nr.index)
1192+
expect(recoveryIndices).toContain(2)
1193+
})
1194+
1195+
it('still assigns a view when an in-range sizesValue entry is undefined', async () => {
1196+
// Same regression as the size=0 case (issue #906), but exercising the
1197+
// `sizesValue[i] && ...` branch. The fallback must catch undefined
1198+
// entries too — a sparse cache slot would otherwise also skip the slot.
1199+
const { vm } = mountHarness({
1200+
items: Array.from({ length: 5 }, (_, id) => ({ id, size: 20 })),
1201+
itemSize: null,
1202+
minItemSize: 20,
1203+
clientHeight: 100,
1204+
})
1205+
1206+
await nextTick()
1207+
await nextTick()
1208+
1209+
const sizesRef = vm.sizes as Array<{ accumulator: number, size: number | undefined } | undefined>
1210+
const saved = sizesRef[2]
1211+
sizesRef[2] = undefined
1212+
1213+
vm.updateVisibleItems(true)
1214+
1215+
sizesRef[2] = saved
1216+
1217+
const recoveryIndices = vm.visiblePool.map((view: View) => view.nr.index)
1218+
expect(recoveryIndices).toContain(2)
1219+
})
1220+
11571221
it('does not crash on 0 → 1 items transition in variable-size mode', async () => {
11581222
// Regression: in variable-size mode the size cache is computed lazily from
11591223
// `items`. When an empty list gains its first row, an upstream wrapper can

packages/vue-virtual-scroller/src/composables/useRecycleScroller.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,8 +1468,13 @@ export function useRecycleScroller<TOptions extends UseRecycleScrollerOptions<an
14681468
const viewVisible = renderedIndexSet
14691469
? renderedIndexSet.has(view.nr.index)
14701470
: (view.nr.index >= startIndex && view.nr.index < endIndex)
1471-
const viewSize = itemSize || (sizesValue[view.nr.index] && sizesValue[view.nr.index].size)
1472-
if (!viewVisible || !viewSize) {
1471+
// Recycle only when the view's index has left the visible range.
1472+
// A 0 / undefined cached size for a still-visible row is a
1473+
// transient state (e.g. an item that hasn't been measured yet);
1474+
// recycling on it would force step 2 to immediately re-claim the
1475+
// slot, and any per-tick gap visible to the browser shows up as
1476+
// a blank row. See issue #906.
1477+
if (!viewVisible) {
14731478
removeAndRecycleView(view, keepFlowModeOrderIncrementally)
14741479
}
14751480
}
@@ -1493,9 +1498,15 @@ export function useRecycleScroller<TOptions extends UseRecycleScrollerOptions<an
14931498
let firstVisiblePosition: number | null = null
14941499
let renderedVisibleSize = 0
14951500
forEachRenderedIndex(startIndex, endIndex, renderedIndices, (i) => {
1496-
const elementSize = itemSize || (sizesValue[i] && sizesValue[i].size)
1497-
if (!elementSize)
1498-
return
1501+
// Fall back to `_computedMinItemSize` (and ultimately `1`) so every
1502+
// index in the resolved range claims a pooled view even when the
1503+
// cache is transiently sparse or reports `size: 0` for an unmeasured
1504+
// row. Returning early here would leave the DOM slot blank until
1505+
// the next reconciliation tick — exactly the "blank rows" symptom
1506+
// reported in issue #906, made worse by `scrollToItem` jumps which
1507+
// recycle every view in step 1 and rely on this loop to repopulate.
1508+
const cachedSize = sizesValue[i] && sizesValue[i].size
1509+
const elementSize = itemSize || cachedSize || _computedMinItemSize || 1
14991510
item = currentItems[i]
15001511
const key = (keyField ? resolveItemKey(item, i, keyField) : i) as ItemKey<TItem, TKeyField>
15011512
view = views.get(key)

tests/e2e/dynamic-scroller.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
metric,
77
readMetricNumbers,
88
scrollViewportBy,
9+
viewport,
910
waitForSettle,
1011
} from './support/demo'
1112

@@ -86,6 +87,34 @@ test('dynamic scroller demo keeps visible rows contiguous after fast scrolling',
8687
}
8788
})
8889

90+
test('dynamic scroller demo keeps visible rows contiguous after a far absolute jump', async ({ browserName, page }) => {
91+
test.skip(browserName !== 'chromium')
92+
93+
// Regression for issue #906: a single large jump (the same code path
94+
// `scrollToItem` uses internally) recycles every visible view in
95+
// updateVisibleItems step 1 and must repopulate the slots in step 2,
96+
// even for rows whose size cache hasn't measured yet. A skipped index
97+
// would surface here as a non-contiguous row gap.
98+
await page.goto('/demos/dynamic-scroller')
99+
await waitForSettle(page)
100+
101+
await viewport(page).evaluate((element) => {
102+
const target = element as HTMLElement
103+
target.scrollTop = Math.max(0, Math.floor((target.scrollHeight - target.clientHeight) * 0.5))
104+
target.dispatchEvent(new Event('scroll'))
105+
})
106+
await waitForSettle(page)
107+
await waitForContiguousVisibleRows(page)
108+
109+
await viewport(page).evaluate((element) => {
110+
const target = element as HTMLElement
111+
target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight - 100)
112+
target.dispatchEvent(new Event('scroll'))
113+
})
114+
await waitForSettle(page)
115+
await waitForContiguousVisibleRows(page)
116+
})
117+
89118
test('dynamic scroller demo filters, remeasures, and updates the visible range', async ({ browserName, page }) => {
90119
test.skip(browserName !== 'chromium')
91120

0 commit comments

Comments
 (0)