Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(VDataTableVirtual): dont render all rows when items are updated #18837

Closed
wants to merge 2 commits into from

Conversation

shengzhou1216
Copy link

resolves #18806

Set default virtual item's itemHeight from 0 to 16.

Description

packages/vuetify/src/composables/virtual.tsvirtual.ts

// Composables
import { useDisplay } from '@/composables/display'
import { useResizeObserver } from '@/composables/resizeObserver'

// Utilities
import { computed, nextTick, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue'
import { clamp, debounce, IN_BROWSER, propsFactory } from '@/util'

// Types
import type { Ref } from 'vue'

const UP = -1
const DOWN = 1

/** Determines how large each batch of items should be */
const BUFFER_PX = 100

type VirtualProps = {
  itemHeight?: number | string
  height?: number | string
}

export const makeVirtualProps = propsFactory({
  itemHeight: {
    type: [Number, String],
    default: null,
  },
  height: [Number, String],
}, 'virtual')

export function useVirtual <T> (props: VirtualProps, items: Ref<readonly T[]>) {
  const display = useDisplay()

  const itemHeight = shallowRef(0)
  watchEffect(() => {
    itemHeight.value = parseFloat(props.itemHeight || 16)
  })

  const first = shallowRef(0)
  const last = shallowRef(Math.ceil(
    // Assume 16px items filling the entire screen height if
    // not provided. This is probably incorrect but it minimises
    // the chance of ending up with empty space at the bottom.
    // The default value is set here to avoid poisoning getSize()
    (parseInt(props.height!) || display.height.value) / (itemHeight.value || 16)
  ) || 1)
  const paddingTop = shallowRef(0)
  const paddingBottom = shallowRef(0)

  /** The scrollable element */
  const containerRef = ref<HTMLElement>()
  /** An element marking the top of the scrollable area,
   * used to add an offset if there's padding or other elements above the virtual list */
  const markerRef = ref<HTMLElement>()
  /** markerRef's offsetTop, lazily evaluated */
  let markerOffset = 0

  const { resizeRef, contentRect } = useResizeObserver()
  watchEffect(() => {
    resizeRef.value = containerRef.value
  })
  const viewportHeight = computed(() => {
    return containerRef.value === document.documentElement
      ? display.height.value
      : contentRect.value?.height || parseInt(props.height!) || 0
  })
  /** All static elements have been rendered and we have an assumed item height */
  const hasInitialRender = computed(() => {
    return !!(containerRef.value && markerRef.value && viewportHeight.value && itemHeight.value)
  })

  let sizes = Array.from<number | null>({ length: items.value.length })
  let offsets = Array.from<number>({ length: items.value.length })
  const updateTime = shallowRef(0)
  let targetScrollIndex = -1

  function getSize (index: number) {
    return sizes[index] || itemHeight.value
  }

  const updateOffsets = debounce(() => {
    const start = performance.now()
    offsets[0] = 0
    const length = items.value.length
    for (let i = 1; i <= length - 1; i++) {
      offsets[i] = (offsets[i - 1] || 0) + getSize(i - 1)
    }
    updateTime.value = Math.max(updateTime.value, performance.now() - start)
  }, updateTime)

  const unwatch = watch(hasInitialRender, v => {
    if (!v) return
    // First render is complete, update offsets and visible
    // items in case our assumed item height was incorrect

    unwatch()
    markerOffset = markerRef.value!.offsetTop
    updateOffsets.immediate()
    calculateVisibleItems()

    if (!~targetScrollIndex) return

    nextTick(() => {
      IN_BROWSER && window.requestAnimationFrame(() => {
        scrollToIndex(targetScrollIndex)
        targetScrollIndex = -1
      })
    })
  })
  watch(viewportHeight, (val, oldVal) => {
    oldVal && calculateVisibleItems()
  })

  onScopeDispose(() => {
    updateOffsets.clear()
  })

  function handleItemResize (index: number, height: number) {
    const prevHeight = sizes[index]
    const prevMinHeight = itemHeight.value

    itemHeight.value = prevMinHeight ? Math.min(itemHeight.value, height) : height

    if (prevHeight !== height || prevMinHeight !== itemHeight.value) {
      sizes[index] = height
      updateOffsets()
    }
  }

  function calculateOffset (index: number) {
    index = clamp(index, 0, items.value.length - 1)
    return offsets[index] || 0
  }

  function calculateIndex (scrollTop: number) {
    return binaryClosest(offsets, scrollTop)
  }

  let lastScrollTop = 0
  let scrollVelocity = 0
  let lastScrollTime = 0
  function handleScroll () {
    if (!containerRef.value || !markerRef.value) return

    const scrollTop = containerRef.value.scrollTop
    const scrollTime = performance.now()
    const scrollDeltaT = scrollTime - lastScrollTime

    if (scrollDeltaT > 500) {
      scrollVelocity = Math.sign(scrollTop - lastScrollTop)

      // Not super important, only update at the
      // start of a scroll sequence to avoid reflows
      markerOffset = markerRef.value.offsetTop
    } else {
      scrollVelocity = scrollTop - lastScrollTop
    }

    lastScrollTop = scrollTop
    lastScrollTime = scrollTime

    calculateVisibleItems()
  }
  function handleScrollend () {
    if (!containerRef.value || !markerRef.value) return

    scrollVelocity = 0
    lastScrollTime = 0

    calculateVisibleItems()
  }

  let raf = -1
  function calculateVisibleItems () {
    cancelAnimationFrame(raf)
    raf = requestAnimationFrame(_calculateVisibleItems)
  }
  function _calculateVisibleItems () {
    if (!containerRef.value || !viewportHeight.value) return
    const scrollTop = lastScrollTop - markerOffset
    const direction = Math.sign(scrollVelocity)

    const startPx = Math.max(0, scrollTop - BUFFER_PX)
    const start = clamp(calculateIndex(startPx), 0, items.value.length)

    const endPx = scrollTop + viewportHeight.value + BUFFER_PX
    const end = clamp(calculateIndex(endPx) + 1, start + 1, items.value.length)

    if (
      // Only update the side we're scrolling towards,
      // the other side will be updated incidentally
      (direction !== UP || start < first.value) &&
      (direction !== DOWN || end > last.value)
    ) {
      const topOverflow = calculateOffset(first.value) - calculateOffset(start)
      const bottomOverflow = calculateOffset(end) - calculateOffset(last.value)
      const bufferOverflow = Math.max(topOverflow, bottomOverflow)

      if (bufferOverflow > BUFFER_PX) {
        first.value = start
        last.value = end
      } else {
        // Only update the side that's reached its limit if there's still buffer left
        if (start <= 0) first.value = start
        if (end >= items.value.length) last.value = end
      }
    }

    paddingTop.value = calculateOffset(first.value)
    paddingBottom.value = calculateOffset(items.value.length) - calculateOffset(last.value)
  }

  function scrollToIndex (index: number) {
    const offset = calculateOffset(index)
    if (!containerRef.value || (index && !offset)) {
      targetScrollIndex = index
    } else {
      containerRef.value.scrollTop = offset
    }
  }

  const computedItems = computed(() => {
    return items.value.slice(first.value, last.value).map((item, index) => ({
      raw: item,
      index: index + first.value,
    }))
  })

  watch(items, () => {
    sizes = Array.from({ length: items.value.length })
    offsets = Array.from({ length: items.value.length })
    updateOffsets.immediate()
    calculateVisibleItems()
  }, { deep: true })

  return {
    containerRef,
    markerRef,
    computedItems,
    paddingTop,
    paddingBottom,
    scrollToIndex,
    handleScroll,
    handleScrollend,
    handleItemResize,
  }
}

// https://gist.github.com/robertleeplummerjr/1cc657191d34ecd0a324
function binaryClosest (arr: ArrayLike<number>, val: number) {
  let high = arr.length - 1
  let low = 0
  let mid = 0
  let item = null
  let target = -1

  if (arr[high]! < val) {
    return high
  }

  while (low <= high) {
    mid = (low + high) >> 1
    item = arr[mid]!

    if (item > val) {
      high = mid - 1
    } else if (item < val) {
      target = mid
      low = mid + 1
    } else if (item === val) {
      return mid
    } else {
      return low
    }
  }

  return target
}

When items is updated from an empty list to a non-empty list (e.g. items.length = 1000).

  watch(items, () => {
    sizes = Array.from({ length: items.value.length })
    offsets = Array.from({ length: items.value.length })
    updateOffsets.immediate()
    calculateVisibleItems()
  }, { deep: true })

sizes and offsets will be initialized with zeros array.

  function calculateIndex (scrollTop: number) {
    return binaryClosest(offsets, scrollTop)
  }

calculateIndex (0) will got 499 not 0. Then, _calculateVisibleItems will set first to 0, and last to 999.

Then, computedItems will return all items, which are actually rendered rows.

Perhaps changing the BS algorithm of binaryClosest to lower bound BS might also work,but i have not tried it.

Markup:

<template>
  <div>
    <v-data-table-virtual
      ref="elTable"
      :headers="headers"
      :items="tableDataItems"
      :height="tableHeight"
      item-value="name"
      multi-sort
      fixed-header
      fixed-footer
      hide-default-footer
      show-expand
    />

    <div class="mt-8 pa-4">
      RENDERED ROWS: <strong>{{ renderedRows }}</strong>
    </div>
  </div>
</template>

<script setup>
  import { computed, ref, shallowRef } from 'vue'

  const tableHeight = ref('calc(100vh - 300px)')

  const tableDataItems = ref([])
  const elTable = ref()
  const renderedRows = ref(0)

  const headers = [
    { title: 'Boat Type', align: 'start', key: 'name' },
    { title: 'Speed (knots)', align: 'end', key: 'speed' },
    { title: 'Length (m)', align: 'end', key: 'length' },
    { title: 'Price ($)', align: 'end', key: 'price' },
    { title: 'Year', align: 'end', key: 'year' },
  ]

  const boats = [
    {
      name: 'Speedster',
      speed: 35,
      length: 22,
      price: 300000,
      year: 2021,
    },
    {
      name: 'OceanMaster',
      speed: 25,
      length: 35,
      price: 500000,
      year: 2020,
    },
    {
      name: 'Voyager',
      speed: 20,
      length: 45,
      price: 700000,
      year: 2019,
    },
    {
      name: 'WaveRunner',
      speed: 40,
      length: 19,
      price: 250000,
      year: 2022,
    },
    {
      name: 'SeaBreeze',
      speed: 28,
      length: 31,
      price: 450000,
      year: 2018,
    },
    {
      name: 'HarborGuard',
      speed: 18,
      length: 50,
      price: 800000,
      year: 2017,
    },
    {
      name: 'SlickFin',
      speed: 33,
      length: 24,
      price: 350000,
      year: 2021,
    },
    {
      name: 'StormBreaker',
      speed: 22,
      length: 38,
      price: 600000,
      year: 2020,
    },
    {
      name: 'WindSail',
      speed: 15,
      length: 55,
      price: 900000,
      year: 2019,
    },
    {
      name: 'FastTide',
      speed: 37,
      length: 20,
      price: 280000,
      year: 2022,
    },
  ]

  const virtualBoats = computed(() => {
    return [...Array(999).keys()].map(i => {
      const boat = { ...boats[i % 10] }
      boat.name = `${boat.name} #${i}`
      return boat
    })
  })


  setTimeout(() => {
    tableDataItems.value = virtualBoats.value
    setTimeout(() => {
      renderedRows.value =
        elTable.value?.$el.querySelectorAll('tbody tr').length ?? 0
      console.info(
        'rows: ',
        elTable.value,
        elTable.value?.$el.querySelectorAll('tbody tr')
      )
    }, 1000)
  }, 5000)
</script>

@MajesticPotatoe MajesticPotatoe changed the title fix: VDataTableVirtual renders all rows when items are updated fix(VDataTableVirtual): dont render all rows when items are updated Dec 11, 2023
@MajesticPotatoe MajesticPotatoe added T: bug Functionality that does not work as intended/expected C: VDataTableVirtual labels Dec 11, 2023
@@ -33,7 +33,7 @@ export function useVirtual <T> (props: VirtualProps, items: Ref<readonly T[]>) {

const itemHeight = shallowRef(0)
watchEffect(() => {
itemHeight.value = parseFloat(props.itemHeight || 0)
itemHeight.value = parseFloat(props.itemHeight || 16)
Copy link
Member

Choose a reason for hiding this comment

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

I don't have a better solution but magic numbers are bad.

Copy link
Member

@KaelWD KaelWD Dec 19, 2023

Choose a reason for hiding this comment

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

// The default value is set here to avoid poisoning getSize()

Setting it here too could cause problems with items that are actually <16px

@johnleider johnleider added this to the v3.4.x milestone Dec 18, 2023
@johnleider johnleider removed this from the v3.4.x milestone Jan 22, 2024
@KaelWD KaelWD force-pushed the master branch 3 times, most recently from cd170f8 to 98e57dc Compare February 14, 2024 06:14
@johnleider johnleider added the S: stale This issue is untriaged and hasn't seen any activity in at least six months. label Feb 21, 2024
@johnleider
Copy link
Member

This Pull Request is being closed due to inactivity.

If you have any additional questions, please reach out to us in our Discord community.

@johnleider johnleider closed this Mar 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C: VDataTableVirtual S: stale This issue is untriaged and hasn't seen any activity in at least six months. T: bug Functionality that does not work as intended/expected
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug Report][3.4.4] VDataTableVirtual renders all rows when items are updated
4 participants