-
-
Notifications
You must be signed in to change notification settings - Fork 425
docs(vue-virtual): use onUpdated to prevent scroll jumping
#1125
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,7 +15,7 @@ | |||||||||||||||||||
| v-for="virtualColumn in virtualColumns" | ||||||||||||||||||||
| :key="virtualColumn.key" | ||||||||||||||||||||
| :data-index="virtualColumn.index" | ||||||||||||||||||||
| :ref="measureElement" | ||||||||||||||||||||
| ref="virtualItemEls" | ||||||||||||||||||||
| :class="virtualColumn.index % 2 ? 'ListItemOdd' : 'ListItemEven'" | ||||||||||||||||||||
| :style="{ | ||||||||||||||||||||
| position: 'absolute', | ||||||||||||||||||||
|
|
@@ -35,7 +35,7 @@ | |||||||||||||||||||
| </template> | ||||||||||||||||||||
|
|
||||||||||||||||||||
| <script setup lang="ts"> | ||||||||||||||||||||
| import { ref, computed, type VNodeRef } from 'vue' | ||||||||||||||||||||
| import { computed, onMounted, onUpdated, ref, shallowRef } from 'vue' | ||||||||||||||||||||
| import { useVirtualizer } from '@tanstack/vue-virtual' | ||||||||||||||||||||
| import { generateSentences } from './utils' | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -54,13 +54,14 @@ const virtualColumns = computed(() => columnVirtualizer.value.getVirtualItems()) | |||||||||||||||||||
|
|
||||||||||||||||||||
| const totalSize = computed(() => columnVirtualizer.value.getTotalSize()) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const measureElement = (el) => { | ||||||||||||||||||||
| if (!el) { | ||||||||||||||||||||
| return | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const virtualItemEls = shallowRef([]) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| columnVirtualizer.value.measureElement(el) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return undefined | ||||||||||||||||||||
| function measureAll() { | ||||||||||||||||||||
| virtualItemEls.value.forEach((el) => { | ||||||||||||||||||||
| if (el) columnVirtualizer.value.measureElement(el) | ||||||||||||||||||||
| }) | ||||||||||||||||||||
|
Comment on lines
+59
to
+62
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calling measureElement(null) first ensures stale elements from previous renders are cleaned up before re-registering the current ones. Without it, if virtual items get recycled (scrolling removes old DOM nodes), those old nodes may linger in the cache until the ResizeObserver's own callback eventually notices they're disconnected. Adding the null call makes cleanup immediate and deterministic.
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+59
to
63
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| onMounted(measureAll) | ||||||||||||||||||||
| onUpdated(measureAll) | ||||||||||||||||||||
| </script> | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |||||
| <template v-for="virtualRow in virtualRows" :key="virtualRow.key"> | ||||||
| <div | ||||||
| :data-index="virtualRow.index" | ||||||
| :ref="measureElement" | ||||||
| ref="virtualItemEls" | ||||||
| :style="{ | ||||||
| position: 'absolute', | ||||||
| top: 0, | ||||||
|
|
@@ -47,9 +47,9 @@ | |||||
| </template> | ||||||
|
|
||||||
| <script setup lang="ts"> | ||||||
| import { ref, computed, onMounted, type VNodeRef } from 'vue' | ||||||
| import { useWindowVirtualizer, useVirtualizer } from '@tanstack/vue-virtual' | ||||||
| import { generateData, generateColumns } from './utils' | ||||||
| import { computed, onMounted, onUpdated, ref, shallowRef } from 'vue' | ||||||
| import { useVirtualizer, useWindowVirtualizer } from '@tanstack/vue-virtual' | ||||||
| import { generateColumns, generateData } from './utils' | ||||||
|
|
||||||
| const columns = generateColumns(30) | ||||||
| const data = generateData(columns) | ||||||
|
|
@@ -103,13 +103,14 @@ const width = computed(() => { | |||||
| : [0, 0] | ||||||
| }) | ||||||
|
|
||||||
| const measureElement = (el) => { | ||||||
| if (!el) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| rowVirtualizer.value.measureElement(el) | ||||||
| const virtualItemEls = shallowRef([]) | ||||||
|
||||||
| const virtualItemEls = shallowRef([]) | |
| const virtualItemEls = shallowRef<(HTMLElement | null)[]>([]) |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
measureAll never calls rowVirtualizer.value.measureElement(null). In @tanstack/virtual-core, passing null is what triggers cleanup of disconnected nodes (unobserving/removing stale entries from elementsCache). Without that, rows that scroll out/unmount can remain observed/retained. Consider calling measureElement(null) once per update (or using a callback ref that forwards null on unmount) in addition to measuring the current elements.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -35,7 +35,7 @@ | |||||
| v-for="virtualRow in virtualRows" | ||||||
| :key="virtualRow.key" | ||||||
| :data-index="virtualRow.index" | ||||||
| :ref="measureElement" | ||||||
| ref="virtualItemEls" | ||||||
| :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'" | ||||||
| > | ||||||
| <div style="padding: 10px 0"> | ||||||
|
|
@@ -50,7 +50,7 @@ | |||||
| </template> | ||||||
|
|
||||||
| <script setup lang="ts"> | ||||||
| import { ref, computed } from 'vue' | ||||||
| import { computed, onMounted, onUpdated, ref, shallowRef } from 'vue' | ||||||
| import { useVirtualizer } from '@tanstack/vue-virtual' | ||||||
| import { generateSentences } from './utils' | ||||||
|
|
||||||
|
|
@@ -68,13 +68,14 @@ const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems()) | |||||
|
|
||||||
| const totalSize = computed(() => rowVirtualizer.value.getTotalSize()) | ||||||
|
|
||||||
| const measureElement = (el) => { | ||||||
| if (!el) { | ||||||
| return | ||||||
| } | ||||||
| const virtualItemEls = shallowRef([]) | ||||||
|
||||||
| const virtualItemEls = shallowRef([]) | |
| const virtualItemEls = shallowRef<(HTMLElement | null)[]>([]) |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
measureAll never calls rowVirtualizer.value.measureElement(null). In @tanstack/virtual-core, passing null is what triggers cleanup of disconnected nodes (unobserving/removing stale entries from elementsCache). Without that, items that scroll out/unmount can remain observed/retained. Consider calling measureElement(null) once per update (or using a callback ref that forwards null on unmount) in addition to measuring the current elements.
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onUpdated(measureAll) runs after every component update, including scroll-driven updates as virtualRows changes. Measuring all rendered items on every update can be expensive (forces layout reads) and may reintroduce scroll jank. Consider a more targeted trigger (e.g. defer measurement from an element ref callback post-render, or only measure newly mounted items) to avoid repeated full measurement passes.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -22,7 +22,7 @@ | |||||
| v-for="virtualRow in virtualRows" | ||||||
| :key="virtualRow.key" | ||||||
| :data-index="virtualRow.index" | ||||||
| :ref="measureElement" | ||||||
| ref="virtualItemEls" | ||||||
| :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'" | ||||||
| > | ||||||
| <div style="padding: 10px 0"> | ||||||
|
|
@@ -36,7 +36,7 @@ | |||||
| </template> | ||||||
|
|
||||||
| <script setup lang="ts"> | ||||||
| import { ref, computed, onMounted } from 'vue' | ||||||
| import { computed, onMounted, onUpdated, ref, shallowRef } from 'vue' | ||||||
| import { useWindowVirtualizer } from '@tanstack/vue-virtual' | ||||||
| import { generateSentences } from './utils' | ||||||
|
|
||||||
|
|
@@ -64,13 +64,14 @@ const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems()) | |||||
|
|
||||||
| const totalSize = computed(() => rowVirtualizer.value.getTotalSize()) | ||||||
|
|
||||||
| const measureElement = (el) => { | ||||||
| if (!el) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| rowVirtualizer.value.measureElement(el) | ||||||
| const virtualItemEls = shallowRef([]) | ||||||
|
||||||
| const virtualItemEls = shallowRef([]) | |
| const virtualItemEls = shallowRef<(HTMLElement | null)[]>([]) |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
measureAll never calls rowVirtualizer.value.measureElement(null). In @tanstack/virtual-core, passing null is what triggers cleanup of disconnected nodes (unobserving/removing stale entries from elementsCache). Without that, items that scroll out/unmount can remain observed/retained. Consider calling measureElement(null) once per update (or using a callback ref that forwards null on unmount) in addition to measuring the current elements.
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onUpdated(measureAll) runs after every component update, including scroll-driven updates as virtualRows changes. Measuring all rendered items on every update can be expensive (forces layout reads) and may reintroduce scroll jank. Consider a more targeted trigger (e.g. defer measurement from an element ref callback post-render, or only measure newly mounted items) to avoid repeated full measurement passes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shallowRef([])with an empty array literal infersnever[]in TypeScript, which can makeelinforEachbeneverand cause type errors when passing it tomeasureElement. Consider explicitly typingvirtualItemEls(e.g. as an array ofHTMLElement | null).