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
Seems like it takes another rendering cycle, not sure why. Adding another
nextTick()
fixes the scroll: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:loadData()
tableMatrix
(triggers first watch), waitingtableHeight
, waitingtableMatrix
(triggers second watch)tableHeight
(for empty table)tableHeight
, waitingThe important lines are the ones after the penultimate render:
There you can see that
performWriteAction()
callsawait 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 thecurrentFlushPromise
), a storedPromise.resolve()
is used:So basically, since there is nothing to wait for,
nextTick()
resolves immediately. Only theawait
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 precedingnextTick()
. You might as well writeawait Promise.resolve()
directly.Interestingly, it works when you pass a callback to
nextTick()
: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 twoawait
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 thetableHeight
before waiting: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 thetableMatrix
data changes. So you could just set it along the call to the load method:Since it is particularly the clearing and then setting of
tableMatrix
andtableHeight
, 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.
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: