# vue-virtual-scroller-kit — AI Reference Virtual list/table/grid/tree/select for Vue 3: a segment-tree-backed O(log n) position manager, dynamic per-row height measurement via ResizeObserver, priority-free flat virtualization plus 5 higher-level components built on the core `VirtualList` (table, grouped list, tree, infinite loader, searchable select), a standalone custom scrollbar, and 4 standalone composables (keyboard nav, drag-to-reorder, row selection, IntersectionObserver visibility tracking). Zero runtime dependencies beyond the `vue >=3.3.0` peer. Version 0.3.0+ (this document reflects source with 7 real bugs fixed via PR #19, merged into `master` — npm still serves 0.2.6 as of this writing (the fix is merged but not yet published) — including a package that shipped literally unreachable required CSS, and two components with no SSR rendering at all despite the README's "full SSR support" claim. If you're reading an installed `dist/` older than 0.3.0, expect the specific behaviors called out as "fixed"/"as of this fix" below to instead match their "before" state, described alongside each one). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/vue-virtual-scroller-kit/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/vue-virtual-scroller-kit/guide/overview - GitHub: https://github.com/macrulezru/vue-virtual-scroller-kit - npm: https://www.npmjs.com/package/vue-virtual-scroller-kit Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map — single entry, no subpaths `vue-virtual-scroller-kit` exports everything (8 components, core class/composable, 4 standalone composables, all types) from one `"."` entry — there are no subpaths at all. **As of this fix**, `"./style.css"` is also in `exports` — `import 'vue-virtual-scroller-kit/style.css'` works. **Before this fix**, `exports` only listed `"."`, so the real, required stylesheet (table column resize/pin visuals, tree toggle buttons, the select dropdown, the custom scrollbar thumb, skeleton loading shimmer) was **completely unreachable** by any bundler that respects `exports` (every modern one) — there was no working way to import it at all, and the README never even mentioned needing to. --- ## 2. Core: `PositionManager` (`core/PositionManager.ts`) Segment tree over a `Float64Array`, sized to the next power of 2 ≥ `count`. O(log n) for everything: ```ts new PositionManager(count: number, estimatedItemSize: number | ((index: number) => number) = 50) get totalSize: number // sum of all heights set(index, height): void // update one height, propagate delta up the tree getOffset(index): number // cumulative height before `index` (prefix sum) getHeight(index): number findIndex(offset): number // binary search: first row whose cumulative offset >= `offset` ``` `findIndex()` at the exact viewport-bottom boundary is inclusive by design (tested, not a bug) — a row starting exactly at `scrollTop + clientHeight` is included in the raw range before overscan is applied. --- ## 3. Core: `useVirtualScroll` (`core/useVirtualScroll.ts`) — shared by List/Table/Grid ```ts interface UseVirtualScrollOptions { itemCount: number | Ref estimatedItemSize?: SizeProvider | Ref // number | (index) => number overscan?: number // default 3 getScrollElement: () => HTMLElement | null pageMode?: boolean // window as scroll container horizontal?: boolean // scrollLeft/clientWidth axis, RTL-safe. Not combinable with pageMode motionBlur?: boolean | Ref // opt-in CSS blur while scrolling fast, off by default (zero cost) ssrPreloadCount?: number // default 20 — see section 4 } interface UseVirtualScrollReturn { visibleRange: Readonly> // {start, end}, both inclusive totalHeight: Readonly> offsetTop(index): number scrollTo(index, align?: ScrollAlign, options?): void scrollToOffset(offset, options?): void measureItem(index, height): void handleScroll(): void // exposed so a consumer can re-trigger recalculation manually blurAmount: Readonly> // 0 unless motionBlur enabled } ``` - **Measurement batching**: `measureItem()` pushes into a pending list, flushed once per **10ms `setTimeout`** (not per-frame) — multiple rows measured in the same tick coalesce into one recalculation pass. - **Anchor compensation**: on flush, if any measured row is *above* the current viewport start, the scroll position is shifted by the net height delta so on-screen content doesn't visually jump — suppressed for **1000ms** after any `scrollTo(..., {behavior: 'smooth'})` call, so the compensation doesn't fight the browser's own smooth-scroll animation mid-flight. - **Item-count-change caveat**: reactively changing `itemCount` (a `Ref`) rebuilds a **brand-new `PositionManager`** from scratch, discarding every previously-measured height back to the flat estimate. Real-world cost: combining `dynamicRowHeight`/measured heights with `InfiniteLoader`'s `onLoadMore` or `VirtualGrid`'s column-breakpoint-driven row-count changes re-triggers full-list remeasurement on every append/resize. Tested, documented behavior, not a bug — just a real performance characteristic to know about. - **Motion blur**: velocity tracked raw per scroll event (not rAF-throttled) via `computeBlurAmount()` (`utils/motionBlur.ts`) — `min(maxBlur=6px, (|velocity px/ms| / sensitivity=3) * 6px)`, cleared 120ms after the last scroll event. **SSR (as of this fix)**: `visibleRange` initializes to `{start: 0, end: min(itemCount, ssrPreloadCount) - 1}` when `typeof window === 'undefined'`, instead of `{0, 0}`. `recalcVisibleRange()` only ever runs from `onMounted`, which Vue never calls during a real server render — **before this fix**, `visibleRange` stayed `{0, 0}` for the entire SSR pass regardless of real item count. Verified via a real `vue/server-renderer` `renderToString()` call against the built package: `VirtualTable`/`VirtualGrid` (see section 4) rendered **exactly 1 row**, regardless of whether 5 or 5000 rows were passed — directly contradicting the README's "full SSR support" claim. `VirtualList` (section 5) was never affected — it has its own, separate `isSSR` branch that bypasses `visibleRange` entirely during SSR. --- ## 4. `VirtualTable` / `VirtualGrid` — no shared types with List/Tree despite the family name Confirmed independent implementations: `VirtualGrid.vue` calls `useVirtualScroll()` directly (no `VirtualList` import at all); `VirtualTree.vue` *wraps* `VirtualList` via composition. Zero shared interfaces between `VirtualGrid`/`VirtualTree` — an earlier "tightly coupled pair" assumption about these two was checked and found false. ```ts // VirtualTable.vue props: { columns: ColumnDef[]; rows: T[] stickyHeader?: boolean = true; stickyHeaderOffset?: number = 0 sortable?: boolean = false; multiSort?: boolean = false // Shift+click adds to a multi-column sort stack estimatedItemSize?: number = 40; overscan?: number = 3; keyField?: string = 'id' pinnedTopRows?: T[] = []; pinnedBottomRows?: T[] = [] // rendered in /, always visible virtualizeColumns?: boolean = false // horizontal column virtualization via a SECOND, independent PositionManager keyed on column width resizableColumns?: boolean = false; reorderableColumns?: boolean = false // independent of each other onLoadMore?: () => void; hasMore?: boolean = false; isLoading?: boolean = false loadMoreThreshold?: number = 150 // px from bottom that triggers onLoadMore uniformRowHeight?: boolean = false // skips ResizeObserver entirely — prevents scroll drift for known-uniform rows motionBlur?: boolean = false ssrPreloadCount?: number = 20 // as of this fix — see section 3 } expose: { scrollTo, scrollToOffset, measureItem, getSortStack(), clearSort(), getScrollElement(), setColumnVisible, toggleColumnVisible, getHiddenColumns() } ``` Body structure: two always-rendered `` (top/bottom, height driven by `topOffset`/`bottomSpace`) bracket the visible `` rows — avoids DOM add/remove exactly at the scroll anchor, which browsers' native scroll-anchoring would otherwise fight. Column resize (`resizableColumns`) and reorder (`reorderableColumns`) are fully independent pointer-drag systems that can both be enabled at once; reorder uses a **5px movement threshold** to disambiguate a drag from a sort-triggering click on the same ``. ```ts // VirtualGrid.vue props: { items: T[] columns?: number = 0 // 0 = auto-compute from columnWidth/containerWidth; explicit N overrides columnWidth?: number = 200; rowHeight?: number = 200; gap?: number = 8 keyField?: string = 'id'; overscan?: number = 2; isLoading?: boolean = false; motionBlur?: boolean = false dynamicRowHeight?: boolean = false // measures via ResizeObserver, rowHeight becomes the initial estimate; off = zero cost, cells stay individually absolutely-positioned ssrPreloadCount?: number = 20 // rows, not cells — as of this fix } expose: { scrollTo(index, options?), getScrollElement() } ``` Virtualizes **rows** (`rowCount = ceil(items.length / colCount)`), not individual cells. `colCount` auto-computation needs a real `containerWidth` (set via `ResizeObserver` in `onMounted`, so it's `1` during SSR unless `columns` is explicitly set — a real column-count mismatch between server and client render is possible for auto-column-count grids under SSR; pass an explicit `columns` for deterministic SSR output). `role="grid"`/`"row"`/`"gridcell"` are **hardcoded** — unlike `VirtualList`/`VirtualTree`/`VirtualSelect`, `VirtualGrid` has no `containerRole`/`itemRole` override props, so it can't be embedded inside another semantic ARIA widget without an invalid nested role (a real, currently-open gap — not part of this PR's fix batch; the grid/row/gridcell structure is itself valid ARIA, just non-overridable). --- ## 5. `VirtualList` — the shared core all the wrapper components delegate to ```ts props: { items: T[] keyField?: string = 'id' // as of this fix, supports dot-paths ("a.b.c") — see section 8 estimatedItemSize?: number | ((item: T, index: number) => number) = 50 overscan?: number = 3 minHeight?: number = 0; minWidth?: number = 0 // minWidth only matters when horizontal scrollElement?: HTMLElement | null = null // external scroll container, mutually exclusive with pageMode pageMode?: boolean = false // window as scroll container isLoading?: boolean = false restoreKey?: string // sessionStorage-backed scroll position save/restore ssrPreloadCount?: number = 20 recyclePool?: boolean = false // reuse DOM nodes instead of unmount — disables item-keyed transitions motionBlur?: boolean = false horizontal?: boolean = false // fixed at mount — bind :key to remount on axis change containerRole?: string = 'list' // 'none' removes it from the a11y tree (for a wrapping combobox etc.) itemRole?: string = 'listitem' // 'none' when slot content renders the real row role itself } emits: { scroll: [Event]; 'visible-range-change': [{start,end}] } expose: { scrollTo, scrollToOffset, measureItem, getScrollElement() } ``` **ARIA (as of this fix)**: `aria-rowcount`/`aria-rowindex` are only emitted when `containerRole`/`itemRole` are actually in the grid/table-family (`'grid'|'table'|'treegrid'` for rowcount, `'row'|'gridcell'|'cell'|'columnheader'|'rowheader'` for rowindex) — **before this fix**, they were emitted whenever the role wasn't `'none'`, which meant the **default**, completely unconfigured usage (`containerRole:'list'`/`itemRole:'listitem'`) got `aria-rowcount`/ `aria-rowindex` too — invalid per WAI-ARIA (those attributes are only valid on grid/table-family roles), failing any automated accessibility audit run against the most common usage path. **`scrollElement`/`restoreKey` (as of this fix)**: `onMounted` attaches the `scroll` emit listener and the `restoreKey` `saveScroll` listener via `getScrollEl()` (resolves to `props.scrollElement` when set, same target `useVirtualScroll`'s own listener uses), with a matching watcher re-attaching both if `scrollElement` changes after mount. **Before this fix**, both were hardcoded to `containerRef.value` (the component's own root) regardless of `scrollElement` — since native `scroll` events don't bubble, `@scroll` never fired and `restore-key` never persisted position whenever an external `scroll-element` was used (the virtualization itself still worked correctly in that case — only these two side-effects were silently dead). **SSR**: `isSSR = typeof window === 'undefined'`, computed once per component instance. When true, `visibleItems` renders `min(items.length, ssrPreloadCount)` rows using **estimated, non-measured** offsets (`index * estimatedHeight`), bypassing `visibleRange`/`offsetTop` entirely — this was already correct before the PR; `VirtualTable`/ `VirtualGrid` (section 4) didn't have an equivalent and got one added to the shared composable instead. **`recyclePool`**: when true, the row wrapper's `:key` is `slotIdx` (the DOM-order position) instead of `getItemKey(item, index)` — DOM nodes are reused as different items scroll through them rather than unmounted/remounted, trading away item-keyed enter/leave transitions for scroll performance on heavy row content. --- ## 6. `GroupedVirtualList` — collapsible groups over `VirtualList` ```ts props: { groups: GroupDef[] // {key, label, items: T[], collapsed?} estimatedItemSize?: number = 50; estimatedGroupHeaderSize?: number = 40 overscan?: number = 3; keyField?: string = 'id'; motionBlur?: boolean = false stickyGroupHeaders?: boolean = false // see below — an overlay, not real CSS sticky } emits: { 'visible-range-change'; scroll } expose: { toggle(groupKey), scrollTo, getScrollElement() } ``` Flattens `groups` into a `VirtualRow[]` (`{type:'header'|'item', index, item?, groupKey?, groupLabel?, _key?}`) fed to the inner `VirtualList`. **As of this fix**, each row's `_key` field is actually computed (`header-${groupKey}` for headers; `item-${groupKey}-${keyField value}`, falling back to `item-${index}` when the field is missing, for items) and `key-field="_key"` on the inner `VirtualList` now resolves to real, correct values. **Before this fix**, `_key` was never set on any row (the `keyField` prop's real logic lived only in a separate `getRowKey()` helper used for an unrelated, non-virtualized inner `:key` binding) — `VirtualList`'s own keying always fell back to array index regardless of the documented `keyField` prop, risking wrong DOM-node reuse across collapse/expand-driven index shifts. `estimatedGroupHeaderSize` is threaded through as a **per-row function** to `VirtualList`'s `estimatedItemSize` (header rows get one estimate, item rows another) — not a flat number, despite the prop itself being one. `stickyGroupHeaders` renders an **absolutely-positioned overlay div** showing the group at `flatRows[visibleRangeStart]`, NOT a real CSS `position: sticky` row — virtualized rows are individually absolutely-positioned already, so native sticky can't apply to them. Collapse/expand is animated (**220ms**, `ANIM_MS`) — a group cycles through `collapsedGroups` → `expandingGroups`/`collapsingGroups` (mid-animation) → settled, with `setTimeout`-driven cleanup guarded against being stale if the group is toggled again mid-animation. --- ## 7. `VirtualTree` — flattened lazy tree over `VirtualList` ```ts interface TreeNode { id: string | number; data: N; children?: TreeNode[]; hasChildren?: boolean } interface FlatTreeRow { node: TreeNode; depth: number; isExpanded: boolean; hasChildren: boolean; isLoading: boolean } props: { nodes: TreeNode[] indent?: number = 20 // px per depth level estimatedItemSize?: number = 36; overscan?: number = 5 onLoadChildren?: (node) => Promise[]> // lazy children for hasChildren:true nodes with no children[] yet motionBlur?: boolean = false } emits: { 'node-expand'; 'node-collapse'; 'node-click' } expose: { scrollTo, expandAll(nodes?), collapseAll(), expandedIds, getScrollElement() } ``` Renders `container-role="tree" item-role="none"` on the inner `VirtualList`, `role="treeitem"` on each row itself (with `aria-expanded`/`aria-level`/`aria-rowindex`) — confirmed no invalid nested-role tree (this is the fix that established the `containerRole`/`itemRole` mechanism in the first place, still correctly in place). **`key-field="node.id"` (as of this fix in the same PR, a follow-on regression from fixing `GroupedVirtualList`'s `keyField`)**: `VirtualList`'s `getItemKey()` now resolves dot-separated `keyField` paths, not just flat property names. **Before this fix**, `item['node.id']` (a literal, flat property lookup) always missed — `FlatTreeRow` objects have no property literally named `"node.id"`, the real id lives at `row.node.id` — so tree-row keying silently fell back to array index for every single row, regardless of the tree node's real identity. Lazy loading: `onLoadChildren` is only invoked the first time a `hasChildren: true` node with no `children`/cached lazy children is expanded; results are cached in a `Map` keyed by node id (never re-fetched on subsequent collapse/expand of the same node). --- ## 8. `VirtualList.getItemKey()` — dot-path keyField (as of this fix) ```ts // keyField without a "." → flat lookup (unchanged, still the common case) // keyField containing "." → resolved as a dot-separated path into the item ``` Only triggers path resolution when `keyField.includes('.')` — a flat `keyField: 'id'` (the default, and what `VirtualTable`/`VirtualGrid`/ `VirtualSelect`/`GroupedVirtualList` all use) is completely unaffected. Falls back to `index` if the resolved value is `null`/`undefined` at any point in the path, same as the flat case always did. --- ## 9. `InfiniteLoader` — threshold-based bidirectional loading over `VirtualList` ```ts props: { items: T[] onLoadMore: () => Promise // required, no default threshold?: number = 200 // px from the relevant edge direction?: 'down' | 'up' | 'both' = 'down' isLoading: boolean; hasMore: boolean // both required, no defaults — caller-driven state keyField?: string = 'id'; estimatedItemSize?: number = 50; overscan?: number = 3; motionBlur?: boolean = false } expose: { scrollTo, scrollToOffset, getScrollElement() } ``` Own scroll handler (**50ms debounced**) checks threshold independent of `VirtualList`'s own virtualization scroll tracking — passes its own `containerRef` as `VirtualList`'s `scroll-element` prop (so this component is unaffected by the pre-fix `scrollElement` listener bug in section 5 — it has its own separate listener). `direction: 'up'` captures `scrollHeight`/`scrollTop` *before* calling `onLoadMore()`, then restores scroll position after prepend via `requestAnimationFrame(() => el.scrollTop = savedScrollTop + (el.scrollHeight - savedScrollHeight))` — keeps the visual scroll position stable when new items are prepended above the viewport. A module-level `isLoadingMore` flag (not the `isLoading` prop, which is caller-controlled) guards against overlapping concurrent load triggers from rapid scroll events. **Zero test coverage** — no `InfiniteLoader.test.ts` and no Playwright e2e spec, despite this real threshold/direction/concurrency/scroll-restoration logic (flagged in want-fix.md — this exact gap is why the `scrollElement` listener bug elsewhere went unnoticed for a while, though `InfiniteLoader` itself wasn't affected by it). --- ## 10. `VirtualSelect` — searchable virtualized combobox over `VirtualList` ```ts props: { options: T[]; modelValue?: T | null = null labelField?: string = 'label'; valueField?: string = 'value' placeholder?: string = 'Select an option…' disabled?: boolean = false; clearable?: boolean = false estimatedItemSize?: number = 36; maxVisibleRows?: number = 8 searchable?: boolean = true motionBlur?: boolean = false remote?: boolean = false // skip client-side filtering — options assumed pre-filtered by the consumer isLoading?: boolean = false // shows #loading slot instead of options/empty debounceMs?: number = 0 // delay before the `search` EVENT fires (0 = synchronous); the input's displayed value always updates instantly regardless } emits: { 'update:modelValue'; change; search } expose: { open(), close(), getScrollElement() } ``` Client-side filtering (`filteredOptions`, skipped entirely when `remote: true`) is a simple case-insensitive substring match on `getLabel(option)`. Dropdown height = `min(maxVisibleRows, filteredOptions.length) * estimatedItemSize` — NOT measured, always uses the estimate even if real row heights would differ (options don't support `dynamicRowHeight`). Keyboard: ArrowUp/Down move `highlightedIndex` + `scrollTo(idx, 'auto')`, Enter selects, Escape/Tab close — no Home/End (unlike `VirtualScrollbar`/`useVirtualKeyboardNav`, likely intentional for a combobox but not documented as such). Inner `VirtualList` uses `container-role="none" item-role="none"` (the dropdown itself is `role="listbox"`, each row `role="option"`) — no invalid nesting. --- ## 11. `VirtualScrollbar` — standalone, syncs to any external scroll element ```ts props: { target: () => HTMLElement | null // required — e.g. () => listRef.value?.getScrollElement() ?? null orientation?: 'vertical' | 'horizontal' = 'vertical' minThumbSize?: number = 24 // px — keeps the thumb grabbable even for huge lists } expose: { refresh: attach } // manually re-resolve `target()` (e.g. after a Suspense/async-component resolves) ``` Fully decoupled from every other component — syncs to ANY element via the `target()` accessor, not just this package's own components. `attach()` is polled up to **60 animation frames** after mount (handles `target()` resolving after this component's own mount, e.g. behind `Suspense`). Real ARIA: `role="scrollbar"`, `aria-orientation`, `aria-valuemin="0"`/`aria-valuemax="100"` (percentage-based, not raw pixels — `valueNow` is `round(thumbOffsetRatio * 100)`), full keyboard support (Arrow keys, PageUp/PageDown = one `clientSize`, Home/End = jump to start/end). Auto-hides (`visibility: hidden`, not `display:none` — stays in the layout) via `.vvsk-scrollbar--hidden` when `thumbRatio >= 1` (content fits, nothing to scroll). --- ## 12. Standalone composables ```ts useVirtualKeyboardNav(options: { itemCount: number | Ref scrollTo: (index, align?) => void target?: Ref | HTMLElement | null // default document onActivate?: (index) => void; onChange?: (index) => void loop?: boolean = false }): { focusedIndex: Readonly>; setFocus(index); isFocused(index) } // Arrow/Home/End/PageUp/PageDown(±10)/Enter+Space(activate) useDraggableList(options: { items: Ref onReorder?: (newItems, from, to) => void isDragDisabled?: (item, index) => boolean scrollContainer?: HTMLElement | Ref // auto-scroll near edges while dragging }): { dragIndex, overIndex, isDragging: Readonly> ghostStyle: Readonly> // fixed-position, follows cursor getItemStyle(index): CSSProperties // opacity:0 for the placeholder, translateY offset for gap animation getItemProps(index): { 'data-drag-index'; class; onPointerdown } } // Auto-scroll zone: 60px from container edge, max 14px/frame, speed scales linearly within the zone. useRowSelection(options: { items: Ref | ComputedRef getKey?: (item, index) => string|number // default: item.id ?? index multiple?: boolean = true }): { selectedKeys: Readonly>>; selectedItems: ComputedRef isSelected(item, index): boolean toggle(item, index, event?): void // shiftKey = range-select from last toggle; ctrl/cmd = additive toggle; plain = flip just this one (or REPLACE selection entirely when multiple:false) selectAll(): void; clearSelection(): void } useVisibilityTracker(options?: { root?: () => HTMLElement | null // default: browser viewport rootMargin?: string; threshold?: number | number[] = 0 onVisible?, onHidden?: (key) => void }): { visibleKeys: Readonly>>; isVisible(key); observe(el, key); unobserve(key) } // Polls up to 60 RAF frames for `root()` to resolve (same pattern as VirtualScrollbar's attach()). ``` --- ## 13. Utilities ```ts autoColWidths(cols: {key,title}[], rows: T[], options?: {font='12px sans-serif', padding=24, minWidth=60, maxWidth=320}): Map // Canvas measureText over every row's stringified value per column + the header title. // SSR fallback (no canvas 2d context available): flat 120px for every column. normalizeScrollLeft(el): number // RTL-safe "distance from start", positive in both LTR and RTL rawScrollLeftFor(el, distanceFromStart): number setNormalizedScrollLeft(el, distanceFromStart): void computeBlurAmount(velocityPxPerMs, opts?: {maxBlur=6, sensitivity=3}): number ``` --- ## 14. Fixed-bug history (verify against your installed version) All seven fixed via PR #19 (merged), across two commits, each verified by toggling `git stash` between pre-fix and post-fix source and confirming the relevant behavior flips; full suite 202/202 passing after both: 1. **CSS unreachable via `exports`** — section 1. 2. **`VirtualList`'s `@scroll`/`restore-key` silently dead with an external `scroll-element`** — section 5. 3. **`GroupedVirtualList`'s `keyField` was dead** — section 6. 4. **Two stale, unused duplicate public types** (`VirtualScrollOptions`/ `VirtualScrollReturn` in `types.ts`, missing `pageMode`/`horizontal`/ `motionBlur`/`handleScroll`/`blurAmount` compared to the real `UseVirtualScrollOptions`/`UseVirtualScrollReturn`) — removed; the public names now alias the real types from `core/useVirtualScroll.ts`. 5. **`VirtualList`'s default ARIA was invalid** — section 5. 6. **`VirtualTable`/`VirtualGrid` had no SSR rendering at all**, contradicting the README's "full SSR support" claim — sections 3, 4. Verified via a real `renderToString()` SSR render against the built package: pre-fix, both rendered exactly 1 row regardless of 500 real rows passed; post-fix, 20 real preloaded rows (the default `ssrPreloadCount`). 7. **`VirtualList`'s `keyField` didn't support dot-paths — broke `VirtualTree`** — section 7/8, found immediately after fixing #3, same underlying bug class. --- ## 15. Consolidated gotcha list 1. **`VirtualGrid` has no `containerRole`/`itemRole` override props** (unlike List/Tree/Select) — its `role="grid"/"row"/"gridcell"` is valid on its own but can't be embedded inside another semantic widget without an invalid nested role (section 4). 2. **`VirtualGrid`'s auto-column-count (`columns: 0`) needs a real `containerWidth`, unavailable during SSR** — resolves to `1` column server-side unless `columns` is set explicitly, which can produce a real column-count mismatch vs. the client render (section 4). 3. **Reactively changing `itemCount` discards all measured heights**, rebuilding a fresh `PositionManager` from the flat estimate — a real performance cost when combining `dynamicRowHeight` with frequent `InfiniteLoader` appends or `VirtualGrid` column-breakpoint resizes (section 3). 4. **`VirtualTree`'s `aria-rowindex` is 1-based** (`index + 1`) while `expandAll`/`collapseAll`/most internal indices are 0-based, as is standard for ARIA index attributes — easy to trip on when reading raw ARIA output against the underlying flattened row array (section 7). 5. **`useDraggableList`'s auto-scroll only scrolls vertically** (`el.scrollTop`) even though drag reordering itself works for any layout direction the consumer's CSS produces (section 12). 6. **`VirtualSelect`'s dropdown height is always estimated, never measured** — `maxVisibleRows * estimatedItemSize`, even though individual option rows could in principle vary in real height (section 10). 7. **`InfiniteLoader` has zero test coverage** despite real threshold/direction/concurrency/scroll-restoration logic — flagged in want-fix.md, not part of this PR's scope (section 9).