Skip to content

Commit 0252366

Browse files
committed
fix: consolidate virtual scroll height lock (WC-3333, WC-3390)
- Replace parallel lockedAtX guards with single layoutKey cache - Keep old height alive on invalidation to prevent flicker - Fix overflow detection: clientHeight not scrollHeight — many-column grids now correctly receive the synthetic scroll offset - Guard bumpPage behind scrollTop > 0
1 parent 00441bb commit 0252366

4 files changed

Lines changed: 222 additions & 22 deletions

File tree

packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,14 @@ const _01_coreBindings: BindingGroup = {
7979
DG.exportProgressService,
8080
SA_TOKENS.selectionDialogVM
8181
);
82-
injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction, DG.pageSize);
82+
injected(
83+
GridSizeStore,
84+
CORE.atoms.hasMoreItems,
85+
DG.paginationConfig,
86+
DG.setPageAction,
87+
DG.pageSize,
88+
CORE.atoms.visibleColumnsCount
89+
);
8390
},
8491
define(container: Container) {
8592
container.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope();

packages/pluggableWidgets/datagrid-web/src/model/hooks/useInfiniteControl.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ export function useInfiniteControl(): [trackTableScrolling: ((e: any) => void) |
3232
* causing mismatch by 1 pixel point, thus, add magic number 2 as buffer.
3333
*/
3434
const bottom =
35+
target.scrollTop > 0 &&
3536
Math.floor(target.scrollHeight - VIRTUAL_SCROLLING_OFFSET - target.scrollTop) <=
36-
Math.floor(target.clientHeight) + 2;
37+
Math.floor(target.clientHeight) + 2;
3738
if (bottom) {
3839
gridSizeStore.bumpPage();
3940
}

packages/pluggableWidgets/datagrid-web/src/model/stores/GridSize.store.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,30 @@ export class GridSizeStore {
1313

1414
gridContainerHeight?: number;
1515

16-
private lockedAtPageSize?: number;
16+
private lockedAtLayoutKey?: string;
1717

1818
constructor(
1919
private readonly hasMoreItemsAtom: ComputedAtom<boolean | undefined>,
2020
private readonly paginationConfig: PaginationConfig,
2121
private readonly setPageAction: SetPageAction,
22-
private readonly pageSizeAtom: ComputedAtom<number>
22+
private readonly pageSizeAtom: ComputedAtom<number>,
23+
private readonly visibleColumnsCountAtom: ComputedAtom<number>
2324
) {
24-
makeAutoObservable<GridSizeStore, "lockedAtPageSize">(this, {
25+
makeAutoObservable<GridSizeStore, "lockedAtLayoutKey">(this, {
2526
gridContainerRef: false,
2627
gridBodyRef: false,
2728
gridHeaderRef: false,
28-
lockedAtPageSize: false,
29+
lockedAtLayoutKey: false,
2930

3031
gridContainerHeight: observable,
3132
lockGridContainerHeight: action
3233
});
3334
}
3435

36+
private get layoutKey(): string {
37+
return `${this.pageSizeAtom.get()}-${this.visibleColumnsCountAtom.get()}`;
38+
}
39+
3540
get hasMoreItems(): boolean {
3641
return this.hasMoreItemsAtom.get() ?? false;
3742
}
@@ -80,37 +85,33 @@ export class GridSizeStore {
8085
return;
8186
}
8287

83-
// Reset the locked height when page size changes so layout is recomputed
84-
// for the new number of rows (e.g. switching from 10 → 5 rows).
85-
const currentPageSize = this.pageSizeAtom.get();
86-
if (this.gridContainerHeight !== undefined && this.lockedAtPageSize !== currentPageSize) {
87-
this.gridContainerHeight = undefined;
88-
this.lockedAtPageSize = undefined;
88+
// Single cache key encodes all layout inputs. Any change (page size, column count,
89+
// or future inputs) invalidates the lock in one place.
90+
const currentKey = this.layoutKey;
91+
if (this.lockedAtLayoutKey !== currentKey) {
92+
this.lockedAtLayoutKey = undefined;
8993
}
9094

9195
const gridContainer = this.gridContainerRef.current;
92-
if (!gridContainer || this.gridContainerHeight !== undefined) {
96+
if (!gridContainer || this.lockedAtLayoutKey !== undefined) {
9397
return;
9498
}
9599

96100
const bodyViewportHeight = this.computeBodyViewport();
97101
const headerViewportHeight = this.computeHeaderViewport();
98102

99-
// Don't lock height before the grid body has rendered content.
100-
// clientHeight is 0 when the element has no layout yet, which would
101-
// produce a negative height and break scrolling.
103+
// Don't lock before the grid body has rendered content — clientHeight is 0
104+
// before layout, which would produce a negative height and break scrolling.
102105
if (bodyViewportHeight <= 0) {
103106
return;
104107
}
105108

106109
const fullHeight = bodyViewportHeight + headerViewportHeight;
107110

108-
// If content already overflows the container (fixed-height grid), do not subtract the
109-
// pre-fetch offset — that would hide the last rows and trigger the next page too early.
110-
// Only subtract the offset when the grid does not yet overflow (auto-height grid) so
111-
// that we create a small synthetic overflow that makes the body scrollable.
112-
const overflows = gridContainer.scrollHeight > fullHeight;
111+
// clientHeight is vertical-only (excludes horizontal scrollbar overflow).
112+
// scrollHeight would be inflated by many-column grids and produce false positives.
113+
const overflows = gridContainer.clientHeight < fullHeight;
113114
this.gridContainerHeight = fullHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET);
114-
this.lockedAtPageSize = currentPageSize;
115+
this.lockedAtLayoutKey = currentKey;
115116
}
116117
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { MutableRefObject } from "react";
2+
import { computed, configure, observable } from "mobx";
3+
import { SetPageAction } from "@mendix/widget-plugin-grid/pagination/main";
4+
import { PaginationConfig } from "../../../features/pagination/pagination.config";
5+
import { GridSizeStore, VIRTUAL_SCROLLING_OFFSET } from "../GridSize.store";
6+
7+
configure({ enforceActions: "never" });
8+
9+
function makePaginationConfig(pagination: "virtualScrolling" | "buttons" = "virtualScrolling"): PaginationConfig {
10+
return {
11+
pagination,
12+
pagingPosition: "bottom",
13+
paginationKind: "virtualScrolling.always",
14+
showPagingButtons: "always",
15+
showNumberOfRows: false,
16+
constPageSize: 10,
17+
initPageSize: 10,
18+
isLimitBased: false,
19+
dynamicPageSizeEnabled: false,
20+
dynamicPageEnabled: false,
21+
customPaginationEnabled: false,
22+
requestTotalCount: false
23+
};
24+
}
25+
26+
interface StoreOptions {
27+
hasMoreItems?: boolean;
28+
pageSize?: number;
29+
columnCount?: number;
30+
pagination?: "virtualScrolling" | "buttons";
31+
}
32+
33+
function buildStore(options: StoreOptions = {}): {
34+
store: GridSizeStore;
35+
hasMoreItemsBox: ReturnType<typeof observable.box<boolean | undefined>>;
36+
pageSizeBox: ReturnType<typeof observable.box<number>>;
37+
columnCountBox: ReturnType<typeof observable.box<number>>;
38+
setPageAction: jest.Mock;
39+
} {
40+
const { hasMoreItems = true, pageSize = 10, columnCount = 3, pagination = "virtualScrolling" } = options;
41+
42+
const hasMoreItemsBox = observable.box<boolean | undefined>(hasMoreItems);
43+
const pageSizeBox = observable.box(pageSize);
44+
const columnCountBox = observable.box(columnCount);
45+
const setPageAction = jest.fn() as unknown as SetPageAction;
46+
47+
const store = new GridSizeStore(
48+
computed(() => hasMoreItemsBox.get()),
49+
makePaginationConfig(pagination),
50+
setPageAction,
51+
computed(() => pageSizeBox.get()),
52+
computed(() => columnCountBox.get())
53+
);
54+
55+
return {
56+
store,
57+
hasMoreItemsBox,
58+
pageSizeBox,
59+
columnCountBox,
60+
setPageAction: setPageAction as unknown as jest.Mock
61+
};
62+
}
63+
64+
function createMockRows(count: number, rowHeight: number): HTMLElement[] {
65+
return Array.from({ length: count }, () => {
66+
const row = document.createElement("div");
67+
const cell = document.createElement("div");
68+
Object.defineProperty(cell, "clientHeight", { value: rowHeight, configurable: true });
69+
row.appendChild(cell);
70+
return row;
71+
});
72+
}
73+
74+
interface RefsOptions {
75+
rowHeight?: number;
76+
rowCount?: number;
77+
headerHeight?: number;
78+
containerClientHeight?: number;
79+
bodyScrollHeight?: number;
80+
bodyClientHeight?: number;
81+
}
82+
83+
function setupRefs(store: GridSizeStore, options: RefsOptions = {}): void {
84+
const {
85+
rowHeight = 40,
86+
rowCount = 10,
87+
headerHeight = 50,
88+
containerClientHeight = 1000,
89+
bodyScrollHeight,
90+
bodyClientHeight
91+
} = options;
92+
93+
const container = document.createElement("div");
94+
Object.defineProperty(container, "clientHeight", { value: containerClientHeight, configurable: true });
95+
(store.gridContainerRef as MutableRefObject<HTMLDivElement>).current = container;
96+
97+
const body = document.createElement("div");
98+
createMockRows(rowCount, rowHeight).forEach(r => body.appendChild(r));
99+
Object.defineProperty(body, "scrollHeight", {
100+
value: bodyScrollHeight ?? rowHeight * rowCount,
101+
configurable: true
102+
});
103+
Object.defineProperty(body, "clientHeight", {
104+
value: bodyClientHeight ?? containerClientHeight - headerHeight,
105+
configurable: true
106+
});
107+
(store.gridBodyRef as MutableRefObject<HTMLDivElement>).current = body;
108+
109+
const header = document.createElement("div");
110+
const th = document.createElement("div");
111+
th.className = "th";
112+
Object.defineProperty(th, "offsetHeight", { value: headerHeight, configurable: true });
113+
header.appendChild(th);
114+
(store.gridHeaderRef as MutableRefObject<HTMLDivElement>).current = header;
115+
}
116+
117+
describe("GridSizeStore.lockGridContainerHeight()", () => {
118+
it("locks container height on first call when virtual scrolling is active and hasMoreItems", () => {
119+
const { store } = buildStore({ pageSize: 3, columnCount: 2 });
120+
// bodyViewport = 3 rows × 40px = 120px, header = 50px → fullHeight = 170px
121+
// containerClientHeight (1000) >= fullHeight (170) → no overflow → subtract VIRTUAL_SCROLLING_OFFSET
122+
setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
123+
124+
store.lockGridContainerHeight();
125+
126+
expect(store.gridContainerHeight).toBe(170 - VIRTUAL_SCROLLING_OFFSET);
127+
});
128+
129+
it("is a no-op when hasVirtualScrolling is false", () => {
130+
const { store } = buildStore({ pagination: "buttons" });
131+
setupRefs(store);
132+
133+
store.lockGridContainerHeight();
134+
135+
expect(store.gridContainerHeight).toBeUndefined();
136+
});
137+
138+
it("is a no-op when hasMoreItems is false", () => {
139+
const { store } = buildStore({ hasMoreItems: false });
140+
setupRefs(store);
141+
142+
store.lockGridContainerHeight();
143+
144+
expect(store.gridContainerHeight).toBeUndefined();
145+
});
146+
147+
it("does not double-lock on repeated calls with same layout inputs", () => {
148+
const { store } = buildStore({ pageSize: 3, columnCount: 2 });
149+
setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
150+
151+
store.lockGridContainerHeight();
152+
const heightAfterFirst = store.gridContainerHeight;
153+
154+
// Mutate refs to simulate different measurements — if re-locking, height would change
155+
setupRefs(store, { rowHeight: 99, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
156+
store.lockGridContainerHeight();
157+
158+
expect(store.gridContainerHeight).toBe(heightAfterFirst);
159+
});
160+
161+
it("clears and recomputes height when page size changes", () => {
162+
const { store, pageSizeBox } = buildStore({ pageSize: 3, columnCount: 2 });
163+
setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
164+
store.lockGridContainerHeight();
165+
const heightWithPageSize3 = store.gridContainerHeight;
166+
167+
pageSizeBox.set(5);
168+
setupRefs(store, { rowHeight: 40, rowCount: 5, headerHeight: 50, containerClientHeight: 1000 });
169+
store.lockGridContainerHeight();
170+
171+
// bodyViewport = 5 × 40 = 200, header = 50 → fullHeight = 250, no overflow → subtract offset
172+
expect(store.gridContainerHeight).toBe(250 - VIRTUAL_SCROLLING_OFFSET);
173+
expect(store.gridContainerHeight).not.toBe(heightWithPageSize3);
174+
});
175+
176+
it("clears and recomputes height when visible column count changes", () => {
177+
const { store, columnCountBox } = buildStore({ pageSize: 3, columnCount: 2 });
178+
setupRefs(store, { rowHeight: 40, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
179+
store.lockGridContainerHeight();
180+
const heightWith2Columns = store.gridContainerHeight;
181+
182+
columnCountBox.set(1);
183+
// Simulate rows getting shorter after hiding a column
184+
setupRefs(store, { rowHeight: 20, rowCount: 3, headerHeight: 50, containerClientHeight: 1000 });
185+
store.lockGridContainerHeight();
186+
187+
// bodyViewport = 3 × 20 = 60, header = 50 → fullHeight = 110, no overflow → subtract offset
188+
expect(store.gridContainerHeight).toBe(110 - VIRTUAL_SCROLLING_OFFSET);
189+
expect(store.gridContainerHeight).not.toBe(heightWith2Columns);
190+
});
191+
});

0 commit comments

Comments
 (0)