skip to Main Content

I’m using Vue 3 with a Vuetify table and whenever I modify data I have to fetch everything again. If you modify a cell inside row 123 and column 456 reconstructing the whole grid is annoying because the table scrollbars jump back to the start. I think a good solution for this would be to

  • store the current scroll position
  • perform the write action
  • reassign the stored scroll position

( any better suggestions are highly appreciated )

As a sidenote: Since Vuetify requires a fixed table height for fixed table headers I’m, calculating the height dynamically ( table should fill the rest of the page ).

I created the following example ( Playground link )

<script setup lang="ts">
import { ref, nextTick, onMounted, watch } from "vue";  

const mainContainerComponent = ref<VMain>();
const tableComponent = ref<VTable>();
const tableHeight: Ref<number | undefined> = ref(undefined);
const tableMatrix = ref([[]]);

onMounted(async () => {
  // we only want to scroll inside the table
  document.documentElement.classList.add("overflow-y-hidden");

  await loadData();
});

watch(tableMatrix, async () => {
  // Unset table height and wait for it to rerender
  tableHeight.value = undefined;

  await nextTick();

  if (!tableComponent.value) {
    return;
  }

  const mainContainerComponentRectangle = mainContainerComponent.value.$el.getBoundingClientRect();
  const tableRectangle = tableComponent.value.$el.getBoundingClientRect();
  const topOffset = tableRectangle.top;
  const bottomOffset = mainContainerComponentRectangle.bottom - tableRectangle.bottom;

  tableHeight.value = window.innerHeight - bottomOffset - topOffset;
});

async function loadData() {
  // destroy table
  tableMatrix.value = [];

  await nextTick();
  
  // fetch data
  const fetchedData = new Array(Math.floor(Math.random() * 300) + 50).fill("data");

  // calculate table matrix
  tableMatrix.value = [...fetchedData.map(x => [x])];
}

async function performWriteAction() {
  // send modify request here

  const { scrollLeft, scrollTop } = getTableScrollPosition();

  await loadData();

  // wait for the DOM to finish
  await nextTick();

  // try to restore the previous scroll position
  setTableScrollPosition(scrollLeft, scrollTop);
}

function getTableDOMElement() {
  return tableComponent.value?.$el.querySelector(".v-table__wrapper");
}

function getTableScrollPosition() {
  const { scrollLeft, scrollTop } = getTableDOMElement();
  
  console.log(`current scroll position => x: ${scrollLeft} | y: ${scrollTop}`);

  return { scrollLeft, scrollTop };
}

function setTableScrollPosition(scrollLeft: number, scrollTop: number) {
  const tableElement = getTableDOMElement();
  
  console.log(`scroll to => x: ${scrollLeft} | y: ${scrollTop}`);

  tableElement.scrollLeft = scrollLeft;
  tableElement.scrollTop = scrollTop;
}
</script>

<template>
  <v-app>
    <v-main ref="mainContainerComponent">
      <v-container>
        <v-btn @click="performWriteAction">Modify data</v-btn>
      </v-container>
      
      <v-table
        ref="tableComponent"
        density="compact"
        fixed-header
        :height="tableHeight"
      >
        <thead>
          <tr>
            <th>Col</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(row, rowIndex) in tableMatrix" :key="rowIndex">
            <template v-for="(cell, columnIndex) in row" :key="columnIndex">
              <td>{{ rowIndex }}</td>
            </template>
          </tr>
        </tbody>
      </v-table>
    </v-main>
  </v-app>
</template>

The problem with this code is that the table always jumps back to the start. If you scroll down to the center of the table and modify some data the scroll position is still wrong.

Do you have any ideas what’s wrong or missing?

2

Answers


  1. Seems like it takes another rendering cycle, not sure why. Adding another nextTick() fixes the scroll:

      await nextTick();
      await nextTick();
      setTableScrollPosition(scrollLeft, scrollTop);
    

    Here is the updated playground


    I think I found why it happens, first, let’s look at the order of events (this is without a second nextTick), when inserting a number of log statements in the playground, I get:

    function action
    performWriteAction awaiting loadData()
    loadData clearing tableMatrix (triggers first watch), waiting
    watch 1 unset tableHeight, waiting
    render table is empty, no height
    loadData continue, filling tableMatrix (triggers second watch)
    watch 1 continuing, updating tableHeight (for empty table)
    watch 2 unset tableHeight, waiting
    render table has data, no height
    performWriteAction waiting for update
    watch 2 continuing, updating tableHeight (for filled table)
    performWriteAction continue, setting scroll !!! starts before render !!!
    render table has data, height is set
    setTableScrollPosition scroll back

    The important lines are the ones after the penultimate render:

    function action
    render table has data, no height
    performWriteAction waiting for update
    watch 2 continuing, updating tableHeight (for filled table)
    performWriteAction continue, setting scroll !!! starts before render !!!

    There you can see that performWriteAction() calls await nextTick() immediately after a render, when there are no pending state updates.

    Looking at the implementation of nextTick(), we can see that if there are no pending updates (in the currentFlushPromise), a stored Promise.resolve() is used:

    const resolvedPromise = Promise.resolve() as Promise<any>
    let currentFlushPromise: Promise<void> | null = null
    
    export function nextTick<T = void>(
      this: T,
      fn?: (this: T) => void
    ): Promise<void> {
      const p = currentFlushPromise || resolvedPromise
      return fn ? p.then(this ? fn.bind(this) : fn) : p
    }
    

    So basically, since there is nothing to wait for, nextTick() resolves immediately. Only the await forces to wait until the next computation cycle, allowing the watcher is run in between. But any changes made by the watcher do not affect the preceding nextTick(). You might as well write await Promise.resolve() directly.


    Interestingly, it works when you pass a callback to nextTick():

    async function performWriteAction() {
      const { scrollLeft, scrollTop } = getTableScrollPosition();
      await loadData();
      await nextTick(() => console.log('plz wait'));
      setTableScrollPosition(scrollLeft, scrollTop);
    }
    

    Note that the callback still runs before the render, but looking at the implementation above, it adds another .then(), which again forces to wait for another computation cycle, which is enough to get the render update in between (it is the same effect as the two await statements in my initial answer).


    With all this, I am going to say that there is no real bug in your code, except that nextTick() behaves differently than expected (and maybe that it is not clear anymore what waits for what).

    The clean solution would be to only run nextTick() when there are actually pending changes. You could just reset the tableHeight before waiting:

      await loadData();
      tableHeight.value = null
      await nextTick();
      setTableScrollPosition(scrollLeft, scrollTop);
    }
    

    Or, in general, it looks like you are using nextTick a lot because you want to do CSS changes with JS. If you instead find a way to set table height through CSS (which should be possible), you would get rid of all the problems and a lot of fragile code, where parallel tasks have to wait for each other.

    The most feasible solution though is probably to eliminate the watcher. You know when tableHeight has to be updated, and that is when the tableMatrix data changes. So you could just set it along the call to the load method:

    async function performWriteAction() {
      const { scrollLeft, scrollTop } = getTableScrollPosition();
      tableHeight.value = null;
      await loadData();
      updateTableHeight();
      setTableScrollPosition(scrollLeft, scrollTop);
    }
    

    Since it is particularly the clearing and then setting of tableMatrix and tableHeight, this would eliminate the issue and make your code much more clear and maintainable.

    But that approach would not have led to your interesting question, which I hope this answers conclusively.

    Login or Signup to reply.
  2. You can opt out of the rigid table height set by adding a few lines of css. This way, you will not only get rid of the scroll jump problem, but also clean up your code.

    Vuetify Play


    CSS code:

    main {
      height: 100vh;
      display: flex;
      flex-direction: column;
    }
    .v-container {
      flex: none;
    }
    .v-table {
      flex: auto;
      display: flex;
      flex-direction: column;
      min-height: 0;
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search