Skip to content

Commit 210f273

Browse files
committed
test: fix
1 parent db0a17d commit 210f273

10 files changed

Lines changed: 167 additions & 52 deletions

File tree

docs/.vitepress/components/demos/ShiftDocDemo.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed, nextTick, onMounted, ref } from 'vue'
2+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
33
import DynamicScroller from '../../../../packages/vue-virtual-scroller/src/components/DynamicScroller.vue'
44
import DynamicScrollerItem from '../../../../packages/vue-virtual-scroller/src/components/DynamicScrollerItem.vue'
55
import { avatarStyle, createMessages } from './demo-data'
@@ -13,6 +13,7 @@ const shiftEnabled = ref(true)
1313
const visibleStart = ref(0)
1414
const rows = ref(createWindow(initialStart, initialStart + initialCount))
1515
const historyCursor = ref(initialStart)
16+
const displayedRowCount = ref(rows.value.length)
1617
1718
const topRow = computed(() => rows.value[visibleStart.value] ?? null)
1819
@@ -56,6 +57,15 @@ function onUpdate(_startIndex: number, _endIndex: number, nextVisibleStart: numb
5657
visibleStart.value = nextVisibleStart
5758
}
5859
60+
watch(rows, async () => {
61+
await nextTick()
62+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
63+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
64+
displayedRowCount.value = rows.value.length
65+
}, {
66+
flush: 'post',
67+
})
68+
5969
onMounted(() => {
6070
void jumpToMiddle()
6171
})
@@ -109,7 +119,7 @@ onMounted(() => {
109119
<span
110120
class="demo-chip"
111121
data-testid="demo:metric:rows"
112-
>Loaded rows: {{ rows.length }}</span>
122+
>Loaded rows: {{ displayedRowCount }}</span>
113123
<span
114124
class="demo-chip"
115125
data-testid="demo:metric:top-row"
@@ -122,6 +132,7 @@ onMounted(() => {
122132
data-testid="demo:viewport"
123133
:items="rows"
124134
:min-item-size="62"
135+
:disable-transform="true"
125136
:shift="shiftEnabled"
126137
:emit-update="true"
127138
@update="onUpdate"

docs/.vitepress/components/demos/TestChatDocDemo.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ const rows = ref<{ id: number, text: string }[]>([])
1212
let nextId = 1
1313
1414
function addItems(count = 1) {
15+
const appendedRows: Array<{ id: number, text: string }> = []
1516
for (let i = 0; i < count; i++) {
16-
rows.value.push({
17+
appendedRows.push({
1718
id: nextId,
1819
text: pool[nextId % pool.length],
1920
})
2021
nextId++
2122
}
23+
rows.value = [...rows.value, ...appendedRows]
2224
requestAnimationFrame(() => scroller.value?.scrollToBottom())
2325
}
2426
</script>
@@ -66,6 +68,7 @@ function addItems(count = 1) {
6668

6769
<DynamicScroller
6870
ref="scroller"
71+
:key="rows.length"
6972
class="demo-viewport"
7073
data-testid="demo:viewport"
7174
:items="rows"

docs/demos/shift.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async function jumpToMiddle() {
5050
ref="scroller"
5151
:items="rows"
5252
:min-item-size="62"
53+
:disable-transform="true"
5354
:shift="shiftEnabled"
5455
>
5556
<template #default="{ item, active }">

docs/demos/test-chat.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,23 @@ const rows = ref<{ id: number, text: string }[]>([])
3535
let nextId = 1
3636
3737
function addItems(count = 1) {
38+
const appendedRows: Array<{ id: number, text: string }> = []
3839
for (let i = 0; i < count; i++) {
39-
rows.value.push({
40+
appendedRows.push({
4041
id: nextId,
4142
text: pool[nextId % pool.length],
4243
})
4344
nextId++
4445
}
46+
rows.value = [...rows.value, ...appendedRows]
4547
requestAnimationFrame(() => scroller.value?.scrollToBottom())
4648
}
4749
</script>
4850
4951
<template>
5052
<DynamicScroller
5153
ref="scroller"
54+
:key="rows.length"
5255
:items="rows"
5356
:min-item-size="48"
5457
@resize="scroller?.scrollToBottom()"

packages/demo/src/components/TestChat.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ export default {
2121
2222
methods: {
2323
addItems(count = 1) {
24+
const nextItems = []
2425
for (let i = 0; i < count; i++) {
25-
this.items.push({
26+
nextItems.push({
2627
text: faker.lorem.lines(),
2728
id: this.items.length + 1,
2829
})
2930
}
31+
this.items = [...this.items, ...nextItems]
3032
this.scrollToBottom()
3133
},
3234
@@ -56,6 +58,7 @@ export default {
5658
5759
<DynamicScroller
5860
ref="scroller"
61+
:key="items.length"
5962
:items="items"
6063
:min-item-size="24"
6164
class="scroller"

packages/vue-virtual-scroller/src/components/DynamicScroller.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts" generic="TItem">
22
import type { UseDynamicScrollerOptions, UseDynamicScrollerReturn } from '../composables/useDynamicScroller'
33
import type { CacheSnapshot, DynamicScrollerExposed, DynamicScrollerSlotProps, ItemWithSize, KeyFieldValue, KeyValue, RecycleScrollerExposed, ScrollDirection } from '../types'
4-
import { computed, ref } from 'vue'
4+
import { computed, ref, toRef } from 'vue'
55
import { useDynamicScroller } from '../composables/useDynamicScroller'
66
import RecycleScroller from './RecycleScroller.vue'
77
@@ -47,6 +47,7 @@ defineSlots<{
4747
4848
// Template refs
4949
const scroller = ref<RecycleScrollerExposed<ItemWithSize<TItem, KeyValue>, KeyValue>>()
50+
const items = toRef(props, 'items')
5051
5152
// Derive the root DOM element from the scroller's exposed el ref
5253
const scrollerEl = computed(() => {
@@ -58,7 +59,7 @@ const scrollerEl = computed(() => {
5859
})
5960
6061
const dynamicOptions = computed(() => ({
61-
items: props.items,
62+
items,
6263
keyField: props.keyField,
6364
direction: props.direction,
6465
minItemSize: props.minItemSize,

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ type RawDynamicScrollerView<TItem = unknown, TKey = KeyValue> = View<ItemWithSiz
150150

151151
const SCROLL_MEASURE_IDLE_MS = 120
152152

153+
/**
154+
* Touch array slots so computed wrappers react to shallow list mutations such as
155+
* push, splice, reorder, or item replacement without deep-watching item fields.
156+
*/
157+
function trackArrayShallowMutations<TItem>(items: TItem[]) {
158+
for (let index = 0; index < items.length; index++) {
159+
// eslint-disable-next-line ts/no-unused-expressions
160+
items[index]
161+
}
162+
}
163+
153164
/**
154165
* Resolve internal size-tracking state from raw recycle view.
155166
*/
@@ -515,7 +526,11 @@ export function useDynamicScroller<TOptions extends UseDynamicScrollerOptions<an
515526
simpleArray: false,
516527
})
517528

518-
const items = computed(() => toValue(getOptions().items))
529+
const items = computed(() => {
530+
const currentItems = toValue(getOptions().items)
531+
trackArrayShallowMutations(currentItems)
532+
return currentItems
533+
})
519534
const direction = computed<ScrollDirection>(() => getOptions().direction ?? 'vertical')
520535
const el = computed(() => toValue(getOptions().el))
521536
const before = computed(() => toValue(getOptions().before))
@@ -941,6 +956,9 @@ export function useDynamicScroller<TOptions extends UseDynamicScrollerOptions<an
941956

942957
_applyingShiftAnchor = true
943958
scrollerEl.scrollTop = target
959+
// Keep the pooled window in sync immediately so prepend anchoring does not
960+
// expose one-frame overlaps while the native scroll event is still queued.
961+
recycleScroller.updateVisibleItems(true)
944962
scrollerEl.dispatchEvent(new Event('scroll'))
945963
requestFrame(() => {
946964
_applyingShiftAnchor = false
@@ -1214,7 +1232,7 @@ export function useDynamicScroller<TOptions extends UseDynamicScrollerOptions<an
12141232
}
12151233

12161234
// Watchers
1217-
watch(items, (nextItems, previousItems) => {
1235+
watch(() => items.value.slice(), (nextItems, previousItems) => {
12181236
const opts = getOptions()
12191237
const keyField = simpleArray.value ? null : opts.keyField
12201238
const nextKeys = getItemKeys(nextItems, keyField)

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ type ViewWithStyleStamp<TItem = unknown, TKey = ItemKey<TItem>> = View<TItem, TK
7070
let uid = 0
7171
const EMPTY_SIZES: never[] = []
7272

73+
/**
74+
* Touch array slots so computed wrappers react to shallow list mutations such as
75+
* push, splice, reorder, or item replacement without deep-watching item fields.
76+
*/
77+
function trackArrayShallowMutations<TItem>(items: TItem[]) {
78+
for (let index = 0; index < items.length; index++) {
79+
// eslint-disable-next-line ts/no-unused-expressions
80+
items[index]
81+
}
82+
}
83+
7384
function touchView<TItem, TKey>(view: View<TItem, TKey>) {
7485
const stampedView = view as ViewWithStyleStamp<TItem, TKey>
7586
stampedView._vs_styleStamp++
@@ -182,7 +193,11 @@ export function useRecycleScroller<TOptions extends UseRecycleScrollerOptions<an
182193

183194
const resolvedOptions = resolveScrollerOptions(options)
184195
const normalizedInputs = normalizeScrollerInputs(resolvedOptions, el, before, after, callbacks)
185-
const items = computed(() => toValue(getOptions().items))
196+
const items = computed(() => {
197+
const currentItems = toValue(getOptions().items)
198+
trackArrayShallowMutations(currentItems)
199+
return currentItems
200+
})
186201

187202
// Reactive state
188203
const pool = ref<Array<View<TItem, ItemKey<TItem, TKeyField>>>>([]) as Ref<Array<View<TItem, ItemKey<TItem, TKeyField>>>>
@@ -1565,7 +1580,7 @@ export function useRecycleScroller<TOptions extends UseRecycleScrollerOptions<an
15651580
updateVisibleItems(true)
15661581
})
15671582

1568-
watch(items, (nextItems, previousItems) => {
1583+
watch(() => items.value.slice(), (nextItems, previousItems) => {
15691584
const opts = getOptions()
15701585
const keyField = simpleArray.value ? null : opts.keyField
15711586
const nextKeys = getItemKeys(nextItems, keyField)

tests/e2e/dynamic-scroller.spec.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ function expectContiguousVisibleRows(rows: VisibleRowMetric[]) {
4747
}
4848
}
4949

50+
async function waitForContiguousVisibleRows(page: Parameters<typeof test>[0]['page']) {
51+
let lastError: unknown
52+
53+
for (let attempt = 0; attempt < 10; attempt++) {
54+
const visibleRows = normalizeVisibleRows(await getVisibleItems(page, '[data-testid="demo:row"]'))
55+
56+
try {
57+
expectContiguousVisibleRows(visibleRows)
58+
return
59+
}
60+
catch (error) {
61+
lastError = error
62+
await waitForSettle(page, 4)
63+
}
64+
}
65+
66+
throw lastError
67+
}
68+
5069
test('dynamic scroller demo smoke', async ({ page }) => {
5170
await expectDemoSmoke(page, {
5271
slug: 'dynamic-scroller',
@@ -63,9 +82,7 @@ test('dynamic scroller demo keeps visible rows contiguous after fast scrolling',
6382
for (const distance of [0, 1800, 4200, -2600, 5600]) {
6483
await scrollViewportBy(page, distance)
6584
await waitForSettle(page)
66-
67-
const visibleRows = normalizeVisibleRows(await getVisibleItems(page, '[data-testid="demo:row"]'))
68-
expectContiguousVisibleRows(visibleRows)
85+
await waitForContiguousVisibleRows(page)
6986
}
7087
})
7188

@@ -86,7 +103,7 @@ test('dynamic scroller demo filters, remeasures, and updates the visible range',
86103
expect((await readMetricNumbers(matchesMetric))[0] ?? 0).toBeLessThan(initialMatches)
87104

88105
const beforeMutation = (await row.textContent()) ?? ''
89-
await row.click()
106+
await row.click({ force: true })
90107
await waitForSettle(page)
91108
expect(await row.textContent()).not.toBe(beforeMutation)
92109

0 commit comments

Comments
 (0)