mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
perf(grid): stage virtual card mounting (#619)
This commit is contained in:
@@ -25,8 +25,8 @@ const props = withDefaults(
|
|||||||
scrollToIndex: undefined,
|
scrollToIndex: undefined,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
columns: undefined,
|
columns: undefined,
|
||||||
initialCount: 24,
|
initialCount: 6,
|
||||||
batchSize: 24,
|
batchSize: 6,
|
||||||
overscanRows: 4,
|
overscanRows: 4,
|
||||||
getItemKey: undefined,
|
getItemKey: undefined,
|
||||||
},
|
},
|
||||||
@@ -54,6 +54,8 @@ const viewportBottom = ref(0)
|
|||||||
const heightVersion = ref(0)
|
const heightVersion = ref(0)
|
||||||
const frozenVisibleRange = ref<VirtualRange | null>(null)
|
const frozenVisibleRange = ref<VirtualRange | null>(null)
|
||||||
const isOverlayGrid = ref(false)
|
const isOverlayGrid = ref(false)
|
||||||
|
const progressiveStartIndex = ref(0)
|
||||||
|
const progressiveEndIndex = ref(0)
|
||||||
|
|
||||||
const itemHeights = new Map<ItemKey, number>()
|
const itemHeights = new Map<ItemKey, number>()
|
||||||
const observedElements = new Map<HTMLElement, ItemKey>()
|
const observedElements = new Map<HTMLElement, ItemKey>()
|
||||||
@@ -66,6 +68,7 @@ let overlayLockObserver: MutationObserver | null = null
|
|||||||
let scrollTarget: ScrollTarget | null = null
|
let scrollTarget: ScrollTarget | null = null
|
||||||
let layoutFrameId: number | null = null
|
let layoutFrameId: number | null = null
|
||||||
let scrollFrameId: number | null = null
|
let scrollFrameId: number | null = null
|
||||||
|
let progressiveFrameId: number | null = null
|
||||||
let mounted = false
|
let mounted = false
|
||||||
let pendingRevealIndex: number | null = null
|
let pendingRevealIndex: number | null = null
|
||||||
let lastMeasuredColumnCount = 0
|
let lastMeasuredColumnCount = 0
|
||||||
@@ -73,6 +76,8 @@ let lastMeasuredColumnWidth = 0
|
|||||||
let documentOverlayLocked = false
|
let documentOverlayLocked = false
|
||||||
|
|
||||||
const safeGap = computed(() => Math.max(0, props.gap))
|
const safeGap = computed(() => Math.max(0, props.gap))
|
||||||
|
const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCount)))
|
||||||
|
const safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize)))
|
||||||
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
||||||
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
||||||
|
|
||||||
@@ -160,7 +165,7 @@ const rowMetrics = computed(() => {
|
|||||||
|
|
||||||
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
||||||
|
|
||||||
const calculatedVisibleRange = computed<VirtualRange>(() => {
|
const calculatedViewportRange = computed<VirtualRange>(() => {
|
||||||
if (isOverlayGrid.value) {
|
if (isOverlayGrid.value) {
|
||||||
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
|
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
|
||||||
|
|
||||||
@@ -187,8 +192,24 @@ const calculatedVisibleRange = computed<VirtualRange>(() => {
|
|||||||
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
|
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
|
||||||
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
|
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
|
||||||
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
|
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
|
||||||
const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1)
|
|
||||||
const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1)
|
return {
|
||||||
|
endIndex: Math.min(props.items.length, (lastVisibleRow + 1) * columnCount.value),
|
||||||
|
endRow: lastVisibleRow,
|
||||||
|
startIndex: firstVisibleRow * columnCount.value,
|
||||||
|
startRow: firstVisibleRow,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const calculatedVisibleRange = computed<VirtualRange>(() => {
|
||||||
|
if (isOverlayGrid.value || !props.items.length) {
|
||||||
|
return calculatedViewportRange.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportRange = calculatedViewportRange.value
|
||||||
|
const rowCount = rowMetrics.value.rowCount
|
||||||
|
const startRow = clamp(viewportRange.startRow - safeOverscanRows.value, 0, rowCount - 1)
|
||||||
|
const endRow = clamp(viewportRange.endRow + safeOverscanRows.value, startRow, rowCount - 1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
|
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
|
||||||
@@ -200,10 +221,45 @@ const calculatedVisibleRange = computed<VirtualRange>(() => {
|
|||||||
|
|
||||||
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
|
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
|
||||||
|
|
||||||
|
// 视口首批内容同步提交,额外 overscan 分帧挂载;完整列表高度仍由 spacer 立即占位。
|
||||||
|
const renderedVisibleRange = computed<VirtualRange>(() => {
|
||||||
|
const range = visibleRange.value
|
||||||
|
|
||||||
|
if (isOverlayGrid.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) {
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportRange = calculatedViewportRange.value
|
||||||
|
const initialEndIndex = Math.min(
|
||||||
|
range.endIndex,
|
||||||
|
Math.max(
|
||||||
|
viewportRange.endIndex,
|
||||||
|
Math.ceil((viewportRange.startIndex + safeInitialCount.value) / columnCount.value) * columnCount.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const stagedStartIndex = clamp(progressiveStartIndex.value, range.startIndex, viewportRange.startIndex)
|
||||||
|
const stagedEndIndex = clamp(
|
||||||
|
Math.max(progressiveEndIndex.value, initialEndIndex),
|
||||||
|
viewportRange.endIndex,
|
||||||
|
range.endIndex,
|
||||||
|
)
|
||||||
|
const startRow = Math.max(range.startRow, Math.floor(stagedStartIndex / columnCount.value))
|
||||||
|
const endRow = Math.min(range.endRow, Math.max(range.startRow, Math.ceil(stagedEndIndex / columnCount.value) - 1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
endIndex: Math.min(range.endIndex, (endRow + 1) * columnCount.value),
|
||||||
|
endRow,
|
||||||
|
startIndex: startRow * columnCount.value,
|
||||||
|
startRow,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const visibleCells = computed<VirtualCell[]>(() => {
|
const visibleCells = computed<VirtualCell[]>(() => {
|
||||||
const cells: VirtualCell[] = []
|
const cells: VirtualCell[] = []
|
||||||
|
const range = renderedVisibleRange.value
|
||||||
|
|
||||||
for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) {
|
for (let index = range.startIndex; index < range.endIndex; index += 1) {
|
||||||
cells.push({
|
cells.push({
|
||||||
item: props.items[index],
|
item: props.items[index],
|
||||||
index,
|
index,
|
||||||
@@ -219,18 +275,20 @@ const topSpacerHeight = computed(() => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
|
return rowMetrics.value.offsets[renderedVisibleRange.value.startRow] ?? 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleBlockHeight = computed(() => {
|
const visibleBlockHeight = computed(() => {
|
||||||
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
|
const range = renderedVisibleRange.value
|
||||||
|
|
||||||
|
if (!props.items.length || range.endIndex <= range.startIndex) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
(rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) +
|
(rowMetrics.value.offsets[range.endRow] ?? 0) +
|
||||||
(rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) -
|
(rowMetrics.value.heights[range.endRow] ?? 0) -
|
||||||
(rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0),
|
(rowMetrics.value.offsets[range.startRow] ?? 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -331,11 +389,14 @@ function freezeVisibleRange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
|
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
|
||||||
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
|
cancelProgressiveRender()
|
||||||
|
frozenVisibleRange.value = { ...renderedVisibleRange.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseVisibleRange() {
|
function releaseVisibleRange() {
|
||||||
frozenVisibleRange.value = null
|
frozenVisibleRange.value = null
|
||||||
|
syncProgressiveWindow()
|
||||||
|
queueProgressiveRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOverlayLockChange() {
|
function handleOverlayLockChange() {
|
||||||
@@ -646,6 +707,74 @@ function queueViewportSync() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelProgressiveRender() {
|
||||||
|
if (progressiveFrameId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cancelAnimationFrame(progressiveFrameId)
|
||||||
|
progressiveFrameId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncProgressiveWindow() {
|
||||||
|
if (isOverlayGrid.value || frozenVisibleRange.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = calculatedVisibleRange.value
|
||||||
|
const viewportRange = calculatedViewportRange.value
|
||||||
|
const overlapsViewport =
|
||||||
|
progressiveEndIndex.value > viewportRange.startIndex && progressiveStartIndex.value < viewportRange.endIndex
|
||||||
|
|
||||||
|
if (!overlapsViewport) {
|
||||||
|
progressiveStartIndex.value = viewportRange.startIndex
|
||||||
|
progressiveEndIndex.value = viewportRange.endIndex
|
||||||
|
} else {
|
||||||
|
progressiveStartIndex.value = clamp(progressiveStartIndex.value, range.startIndex, viewportRange.startIndex)
|
||||||
|
progressiveEndIndex.value = clamp(progressiveEndIndex.value, viewportRange.endIndex, range.endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialEndIndex = Math.min(
|
||||||
|
range.endIndex,
|
||||||
|
Math.max(
|
||||||
|
viewportRange.endIndex,
|
||||||
|
Math.ceil((viewportRange.startIndex + safeInitialCount.value) / columnCount.value) * columnCount.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
progressiveEndIndex.value = Math.max(progressiveEndIndex.value, initialEndIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProgressiveRender() {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!mounted ||
|
||||||
|
progressiveFrameId !== null ||
|
||||||
|
isOverlayGrid.value ||
|
||||||
|
frozenVisibleRange.value
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = renderedVisibleRange.value
|
||||||
|
const targetRange = visibleRange.value
|
||||||
|
if (range.endIndex >= targetRange.endIndex && range.startIndex <= targetRange.startIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progressiveFrameId = window.requestAnimationFrame(() => {
|
||||||
|
progressiveFrameId = null
|
||||||
|
const batchRows = Math.max(1, Math.floor(safeBatchSize.value / columnCount.value))
|
||||||
|
const batchItems = batchRows * columnCount.value
|
||||||
|
|
||||||
|
if (range.endIndex < targetRange.endIndex) {
|
||||||
|
progressiveEndIndex.value = Math.min(targetRange.endIndex, range.endIndex + batchItems)
|
||||||
|
} else {
|
||||||
|
progressiveStartIndex.value = Math.max(targetRange.startIndex, range.startIndex - batchItems)
|
||||||
|
}
|
||||||
|
queueProgressiveRender()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getTrackScrollTop() {
|
function getTrackScrollTop() {
|
||||||
const element = trackRef.value
|
const element = trackRef.value
|
||||||
|
|
||||||
@@ -821,17 +950,20 @@ onMounted(() => {
|
|||||||
window.addEventListener('resize', handleWindowResize, { passive: true })
|
window.addEventListener('resize', handleWindowResize, { passive: true })
|
||||||
|
|
||||||
queueLayoutSync()
|
queueLayoutSync()
|
||||||
|
queueProgressiveRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
mounted = true
|
mounted = true
|
||||||
refreshScrollTarget()
|
refreshScrollTarget()
|
||||||
queueLayoutSync()
|
queueLayoutSync()
|
||||||
|
queueProgressiveRender()
|
||||||
requestAnimationFrame(queueLayoutSync)
|
requestAnimationFrame(queueLayoutSync)
|
||||||
})
|
})
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
mounted = false
|
mounted = false
|
||||||
|
cancelProgressiveRender()
|
||||||
removeScrollListener(scrollTarget)
|
removeScrollListener(scrollTarget)
|
||||||
scrollTarget = null
|
scrollTarget = null
|
||||||
})
|
})
|
||||||
@@ -858,13 +990,22 @@ onUnmounted(() => {
|
|||||||
window.cancelAnimationFrame(scrollFrameId)
|
window.cancelAnimationFrame(scrollFrameId)
|
||||||
scrollFrameId = null
|
scrollFrameId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelProgressiveRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
itemKeys,
|
itemKeys,
|
||||||
(nextKeys, previousKeys) => {
|
(nextKeys, previousKeys) => {
|
||||||
|
if (!didKeysAppend(nextKeys, previousKeys)) {
|
||||||
|
cancelProgressiveRender()
|
||||||
|
progressiveStartIndex.value = 0
|
||||||
|
progressiveEndIndex.value = 0
|
||||||
|
}
|
||||||
syncMeasurementsForItems(nextKeys, previousKeys)
|
syncMeasurementsForItems(nextKeys, previousKeys)
|
||||||
|
syncProgressiveWindow()
|
||||||
queueLayoutSync()
|
queueLayoutSync()
|
||||||
|
queueProgressiveRender()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -876,15 +1017,29 @@ watch(
|
|||||||
() => props.estimatedItemHeight,
|
() => props.estimatedItemHeight,
|
||||||
() => props.itemAspectRatio,
|
() => props.itemAspectRatio,
|
||||||
() => props.columns,
|
() => props.columns,
|
||||||
|
() => props.initialCount,
|
||||||
|
() => props.batchSize,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
|
cancelProgressiveRender()
|
||||||
|
syncProgressiveWindow()
|
||||||
queueLayoutSync()
|
queueLayoutSync()
|
||||||
|
queueProgressiveRender()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([columnCount, columnWidth], () => {
|
watch([columnCount, columnWidth], () => {
|
||||||
|
cancelProgressiveRender()
|
||||||
invalidateMeasurementsForLayoutChange()
|
invalidateMeasurementsForLayoutChange()
|
||||||
|
syncProgressiveWindow()
|
||||||
queueViewportSync()
|
queueViewportSync()
|
||||||
|
queueProgressiveRender()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([calculatedViewportRange, calculatedVisibleRange], () => {
|
||||||
|
cancelProgressiveRender()
|
||||||
|
syncProgressiveWindow()
|
||||||
|
queueProgressiveRender()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { render, waitFor } from '@testing-library/vue'
|
import { render, waitFor } from '@testing-library/vue'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('ProgressiveCardGrid scroll target lifecycle', () => {
|
describe('ProgressiveCardGrid scroll target lifecycle', () => {
|
||||||
@@ -35,3 +36,180 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ProgressiveCardGrid mount scheduling', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mounts an appended visible range in frame-bounded batches', async () => {
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>()
|
||||||
|
let frameId = 0
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
|
frameId += 1
|
||||||
|
callbacks.set(frameId, callback)
|
||||||
|
|
||||||
|
return frameId
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||||
|
callbacks.delete(id)
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100)
|
||||||
|
|
||||||
|
const initialItems = Array.from({ length: 8 }, (_, id) => ({ id }))
|
||||||
|
const { container, rerender } = render(ProgressiveCardGrid, {
|
||||||
|
props: {
|
||||||
|
batchSize: 4,
|
||||||
|
columns: 4,
|
||||||
|
estimatedItemHeight: 100,
|
||||||
|
gap: 0,
|
||||||
|
initialCount: 4,
|
||||||
|
items: initialItems,
|
||||||
|
getItemKey: (item: { id: number }) => item.id,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: '<div>item</div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const flushFrame = async () => {
|
||||||
|
const frameCallbacks = [...callbacks.values()]
|
||||||
|
callbacks.clear()
|
||||||
|
frameCallbacks.forEach(callback => callback(performance.now()))
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(4)
|
||||||
|
expect(container.querySelector('.progressive-card-grid__spacer')).toHaveStyle({ blockSize: '100px' })
|
||||||
|
await flushFrame()
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(8)
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
batchSize: 4,
|
||||||
|
columns: 4,
|
||||||
|
estimatedItemHeight: 100,
|
||||||
|
gap: 0,
|
||||||
|
initialCount: 4,
|
||||||
|
items: Array.from({ length: 20 }, (_, id) => ({ id })),
|
||||||
|
getItemKey: (item: { id: number }) => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(8)
|
||||||
|
expect(container.querySelector('.progressive-card-grid__spacer')).toHaveStyle({ blockSize: '300px' })
|
||||||
|
await flushFrame()
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(12)
|
||||||
|
await flushFrame()
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the staged window around the viewport after forward and reverse jumps', async () => {
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>()
|
||||||
|
let frameId = 0
|
||||||
|
let scrollOffset = 0
|
||||||
|
const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
|
frameId += 1
|
||||||
|
callbacks.set(frameId, callback)
|
||||||
|
|
||||||
|
return frameId
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||||
|
callbacks.delete(id)
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100)
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||||
|
if (!this.classList.contains('progressive-card-grid__track')) {
|
||||||
|
return getBoundingClientRect.call(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottom: 2500 - scrollOffset,
|
||||||
|
height: 2500,
|
||||||
|
left: 0,
|
||||||
|
right: 400,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
top: -scrollOffset,
|
||||||
|
width: 400,
|
||||||
|
x: 0,
|
||||||
|
y: -scrollOffset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = render(ProgressiveCardGrid, {
|
||||||
|
props: {
|
||||||
|
batchSize: 4,
|
||||||
|
columns: 4,
|
||||||
|
estimatedItemHeight: 100,
|
||||||
|
gap: 0,
|
||||||
|
initialCount: 4,
|
||||||
|
items: Array.from({ length: 100 }, (_, id) => ({ id })),
|
||||||
|
getItemKey: (item: { id: number }) => item.id,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: '<div>item</div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const flushFrame = async () => {
|
||||||
|
const frameCallbacks = [...callbacks.values()]
|
||||||
|
callbacks.clear()
|
||||||
|
frameCallbacks.forEach(callback => callback(performance.now()))
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
const renderedIndices = () =>
|
||||||
|
Array.from(container.querySelectorAll('[data-progressive-grid-index]'), element =>
|
||||||
|
Number(element.getAttribute('data-progressive-grid-index')),
|
||||||
|
)
|
||||||
|
|
||||||
|
await flushFrame()
|
||||||
|
scrollOffset = 2000
|
||||||
|
window.dispatchEvent(new Event('scroll'))
|
||||||
|
await flushFrame()
|
||||||
|
expect(renderedIndices().length).toBeLessThanOrEqual(12)
|
||||||
|
expect(renderedIndices()).toEqual(expect.arrayContaining([80, 81, 82, 83]))
|
||||||
|
|
||||||
|
scrollOffset = 0
|
||||||
|
window.dispatchEvent(new Event('scroll'))
|
||||||
|
await flushFrame()
|
||||||
|
expect(renderedIndices().length).toBeLessThanOrEqual(12)
|
||||||
|
expect(renderedIndices()).toEqual(expect.arrayContaining([0, 1, 2, 3]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mounts one complete overscan row when the column count exceeds the batch size', async () => {
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>()
|
||||||
|
let frameId = 0
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
|
frameId += 1
|
||||||
|
callbacks.set(frameId, callback)
|
||||||
|
|
||||||
|
return frameId
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||||
|
callbacks.delete(id)
|
||||||
|
})
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(99)
|
||||||
|
|
||||||
|
const { container } = render(ProgressiveCardGrid, {
|
||||||
|
props: {
|
||||||
|
batchSize: 6,
|
||||||
|
columns: 9,
|
||||||
|
estimatedItemHeight: 100,
|
||||||
|
gap: 0,
|
||||||
|
initialCount: 6,
|
||||||
|
items: Array.from({ length: 100 }, (_, id) => ({ id })),
|
||||||
|
getItemKey: (item: { id: number }) => item.id,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: '<div>item</div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(9)
|
||||||
|
const frameCallbacks = [...callbacks.values()]
|
||||||
|
callbacks.clear()
|
||||||
|
frameCallbacks.forEach(callback => callback(performance.now()))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(18)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user