From 1214ec72988866763397fb867e998cc354fa6b88 Mon Sep 17 00:00:00 2001 From: Oliver Sluke <22557015+oliversluke@users.noreply.github.com> Date: Mon, 18 May 2026 20:48:38 +0200 Subject: [PATCH] Fixed #4109 - DataTable + VirtualScroller spacer drift with rowGroupMode subheader Add an optional getItemSize(index): number prop to VirtualScroller so it can be made variable-row-height aware. When supplied, VS builds a cumulative-size prefix-sum array and uses it for spacer total, content translation, scroll-to-index, and viewport-size calculation. When null (default), VS retains its existing uniform-height code path byte-for-byte. DataTable supplies a getItemSize that returns itemSize plus an auto- measured group-header height at cluster boundaries, and 0 for hidden rows inside collapsed clusters. The spacer-tbody subtraction sums the actually-rendered window's sizes. This corrects both drift sources: collapsed clusters no longer over-contribute to the cumulative total, and subheader rows are now accounted for in the spacer formula. Two related pre-existing bugs are addressed in the same change: - BodyRow.shouldRenderRowGroupHeader / shouldRenderRowGroupFooter use this.value[this.rowIndex - 1], which mixes a windowed value array with an absolute rowIndex in VS mode. Switched to this.index for windowed position lookup. Non-VS behavior unchanged. - TableBody's inline top on every sticky group-header causes them to stack at the same threshold when groups are dense in VS mode. Gated on isVirtualScrollerDisabled so non-VS sticky behavior is preserved. Tests: 11 new VirtualScroller specs (uniform/variable paths, cumulative correctness, binary-search boundary cases, reference-change invalidation) and 3 new DataTable specs (getItemSize boundary detection, collapsed- cluster handling). All 410 existing specs continue to pass. Signed-off-by: Oliver Sluke <22557015+oliversluke@users.noreply.github.com> --- packages/primevue/src/datatable/BodyRow.vue | 4 +- .../primevue/src/datatable/DataTable.spec.js | 58 ++++++++ packages/primevue/src/datatable/DataTable.vue | 70 +++++++++- packages/primevue/src/datatable/TableBody.vue | 2 +- .../virtualscroller/BaseVirtualScroller.vue | 4 + .../virtualscroller/VirtualScroller.spec.js | 126 +++++++++++++++++ .../src/virtualscroller/VirtualScroller.vue | 127 ++++++++++++++++-- 7 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 packages/primevue/src/virtualscroller/VirtualScroller.spec.js diff --git a/packages/primevue/src/datatable/BodyRow.vue b/packages/primevue/src/datatable/BodyRow.vue index 07502945e1..3dc39d4d85 100644 --- a/packages/primevue/src/datatable/BodyRow.vue +++ b/packages/primevue/src/datatable/BodyRow.vue @@ -554,7 +554,7 @@ export default { }, shouldRenderRowGroupHeader() { const currentRowFieldData = resolveFieldData(this.rowData, this.groupRowsBy); - const prevRowData = this.value[this.rowIndex - 1]; + const prevRowData = this.value[this.index - 1]; if (prevRowData) { const previousRowFieldData = resolveFieldData(prevRowData, this.groupRowsBy); @@ -569,7 +569,7 @@ export default { return false; } else { let currentRowFieldData = resolveFieldData(this.rowData, this.groupRowsBy); - let nextRowData = this.value[this.rowIndex + 1]; + let nextRowData = this.value[this.index + 1]; if (nextRowData) { let nextRowFieldData = resolveFieldData(nextRowData, this.groupRowsBy); diff --git a/packages/primevue/src/datatable/DataTable.spec.js b/packages/primevue/src/datatable/DataTable.spec.js index 4c1899f8ee..645b9cb632 100644 --- a/packages/primevue/src/datatable/DataTable.spec.js +++ b/packages/primevue/src/datatable/DataTable.spec.js @@ -1540,4 +1540,62 @@ describe('DataTable.vue', () => { expect(rowCheckboxIcons[1].text()).toBe('CustomIcon'); expect(rowCheckboxIcons[2].text()).toBe('CustomIcon'); }); + + // VirtualScroller + rowGroupMode="subheader" item-size contract + it('should expose getItemSize as null when rowGroupMode is unset', () => { + expect(wrapper.vm.getItemSize).toBe(null); + }); + + it('should report itemSize plus a header contribution at group boundaries', async () => { + const grouped = [ + { id: 0, rep: 'Amy' }, + { id: 1, rep: 'Amy' }, + { id: 2, rep: 'John' }, + { id: 3, rep: 'John' }, + { id: 4, rep: 'Maria' } + ]; + + await wrapper.setProps({ + value: grouped, + rowGroupMode: 'subheader', + groupRowsBy: 'rep', + virtualScrollerOptions: { itemSize: 36 } + }); + wrapper.vm.d_rowGroupHeaderHeight = 40; + + const fn = wrapper.vm.getItemSize; + + expect(typeof fn).toBe('function'); + expect(fn(0)).toBe(76); // boundary (first row) + data row + expect(fn(1)).toBe(36); // continuation of Amy + expect(fn(2)).toBe(76); // boundary into John + expect(fn(3)).toBe(36); // continuation of John + expect(fn(4)).toBe(76); // boundary into Maria + }); + + it('should report 0 for non-anchor rows of a collapsed group', async () => { + const grouped = [ + { id: 0, rep: 'Amy' }, + { id: 1, rep: 'Amy' }, + { id: 2, rep: 'John' }, + { id: 3, rep: 'John' } + ]; + + await wrapper.setProps({ + value: grouped, + rowGroupMode: 'subheader', + groupRowsBy: 'rep', + expandableRowGroups: true, + expandedRowGroups: ['John'], + virtualScrollerOptions: { itemSize: 36 } + }); + wrapper.vm.d_rowGroupHeaderHeight = 40; + + const fn = wrapper.vm.getItemSize; + + expect(fn(0)).toBe(40); // Amy anchor: header only (collapsed) + expect(fn(1)).toBe(0); // Amy continuation: hidden + expect(fn(2)).toBe(76); // John anchor: header + data row (expanded) + expect(fn(3)).toBe(36); // John continuation: data row only + }); }); diff --git a/packages/primevue/src/datatable/DataTable.vue b/packages/primevue/src/datatable/DataTable.vue index fee84ef25f..c98c1df8d3 100755 --- a/packages/primevue/src/datatable/DataTable.vue +++ b/packages/primevue/src/datatable/DataTable.vue @@ -82,6 +82,7 @@ :style="scrollHeight !== 'flex' ? { height: scrollHeight } : undefined" :scrollHeight="scrollHeight !== 'flex' ? undefined : '100%'" :disabled="virtualScrollerDisabled" + :getItemSize="getItemSize" loaderDisabled inline autoSize @@ -242,7 +243,7 @@ @@ -423,7 +424,8 @@ export default { d_editingMeta: {}, d_filters: this.cloneFilters(this.filters), d_columns: new HelperSet({ type: 'Column' }), - d_columnGroups: new HelperSet({ type: 'ColumnGroup' }) + d_columnGroups: new HelperSet({ type: 'ColumnGroup' }), + d_rowGroupHeaderHeight: 0 }; }, rowTouched: false, @@ -499,6 +501,8 @@ export default { if (this.editMode === 'row' && this.dataKey && !this.d_editingRowKeys) { this.updateEditingRowKeys(this.editingRows); } + + this.updateRowGroupHeaderHeight(); }, beforeUnmount() { this.unbindColumnResizeEvents(); @@ -515,6 +519,8 @@ export default { if (this.editMode === 'row' && this.dataKey && !this.d_editingRowKeys) { this.updateEditingRowKeys(this.editingRows); } + + this.updateRowGroupHeaderHeight(); }, methods: { columnProp(col, prop) { @@ -2045,6 +2051,40 @@ export default { }, hasSpacerStyle(style) { return isNotEmpty(style); + }, + updateRowGroupHeaderHeight() { + if (this.rowGroupMode !== 'subheader' || !this.groupRowsBy || this.virtualScrollerDisabled) { + return; + } + + const header = this.$refs.table && findSingle(this.$refs.table, 'tr.p-datatable-row-group-header'); + + if (header) { + const height = getOuterHeight(header); + + if (height && height !== this.d_rowGroupHeaderHeight) { + this.d_rowGroupHeaderHeight = height; + } + } + }, + getRenderedWindowSize(slotProps) { + if (!slotProps || !slotProps.rows || !slotProps.rows.length) { + return 0; + } + + const fn = this.getItemSize; + + if (typeof fn !== 'function') { + return slotProps.rows.length * slotProps.itemSize; + } + + let total = 0; + + for (let i = 0; i < slotProps.rows.length; i++) { + total += fn(slotProps.getItemOptions(i).index); + } + + return total; } }, computed: { @@ -2111,6 +2151,32 @@ export default { return !data || data.length === 0; }, + getItemSize() { + const baseSize = (this.virtualScrollerOptions && this.virtualScrollerOptions.itemSize) || 0; + const groupRowsBy = this.groupRowsBy; + const subheader = this.rowGroupMode === 'subheader' && !!groupRowsBy; + + if (!subheader) { + return null; + } + + const items = this.processedData; + const expandable = this.expandableRowGroups; + const expandedGroups = this.expandedRowGroups; + const headerHeight = this.d_rowGroupHeaderHeight; + + return (index) => { + if (!items || index < 0 || index >= items.length) { + return baseSize; + } + + const groupValue = resolveFieldData(items[index], groupRowsBy); + const isFirstOfGroup = index === 0 || resolveFieldData(items[index - 1], groupRowsBy) !== groupValue; + const isExpanded = !expandable || (expandedGroups && expandedGroups.indexOf(groupValue) > -1); + + return (isFirstOfGroup ? headerHeight : 0) + (isExpanded ? baseSize : 0); + }; + }, paginatorTop() { return this.paginator && (this.paginatorPosition !== 'bottom' || this.paginatorPosition === 'both'); }, diff --git a/packages/primevue/src/datatable/TableBody.vue b/packages/primevue/src/datatable/TableBody.vue index 9d052807af..0a9f0158ef 100755 --- a/packages/primevue/src/datatable/TableBody.vue +++ b/packages/primevue/src/datatable/TableBody.vue @@ -279,7 +279,7 @@ export default { }, computed: { rowGroupHeaderStyle() { - if (this.scrollable) { + if (this.scrollable && this.isVirtualScrollerDisabled) { return { top: this.rowGroupHeaderStyleObject.top }; } diff --git a/packages/primevue/src/virtualscroller/BaseVirtualScroller.vue b/packages/primevue/src/virtualscroller/BaseVirtualScroller.vue index ddecde33cc..135ad9fa10 100644 --- a/packages/primevue/src/virtualscroller/BaseVirtualScroller.vue +++ b/packages/primevue/src/virtualscroller/BaseVirtualScroller.vue @@ -20,6 +20,10 @@ export default { type: [Number, Array], default: 0 }, + getItemSize: { + type: Function, + default: null + }, scrollHeight: null, scrollWidth: null, orientation: { diff --git a/packages/primevue/src/virtualscroller/VirtualScroller.spec.js b/packages/primevue/src/virtualscroller/VirtualScroller.spec.js new file mode 100644 index 0000000000..4a0f02ed03 --- /dev/null +++ b/packages/primevue/src/virtualscroller/VirtualScroller.spec.js @@ -0,0 +1,126 @@ +import { mount } from '@vue/test-utils'; +import PrimeVue from 'primevue/config'; +import VirtualScroller from './VirtualScroller.vue'; + +window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +const buildItems = (count) => Array.from({ length: count }, (_, i) => ({ id: i })); + +const mountScroller = (props = {}) => { + const wrapper = mount(VirtualScroller, { + global: { plugins: [PrimeVue] }, + props: { + items: buildItems(20), + itemSize: 30, + style: { height: '120px', width: '200px' }, + ...props + } + }); + + // jsdom does not report element visibility, so viewInit() skips init(). + // getContentPosition reads parseFloat('') -> NaN in jsdom; stub it to 0. + wrapper.vm.getContentPosition = () => ({ left: 0, right: 0, top: 0, bottom: 0, x: 0, y: 0 }); + wrapper.vm.init(); + + return wrapper; +}; + +describe('VirtualScroller.vue', () => { + it('should exist', () => { + const wrapper = mountScroller(); + + expect(wrapper.find('.p-virtualscroller').exists()).toBe(true); + }); + + describe('uniform-size path (getItemSize not provided)', () => { + it('should not build cumulative sizes', () => { + const wrapper = mountScroller(); + + expect(wrapper.vm.cumulativeSizes).toBe(null); + }); + + it('should compute spacer height from items.length * itemSize', () => { + const wrapper = mountScroller(); + + expect(wrapper.vm.spacerStyle.height).toBe('600px'); + }); + + it('should compute index from scrollTop via floor division', () => { + const wrapper = mountScroller(); + + expect(wrapper.vm.getIndexAtOffset(0)).toBe(0); + expect(wrapper.vm.getIndexAtOffset(90)).toBe(3); + expect(wrapper.vm.getIndexAtOffset(599)).toBe(19); + }); + }); + + describe('variable-size path (getItemSize provided)', () => { + // header on every fifth row: sizes 80, 30, 30, 30, 30, 80, 30, ... + const stepSize = (index) => (index % 5 === 0 ? 80 : 30); + + it('should build cumulative sizes from getItemSize', () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + const sizes = wrapper.vm.cumulativeSizes; + + expect(sizes.length).toBe(21); + expect(sizes[0]).toBe(0); + expect(sizes[1]).toBe(80); + expect(sizes[5]).toBe(80 + 30 * 4); + expect(sizes[20]).toBe(80 * 4 + 30 * 16); + }); + + it('should compute spacer height from cumulative total', () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + expect(wrapper.vm.spacerStyle.height).toBe(`${80 * 4 + 30 * 16}px`); + }); + + it('getItemOffset should return the y-coordinate of items[index]', () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + expect(wrapper.vm.getItemOffset(0)).toBe(0); + expect(wrapper.vm.getItemOffset(1)).toBe(80); + expect(wrapper.vm.getItemOffset(5)).toBe(80 + 30 * 4); + }); + + it('getIndexAtOffset should binary-search the cumulative array', () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + expect(wrapper.vm.getIndexAtOffset(0)).toBe(0); + expect(wrapper.vm.getIndexAtOffset(79)).toBe(0); + expect(wrapper.vm.getIndexAtOffset(80)).toBe(1); + expect(wrapper.vm.getIndexAtOffset(199)).toBe(4); + expect(wrapper.vm.getIndexAtOffset(200)).toBe(5); + expect(wrapper.vm.getIndexAtOffset(-50)).toBe(0); + expect(wrapper.vm.getIndexAtOffset(99999)).toBe(19); + }); + + it('should rebuild cumulative sizes when getItemSize reference changes', async () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + expect(wrapper.vm.spacerStyle.height).toBe(`${80 * 4 + 30 * 16}px`); + + await wrapper.setProps({ getItemSize: () => 40 }); + wrapper.vm.init(); + + expect(wrapper.vm.spacerStyle.height).toBe('800px'); + }); + + it('should fall back to uniform math when getItemSize is removed', async () => { + const wrapper = mountScroller({ getItemSize: stepSize }); + + expect(wrapper.vm.cumulativeSizes).not.toBe(null); + + await wrapper.setProps({ getItemSize: null }); + wrapper.vm.init(); + + expect(wrapper.vm.cumulativeSizes).toBe(null); + expect(wrapper.vm.spacerStyle.height).toBe('600px'); + }); + }); +}); diff --git a/packages/primevue/src/virtualscroller/VirtualScroller.vue b/packages/primevue/src/virtualscroller/VirtualScroller.vue index d5b9dc2784..30a8b2db7e 100644 --- a/packages/primevue/src/virtualscroller/VirtualScroller.vue +++ b/packages/primevue/src/virtualscroller/VirtualScroller.vue @@ -71,6 +71,7 @@ export default { }, element: null, content: null, + cumulativeSizes: null, lastScrollPos: null, scrollTimeout: null, resizeTimeout: null, @@ -105,6 +106,10 @@ export default { this.init(); this.calculateAutoSize(); }, + getItemSize() { + this.init(); + this.calculateAutoSize(); + }, orientation() { this.lastScrollPos = this.isBoth() ? { top: 0, left: 0 } : 0; }, @@ -152,10 +157,84 @@ export default { init() { if (!this.disabled) { this.setSize(); + this.buildCumulativeSizes(); this.calculateOptions(); this.setSpacerSize(); } }, + buildCumulativeSizes() { + if (typeof this.getItemSize !== 'function' || this.isBoth() || this.isHorizontal()) { + this.cumulativeSizes = null; + + return; + } + + const items = this.items || []; + const sizes = new Array(items.length + 1); + + sizes[0] = 0; + + for (let i = 0; i < items.length; i++) { + sizes[i + 1] = sizes[i] + this.getItemSize(i); + } + + this.cumulativeSizes = sizes; + }, + getItemOffset(index) { + if (this.cumulativeSizes) { + const i = Math.max(0, Math.min(index, this.cumulativeSizes.length - 1)); + + return this.cumulativeSizes[i]; + } + + return index * this.itemSize; + }, + getIndexAtOffset(offset) { + if (!this.cumulativeSizes) { + return Math.floor(offset / (this.itemSize || offset)); + } + + const sizes = this.cumulativeSizes; + + if (offset <= 0) return 0; + + const last = sizes.length - 1; + + if (offset >= sizes[last]) return Math.max(0, last - 1); + + let lo = 0; + let hi = last; + + while (lo < hi) { + const mid = (lo + hi) >>> 1; + + if (sizes[mid + 1] <= offset) lo = mid + 1; + else hi = mid; + } + + return lo; + }, + getLastByCumulative(first, numToleratedItems) { + if (!this.cumulativeSizes) return this.getLast(first); + + const sizes = this.cumulativeSizes; + const contentPos = this.getContentPosition(); + const contentHeight = this.element ? this.element.offsetHeight - contentPos.top : 0; + const startOffset = sizes[Math.min(first, sizes.length - 1)]; + const target = startOffset + contentHeight + 2 * numToleratedItems * this.itemSize; + + let lo = first; + let hi = sizes.length - 1; + + while (lo < hi) { + const mid = (lo + hi) >>> 1; + + if (sizes[mid] < target) lo = mid + 1; + else hi = mid; + } + + return lo; + }, isVertical() { return this.orientation === 'vertical'; }, @@ -194,7 +273,15 @@ export default { isRangeChanged = newFirst.rows !== first.rows || newFirst.cols !== first.cols; } else { newFirst = calculateFirst(index, numToleratedItems); - horizontal ? scrollTo(calculateCoord(newFirst, itemSize, contentPos.left), scrollTop) : scrollTo(scrollLeft, calculateCoord(newFirst, itemSize, contentPos.top)); + + if (horizontal) { + scrollTo(calculateCoord(newFirst, itemSize, contentPos.left), scrollTop); + } else if (this.cumulativeSizes) { + scrollTo(scrollLeft, this.getItemOffset(newFirst) + contentPos.top); + } else { + scrollTo(scrollLeft, calculateCoord(newFirst, itemSize, contentPos.top)); + } + isScrollChanged = this.lastScrollPos !== (horizontal ? scrollLeft : scrollTop); isRangeChanged = newFirst !== first; } @@ -224,7 +311,8 @@ export default { } } else { if (viewport.first - first > index) { - const pos = (viewport.first - 1) * this.itemSize; + const target = viewport.first - 1; + const pos = !horizontal && this.cumulativeSizes ? this.getItemOffset(target) : target * this.itemSize; horizontal ? scrollTo(pos, 0) : scrollTo(0, pos); } @@ -238,7 +326,8 @@ export default { } } else { if (viewport.last - first <= index + 1) { - const pos = (viewport.first + 1) * this.itemSize; + const target = viewport.first + 1; + const pos = !horizontal && this.cumulativeSizes ? this.getItemOffset(target) : target * this.itemSize; horizontal ? scrollTo(pos, 0) : scrollTo(0, pos); } @@ -266,7 +355,7 @@ export default { } else { const scrollPos = horizontal ? scrollLeft : scrollTop; - firstInViewport = calculateFirstInViewport(scrollPos, this.itemSize); + firstInViewport = !horizontal && this.cumulativeSizes ? this.getIndexAtOffset(scrollPos) : calculateFirstInViewport(scrollPos, this.itemSize); lastInViewport = firstInViewport + this.numItemsInViewport; } } @@ -299,12 +388,19 @@ export default { }, calculateOptions() { const both = this.isBoth(); + const horizontal = this.isHorizontal(); const first = this.first; const { numItemsInViewport, numToleratedItems } = this.calculateNumItems(); const calculateLast = (_first, _num, _numT, _isCols = false) => this.getLast(_first + _num + (_first < _numT ? 2 : 3) * _numT, _isCols); - const last = both - ? { rows: calculateLast(first.rows, numItemsInViewport.rows, numToleratedItems[0]), cols: calculateLast(first.cols, numItemsInViewport.cols, numToleratedItems[1], true) } - : calculateLast(first, numItemsInViewport, numToleratedItems); + let last; + + if (both) { + last = { rows: calculateLast(first.rows, numItemsInViewport.rows, numToleratedItems[0]), cols: calculateLast(first.cols, numItemsInViewport.cols, numToleratedItems[1], true) }; + } else if (!horizontal && this.cumulativeSizes) { + last = this.getLastByCumulative(first, numToleratedItems); + } else { + last = calculateLast(first, numItemsInViewport, numToleratedItems); + } this.last = last; this.numItemsInViewport = numItemsInViewport; @@ -396,12 +492,17 @@ export default { const horizontal = this.isHorizontal(); const contentPos = this.getContentPosition(); const setProp = (_name, _value, _size, _cpos = 0) => (this.spacerStyle = { ...this.spacerStyle, ...{ [`${_name}`]: (_value || []).length * _size + _cpos + 'px' } }); + const setVerticalSize = (_cpos = 0) => (this.spacerStyle = { ...this.spacerStyle, height: this.cumulativeSizes[this.cumulativeSizes.length - 1] + _cpos + 'px' }); if (both) { setProp('height', items, this.itemSize[0], contentPos.y); setProp('width', this.columns || items[1], this.itemSize[1], contentPos.x); + } else if (horizontal) { + setProp('width', this.columns || items, this.itemSize, contentPos.x); + } else if (this.cumulativeSizes) { + setVerticalSize(contentPos.y); } else { - horizontal ? setProp('width', this.columns || items, this.itemSize, contentPos.x) : setProp('height', items, this.itemSize, contentPos.y); + setProp('height', items, this.itemSize, contentPos.y); } } }, @@ -415,10 +516,10 @@ export default { if (both) { setTransform(calculateTranslateVal(first.cols, this.itemSize[1]), calculateTranslateVal(first.rows, this.itemSize[0])); + } else if (horizontal) { + setTransform(calculateTranslateVal(first, this.itemSize), 0); } else { - const translateVal = calculateTranslateVal(first, this.itemSize); - - horizontal ? setTransform(translateVal, 0) : setTransform(0, translateVal); + setTransform(0, this.cumulativeSizes ? this.getItemOffset(first) : calculateTranslateVal(first, this.itemSize)); } } }, @@ -488,11 +589,11 @@ export default { const isScrollDownOrRight = this.lastScrollPos <= scrollPos; if (!this.appendOnly || (this.appendOnly && isScrollDownOrRight)) { - const currentIndex = calculateCurrentIndex(scrollPos, this.itemSize); + const currentIndex = !horizontal && this.cumulativeSizes ? this.getIndexAtOffset(scrollPos) : calculateCurrentIndex(scrollPos, this.itemSize); const triggerIndex = calculateTriggerIndex(currentIndex, this.first, this.last, this.numItemsInViewport, this.d_numToleratedItems, isScrollDownOrRight); newFirst = calculateFirst(currentIndex, triggerIndex, this.first, this.last, this.numItemsInViewport, this.d_numToleratedItems, isScrollDownOrRight); - newLast = calculateLast(currentIndex, newFirst, this.last, this.numItemsInViewport, this.d_numToleratedItems); + newLast = !horizontal && this.cumulativeSizes ? this.getLastByCumulative(newFirst, this.d_numToleratedItems) : calculateLast(currentIndex, newFirst, this.last, this.numItemsInViewport, this.d_numToleratedItems); isRangeChanged = newFirst !== this.first || newLast !== this.last || this.isRangeChanged; newScrollPos = scrollPos; }