16 KiB
Rebuild list-table-v2 with TanStack + pretext
Goal
Replace el-table-v2 dependency with a custom implementation using:
- @chenglou/pretext for text measurement (column widths, row heights for plain text)
- Custom prefix-sum virtualizer (pretext-table style, lightweight)
- TanStack Table for headless table logic (optional, depends on complexity)
Keep the existing prop interface of list-table-v2.vue so consumers don't need changes.
What I already know
Existing Component (list-table-v2.vue)
- Vue 3 + TypeScript component
- Props:
data,columns(with key, dataKey, name, i18n, type, width, minWidth, fixed, align, slot, dict, timestamp, filesize, cellRenderer, headerCellRenderer),page,height,minHeight,maxHeight,example,rowKey,selectKey,treeProps,lazy,border,timestampFormat,rowHeight,estimatedRowHeight,headerHeight,debug - Emits:
query,selection-change,row-click,cell-click - Uses mini hidden table for row height probing
- Uses
el-auto-resizer+el-table-v2 - Features: pagination, fixed columns, sorting via slot, dict/timestamp/filesize formatting
pretext-table (React) Implementation Insights
- @chenglou/pretext two-phase API:
prepare(text, font)→ cached handle (Canvas measureText for segmentation)layout(prepared, maxWidth, lineHeight)→{ height, lineCount }(pure arithmetic, no DOM)
- Column width: Binary search with pretext to find minimum single-line width. Samples rows, measures header + cell text widths, respects minWidth/maxWidth
- Row height:
prepare()per cell text,layout()at column width, takes max cell height + padding - Virtual scrolling: Custom binary search over prefix-sum offsets (NOT TanStack Virtual)
- Does NOT use TanStack Table or TanStack Virtual
Available Packages
@tanstack/vue-tablev8.21.3 - headless Vue 3 table@tanstack/vue-virtualv3.13.23 - headless Vue 3 virtualization@chenglou/pretextv0.0.3 - text measurement
Virtualization Approach: Custom Prefix-Sum (pretext-table Style)
Chosen over TanStack Virtual because:
- Lightweight — binary search + prefix-sum offsets, no library dependency
- Works naturally with pretext — all row heights pre-computed via
layout(), just math - Full control over how pretext integrates
Algorithm
- Pretext computes ALL row heights upfront (pure arithmetic, no DOM)
- Build prefix-sum offset array:
offsets[i] = sum(heights[0..i-1]) - Binary search on scroll to find visible range: O(log n)
- Render only visible rows + overscan
cellRenderer Return Types
Plain VNode (backward compatible):
cellRenderer: ({ data }) => <span>{data}</span>
Size-hinted VNode (new):
cellRenderer: ({ data }) => ({
vnode: <el-tag>{data}</el-tag>,
minHeight: 30, // pixel hint for row height estimation
minWidth: 100 // pixel hint for column width estimation (no runtime augmentation)
})
Runtime Augmentation for Row Heights
For columns with custom cellRenderer (where pretext can't measure):
- Initial: Use
minHeightfrom size-hinted return, or sensible default (44px) - After mount: Measure actual DOM height per row using ResizeObserver
- Running average per column: Maintain
Map<columnKey, { samples: number[], avg: number }> - Self-adjust: When average shifts >10%, recompute offsets and re-render
Column Width Strategy (Pre-computed, No Runtime Augmentation)
Flex parameters computed via pretext-measured sampling:
- Sample: For each column, sample values from data rows
- Measure: For each sampled value, measure "shrink wrap" width via
walkLineRanges(finds widest line, handles\n) - Compute statistics: mean (μ), variance (σ²) of measured widths
- Assign flex parameters:
- flexBasis:
μ(average measured width + padding) — can be overridden per column - flexGrow: derived from variance (see formula below) — can be overridden per column
- flexShrink: derived from variance — can be overridden per column
- minWidth:
max(μ - 2*σ, 50px)— can be overridden per column - maxWidth:
300pxfixed for now
- flexBasis:
Variance-based flex formula (normalized 0-1 scale):
const varianceScore = Math.min(1, σ / (μ * 0.5)); // 0 = no variance, 1 = high variance
const flexGrow = 0.1 + varianceScore * 1.9; // 0.1 (stable) to 2.0 (variable)
const flexShrink = 0.1 + varianceScore * 1.9; // same scaling
User overrides: Column definition can specify flexGrow, flexShrink, flexBasis, minWidth, maxWidth directly, which takes precedence over computed values.
Dynamic Wrapping Behavior
- CSS
word-break: break-word+white-space: normalon cells - When column width reaches
maxWidth(300px), text wraps to multiple lines - When table can't expand (parent has
overflow-x: hidden), columns compress (flexShrink applies) - For custom renderer columns: uses
minWidthfrom size-hinted return or default 80px
User can override via column definition props. No runtime width adjustment.
Assumptions (to validate)
- pretext performance is acceptable for large datasets (~1M rows)
- Custom virtualizer approach is sufficient (no TanStack Virtual needed)
- Runtime height adjustment for custom renderers is acceptable UX
Open Questions
All resolved:
- Fixed columns: YES (left/right pinning via
fixed: 'left' | 'right') - flexGrow/flexShrink: Data-driven via variance; average affects flexBasis; manual overrides allowed
- maxWidth: 300px fixed
Requirements
- Backward compatible Props: Keep existing prop interface (data, columns, page, height, rowKey, border, etc.)
- Config-less behavior: Table works without any width configuration
- Content-aware column widths: Pretext measures sampled data; variance drives flexGrow/flexShrink, mean drives flexBasis
- User overrides: Column definition can override flexGrow, flexShrink, flexBasis, minWidth, maxWidth
- Dynamic wrapping: maxWidth=300px triggers CSS wrapping; parent overflow-x hidden triggers compression
- Virtual scrolling: Custom prefix-sum + binary search virtualizer; all row heights pre-computed via pretext
- Runtime height augmentation: For custom cellRenderer columns, measure DOM after mount, maintain running average
- cellRenderer flexibility: Return plain VNode or
{vnode, minHeight, minWidth}object - Pagination: Support existing pagination UI and behavior
- Fixed columns: Support left/right pinning via
fixed: 'left' | 'right' - Slots: Support column slots and header slots
- Dict/timestamp/filesize formatting: Keep existing built-in formatters
Acceptance Criteria (evolving)
- Existing prop interface maintained (backward compatible)
- Table renders correctly without any width config
- Column widths adapt to content
- Long text wraps instead of overflowing
- Virtual scrolling works for large datasets
- Row heights estimated via pretext (no DOM measurement for height)
- Pagination works
- Fixed columns work
- Lint/typecheck passes
Out of Scope (explicit)
- Tree data / expandable rows (not in current impl either)
- Column resizing by drag (nice to have)
- Sort/filter (via slot customization, not built-in)
Technical Notes
Implementation Structure (planned)
packages/base/data/list-table-v2.vue (main component)
├── usePretextColumnWidths() # pretext-based column width computation
├── usePretextRowHeights() # pretext-based row height pre-computation
├── useVirtualRows() # custom prefix-sum + binary search virtualizer
└── useRuntimeHeightAugment() # ResizeObserver + running average for custom renderers
Column Width Computation (usePretextColumnWidths)
- Sample data rows (up to 100 samples evenly distributed)
- For each sampled value, measure "shrink wrap" width via pretext:
let maxW = 0; walkLineRanges(prepared, 10000, line => { if (line.width > maxW) maxW = line.width; }); // maxW = minimum width to contain all lines (widest line wins)This handles embedded
\ncorrectly and avoids binary search. - Compute mean (μ) and variance (σ²) per column
- Derive flex parameters:
flexBasis = μ + paddingflexGrow = 0.1 + (σ/μ*0.5) * 1.9(clamped 0.1-2.0)flexShrink = same as flexGrowminWidth = max(μ - 2*σ, 50) + paddingmaxWidth = 300px
- User overrides from column definition applied after computation
Row Height Computation (usePretextRowHeights)
For plain text columns (no custom renderer):
- For each row, for each column:
prepare(text, font)→ cachedlayout(prepared, columnWidth - padding, lineHeight)→ height
- Row height = max cell height + row padding
For custom renderer columns:
- Use
minHeightfrom size-hinted return - After mount, measure DOM via ResizeObserver
- Update running average per column
- Recompute offsets when average shifts >10%
Virtualization (useVirtualRows)
- Build prefix-sum offsets:
offsets[i+1] = offsets[i] + heights[i] - On scroll: binary search for
startIndexwhereoffsets[i+1] >= scrollTop - Linear scan to find
endIndexwhereoffsets[i] < scrollTop + viewportHeight - Render rows [startIndex-overscan, endIndex+overscan] with
transform: translateY(offsets[startIndex])
Packages Needed
@chenglou/pretextv0.0.4 — text measurement
Implementation Plan (4 PRs)
PR1: Scaffold Hooks & Types ✅ COMPLETED
Files created:
packages/base/data/list-table-v2/
├── index.ts # Exports all hooks and types
├── types.ts # ListTableColumn<T>, ListTableProps, CellRendererResult, etc.
├── measureText.ts # Cached pretext measurement (prepareWithSegments caching)
├── usePretextColumnWidths.ts # Flex params from variance/mean
├── usePretextRowHeights.ts # Pre-computed row heights via pretext
├── useVirtualRows.ts # Prefix-sum + binary search virtualizer
└── useRuntimeHeightAugment.ts # ResizeObserver + running average
Key decisions:
prepareWithSegments()for everything (superset type works withlayout()+walkLineRanges())- Single cache for PreparedTextWithSegments, LRU-capped at 10,000 entries
- Types isolated in
types.tsfor backward compatibility
PR2: Core Virtualizer + Pretext Measurement (PENDING)
Goal: Wire hooks into Vue component, implement flex container, basic table shell with virtual scrolling.
Files to create/modify:
packages/base/data/list-table-v2/list-table-v2.vue(rewrite)
Implementation steps:
-
Vue component shell
- Props: same as existing
list-table-v2.vue(data, columns, page, height, etc.) - Emits: query, selection-change, row-click, cell-click
- Use
el-auto-resizerreplacement or manual ResizeObserver for container width
- Props: same as existing
-
Wire usePretextColumnWidths
const { computedConfigs, totalFlexBasis } = usePretextColumnWidths(data, columns, containerWidth)- Convert computed flex configs to CSS/flexbox styling
-
Wire usePretextRowHeights
const { rowHeights, totalHeight } = usePretextRowHeights(data, columns, columnWidths)- Returns array of heights for virtualizer
-
Wire useVirtualRows
const { visibleRows, totalHeight, onScroll, scrollToIndex } = useVirtualRows(rowHeights, viewportHeight)- Container div with
overflow-y: auto - Inner spacer div with
height: totalHeight - Render visible rows at
transform: translateY(offsetY)
-
Basic cell rendering
- Plain text cells: render formatted value
- Use existing formatters (dict, timestamp, filesize)
-
CSS flex container for columns
- Each column:
flex: ${flexGrow} ${flexShrink} ${flexBasis}px min-width: ${minWidth}pxmax-width: ${maxWidth}px- CSS
word-break: break-wordfor wrapping
- Each column:
-
Header row
- Fixed height: 44px (or
headerHeightprop if provided) - Render header cells with same flex styling
- Fixed height: 44px (or
Acceptance criteria:
- Table renders without any width config
- Column widths adapt to content
- Scrolling works for 1000+ rows (virtualized)
- No layout shift on scroll
PR3: Custom cellRenderer + Runtime Augmentation (PENDING)
Goal: Support flexible cellRenderer return types and DOM measurement for custom renderers.
Files to create/modify:
packages/base/data/list-table-v2/list-table-v2.vue(update)
Implementation steps:
-
cellRenderer return type handling
function resolveCellContent(cellResult: CellRendererResult, params) { if (!cellResult) return null; // Plain VNode if (cellResult.component) return cellResult; // VNode has 'component' // Size-hinted object: { vnode, minHeight, minWidth } return cellResult.vnode; } -
Track which columns have custom renderers
const customRendererColumns = computed(() => columns.filter(c => c.cellRenderer))
-
Wire useRuntimeHeightAugment
const { recordHeight, flushSamples, getColumnHeight } = useRuntimeHeightAugment()
-
Add ResizeObserver for custom renderer rows
- For each rendered row, observe cells with custom renderers
- On resize: call
recordHeight(columnKey, measuredHeight)
-
Recompute on significant change
- After
flushSamples(), check if any column average shifted >10% - If so, update rowHeights and re-trigger virtualizer
- After
-
Initial height for custom renderers
- Check if cellRenderer returns
{vnode, minHeight}— use that - Otherwise fall back to 44px default
- Check if cellRenderer returns
Acceptance criteria:
- cellRenderer returning plain VNode works as before
- cellRenderer returning
{vnode, minHeight, minWidth}works - DOM heights measured after mount for custom renderers
- Running average updates row heights dynamically
PR4: Fixed Columns, Pagination, Slots, Polish (PENDING)
Goal: Full feature parity with existing list-table-v2.vue
Files to create/modify:
packages/base/data/list-table-v2/list-table-v2.vue(update)
Implementation steps:
-
Fixed columns (left/right pinning)
- Split columns into: left-fixed, scrollable, right-fixed
- Left-fixed:
position: sticky; left: 0 - Right-fixed:
position: sticky; right: 0 - Main scroll container only contains scrollable columns
- Synchronize scroll position between fixed and scrollable headers
-
Pagination
- Add pagination div below table
- Use existing
el-paginationcomponent - On page/size change: emit
query
-
Column slots
v-slot:[column.key]support for custom cell renderingv-slot:header-[column.key]support for custom header rendering
-
Slots passthrough
- Pass through
slotsfrom parent component
- Pass through
-
Built-in formatters
- dict:
formatterByDist()from existing code - timestamp:
TzDateTimecomponent - filesize:
formatFileSize()from existing code
- dict:
-
Debug mode
- Show estimated row heights, column flex params
- Toggle via
debugprop
-
Border styling
- CSS borders on cells/rows when
borderprop is true
- CSS borders on cells/rows when
-
Row/column events
@row-click: emit when row div clicked@cell-click: emit when cell clicked
Acceptance criteria:
- Fixed left/right columns work
- Pagination emits query event
- Column slots work
- dict/timestamp/filesize formatting works
- Debug mode shows info
- Border styling works
- Row/cell click events fire
How to Resume After Context Clear
- Read
prd.mdfor full requirements - Check
task.jsonfor current PR status (subtasksarray) - PR1 is complete — hooks are scaffolded in
packages/base/data/list-table-v2/ - Continue with PR2: implement
list-table-v2.vuecomponent - PR3: add cellRenderer support + runtime augmentation
- PR4: fixed columns, pagination, slots, polish