Skip to content

Commit 102ddc2

Browse files
committed
fix(Table): wire virtualizer.measureElement for dynamic row heights
Rebased on top of #6217's spacer-row structure. - Bind virtualizer.measureElement as a template ref plus data-index on the virtualized <tr> so TanStack Virtual observes it and invokes the measureElement option. Without this the user's measureElement option was typed but never called, blocking dynamic heights entirely (#6101). - Stop forcing the virtualRow.size style on the main row so natural content height is measurable; estimateSize becomes a true initial guess only. - Default measureElement adds the immediate next-sibling <tr>'s height when data-expanded="true", so expansion rows are included in the cached size. A user-supplied measureElement still measures the main row; the expansion sum is layered on top. - Forward the per-row :style to the expanded <tr> as well so it stays adjacent to its main row under any future transform. - Watcher on tableApi.getState().expanded diffs before/after and re-measures only rows whose expanded flag toggled. Expand-all sentinel (boolean true) falls back to a full sweep. - JSDoc notes that TanStack's ResizeObserver is attached to the main <tr> only — intra-expansion resizes (late-loading images, nested toggles) need a manual virtualizer.measure() call. - Docs: new 'Dynamic row heights' subsection with a measureElement example and the auto-sum note for expanded rows. Closes #6101.
1 parent 3cf7d75 commit 102ddc2

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

docs/content/docs/2.components/table.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,24 @@ class: '!p-0'
686686
A height constraint is required on the table for virtualization to work properly (e.g., `class="h-[400px]"`).
687687
::
688688

689+
### Dynamic row heights :badge{label="Soon" class="align-text-top"}
690+
691+
`estimateSize` is only the initial guess — the virtualizer measures every rendered row and updates its internal size map as you scroll. For rows with variable content (expandable rows, rich text, images), pass a `measureElement` callback to read the actual rendered height:
692+
693+
```vue
694+
<UTable
695+
:data="rows"
696+
:columns="columns"
697+
:virtualize="{
698+
estimateSize: () => 65,
699+
measureElement: (el) => el.getBoundingClientRect().height
700+
}"
701+
class="h-[500px]"
702+
/>
703+
```
704+
705+
When a row is expanded via `row.toggleExpanded()`, the expansion row's height is automatically added to the measurement — you don't need to walk siblings yourself.
706+
689707
### With tree data
690708

691709
You can use the `get-sub-rows` prop to display hierarchical (tree) data in the table.

src/runtime/components/Table.vue

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
9999
meta?: TableMeta<T>
100100
/**
101101
* Enable virtualization for large datasets.
102+
*
103+
* Pass a `measureElement` function to opt into dynamic row heights. The value returned by
104+
* your `measureElement` is used as the height of the main row; when `row.getIsExpanded()`
105+
* is `true`, the immediate next-sibling `<tr>`'s height is **added on top of** that value
106+
* automatically, so your callback should measure the main row only. If your custom
107+
* `measureElement` already includes the expanded region (e.g. by measuring a wrapper),
108+
* return only the collapsed-row height to avoid double-counting.
109+
*
110+
* TanStack Virtual's `ResizeObserver` is attached to the main `<tr>` only, and this
111+
* component re-measures rows whenever the TanStack expanded state toggles. It does **not**
112+
* observe size changes *inside* the expansion sibling — late-loading images, async content,
113+
* or nested toggles that resize the expansion region after mount won't trigger an automatic
114+
* re-measure. Call `virtualizer.measure()` (or `virtualizer.measureElement(row)`) yourself
115+
* in those cases.
116+
*
102117
* Note: row pinning is not supported when virtualization is enabled.
103118
* @see https://tanstack.com/virtual/latest/docs/api/virtualizer#options
104119
* @defaultValue false
@@ -110,7 +125,8 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
110125
*/
111126
overscan?: number
112127
/**
113-
* Estimated size (in px) of each item, or a function that returns the size for a given index
128+
* Estimated size (in px) of each item, or a function that returns the size for a given index.
129+
* Used as the initial estimate before the virtualizer measures actual row heights.
114130
* @defaultValue 65
115131
*/
116132
estimateSize?: number | ((index: number) => number)
@@ -224,7 +240,7 @@ export type TableSlots<T extends TableData = TableData> = {
224240
</script>
225241

226242
<script setup lang="ts" generic="T extends TableData">
227-
import { computed, useTemplateRef, watch, toRef } from 'vue'
243+
import { computed, nextTick, useTemplateRef, watch, toRef } from 'vue'
228244
import { Primitive, useForwardProps } from 'reka-ui'
229245
import { upperFirst } from 'scule'
230246
import { defu } from 'defu'
@@ -284,7 +300,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {})
284300
}))
285301
286302
const [DefineTableTemplate, ReuseTableTemplate] = createReusableTemplate()
287-
const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: TableRow<T>, style?: Record<string, string> }>({
303+
const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: TableRow<T>, style?: Record<string, string>, index?: number }>({
288304
props: {
289305
row: {
290306
type: Object,
@@ -293,6 +309,11 @@ const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: Tabl
293309
style: {
294310
type: Object,
295311
required: false
312+
},
313+
index: {
314+
type: Number,
315+
required: false,
316+
default: undefined
296317
}
297318
}
298319
})
@@ -432,6 +453,17 @@ const virtualizer = !!props.virtualize && useVirtualizer({
432453
estimateSize: (index: number) => {
433454
const estimate = virtualizerProps.value.estimateSize
434455
return typeof estimate === 'function' ? estimate(index) : estimate
456+
},
457+
measureElement: (el, entry, instance) => {
458+
const userMeasure = virtualizerProps.value.measureElement
459+
const base = userMeasure
460+
? userMeasure(el, entry, instance)
461+
: Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? (el as HTMLElement).offsetHeight)
462+
if ((el as Element).getAttribute('data-expanded') === 'true') {
463+
const next = (el as Element).nextElementSibling as HTMLElement | null
464+
if (next && next.tagName === 'TR') return base + next.offsetHeight
465+
}
466+
return base
435467
}
436468
})
437469
@@ -444,6 +476,55 @@ const virtualPaddingBottom = computed(() => {
444476
return virtualizer.value.getTotalSize() - (virtualItems.value[virtualItems.value.length - 1]?.end ?? 0)
445477
})
446478
479+
function measureRowRef(el: Element | ComponentPublicInstance | null) {
480+
if (!virtualizer || !el) return
481+
virtualizer.value.measureElement(el as Element)
482+
}
483+
484+
// Re-measure rows whose expanded state toggled. TanStack's ResizeObserver is
485+
// attached to the main `<tr>`, but expansion inserts a *sibling* `<tr>` that
486+
// doesn't resize the main row, so the RO never fires — this manual re-measure
487+
// keeps the virtualizer's cached heights in sync. Diff the before/after state
488+
// and only re-measure rows whose expanded flag actually changed. When the
489+
// expanded state is the sentinel `true` (expand-all) we fall back to a full
490+
// sweep since there are no individual row ids to diff against.
491+
if (virtualizer) {
492+
watch(
493+
() => tableApi.getState().expanded,
494+
(next, prev) => {
495+
nextTick(() => {
496+
const root = rootRef.value?.$el as HTMLElement | undefined
497+
if (!root) return
498+
499+
const measureAll = () => {
500+
root.querySelectorAll<HTMLElement>('[data-index]').forEach((row) => {
501+
virtualizer.value.measureElement(row)
502+
})
503+
}
504+
505+
if (typeof next === 'boolean' || typeof prev === 'boolean') {
506+
measureAll()
507+
return
508+
}
509+
510+
const prevMap = (prev ?? {}) as Record<string, boolean>
511+
const nextMap = (next ?? {}) as Record<string, boolean>
512+
const changedIds = new Set<string>()
513+
for (const id in nextMap) if (!!nextMap[id] !== !!prevMap[id]) changedIds.add(id)
514+
for (const id in prevMap) if (!!nextMap[id] !== !!prevMap[id]) changedIds.add(id)
515+
516+
changedIds.forEach((id) => {
517+
const index = tableApi.getRow(id)?.index
518+
if (index === undefined) return
519+
const el = root.querySelector<HTMLElement>(`[data-index="${index}"]`)
520+
if (el) virtualizer.value.measureElement(el)
521+
})
522+
})
523+
},
524+
{ deep: true }
525+
)
526+
}
527+
447528
function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
448529
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
449530
}
@@ -519,8 +600,10 @@ defineExpose({
519600
</script>
520601

521602
<template>
522-
<DefineRowTemplate v-slot="{ row, style }">
603+
<DefineRowTemplate v-slot="{ row, style, index }">
523604
<tr
605+
:ref="index !== undefined ? measureRowRef : undefined"
606+
:data-index="index"
524607
:data-selected="row.getIsSelected()"
525608
:data-selectable="!!props.onSelect || !!props.onHover || !!props.onContextmenu"
526609
:data-expanded="row.getIsExpanded()"
@@ -565,7 +648,7 @@ defineExpose({
565648
</td>
566649
</tr>
567650

568-
<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [uiProp?.tr] })">
651+
<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [uiProp?.tr] })" :style="style">
569652
<td :colspan="row.getAllCells().length" data-slot="td" :class="ui.td({ class: [uiProp?.td] })">
570653
<slot name="expanded" :row="row" />
571654
</td>
@@ -625,7 +708,7 @@ defineExpose({
625708
<ReuseRowTemplate
626709
v-if="centerRows[virtualRow.index]"
627710
:row="centerRows[virtualRow.index]!"
628-
:style="{ height: `${virtualRow.size}px` }"
711+
:index="virtualRow.index"
629712
/>
630713
</template>
631714
<tr v-if="virtualPaddingBottom > 0" :style="{ height: `${virtualPaddingBottom}px` }" aria-hidden="true">

0 commit comments

Comments
 (0)