Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We added a "Custom row key" property in the Advanced section to provide stable row identifiers when using view entities, preventing scroll position loss during data refresh.

### Fixed

- We fixed an issue where the vertical scrollbar disappeared after hiding a wide column while virtual scrolling was enabled.
- We fixed an issue where only the first page loaded when the grid had enough columns to require horizontal scrolling.

## [3.9.0] - 2026-03-23

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ const _01_coreBindings: BindingGroup = {
DG.exportProgressService,
SA_TOKENS.selectionDialogVM
);
injected(GridSizeStore, CORE.atoms.hasMoreItems, DG.paginationConfig, DG.setPageAction, DG.pageSize);
injected(
GridSizeStore,
CORE.atoms.hasMoreItems,
DG.paginationConfig,
DG.setPageAction,
DG.pageSize,
CORE.atoms.visibleColumnsCount
);
},
define(container: Container) {
container.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { renderHook, act } from "@testing-library/react";
import { createRef, UIEvent } from "react";

jest.mock("@mendix/widget-plugin-hooks/useOnScreen", () => ({
useOnScreen: () => true
}));

const mockBumpPage = jest.fn();
const mockLockGridContainerHeight = jest.fn();
const mockGridContainerRef = createRef<HTMLDivElement>();

jest.mock("../injection-hooks", () => ({
useGridSizeStore: () => ({
hasVirtualScrolling: true,
gridContainerRef: mockGridContainerRef,
bumpPage: mockBumpPage,
lockGridContainerHeight: mockLockGridContainerHeight
})
}));

import { useInfiniteControl } from "../useInfiniteControl";
import { VIRTUAL_SCROLLING_OFFSET } from "../../stores/GridSize.store";

function makeScrollEvent(overrides: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
scrollLeft?: number;
}): UIEvent<HTMLDivElement> {
const { scrollTop, scrollHeight, clientHeight, scrollLeft = 0 } = overrides;
return {
target: { scrollTop, scrollHeight, clientHeight, scrollLeft }
} as unknown as UIEvent<HTMLDivElement>;
}

describe("useInfiniteControl — trackTableScrolling", () => {
beforeEach(() => {
jest.useFakeTimers();
mockBumpPage.mockClear();
});

afterEach(() => {
jest.useRealTimers();
});

it("does not call bumpPage when scrollTop is 0, even if scroll position satisfies bottom condition", () => {
const { result } = renderHook(() => useInfiniteControl());
const [trackTableScrolling] = result.current;

// scrollTop === 0 → guard blocks bumpPage regardless of height math
const e = makeScrollEvent({
scrollTop: 0,
scrollHeight: 500,
clientHeight: 500
});

act(() => {
trackTableScrolling!(e);
});

expect(mockBumpPage).not.toHaveBeenCalled();
});

it("calls bumpPage when scrollTop > 0 and scroll position reaches the bottom", () => {
const { result } = renderHook(() => useInfiniteControl());
const [trackTableScrolling] = result.current;

// scrollTop > 0 and Math.floor(scrollHeight - OFFSET - scrollTop) <= clientHeight + 2
const scrollHeight = 500;
const clientHeight = 400;
const scrollTop = scrollHeight - VIRTUAL_SCROLLING_OFFSET - clientHeight; // exactly at bottom

const e = makeScrollEvent({ scrollTop, scrollHeight, clientHeight });

act(() => {
trackTableScrolling!(e);
});

expect(mockBumpPage).toHaveBeenCalledTimes(1);
});

it("does not call bumpPage when scrolled but not yet at the bottom", () => {
const { result } = renderHook(() => useInfiniteControl());
const [trackTableScrolling] = result.current;

const e = makeScrollEvent({
scrollTop: 10,
scrollHeight: 500,
clientHeight: 100
});

act(() => {
trackTableScrolling!(e);
});

expect(mockBumpPage).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export function useInfiniteControl(): [trackTableScrolling: ((e: any) => void) |
* causing mismatch by 1 pixel point, thus, add magic number 2 as buffer.
*/
const bottom =
target.scrollTop > 0 &&
Math.floor(target.scrollHeight - VIRTUAL_SCROLLING_OFFSET - target.scrollTop) <=
Math.floor(target.clientHeight) + 2;
Math.floor(target.clientHeight) + 2;
if (bottom) {
gridSizeStore.bumpPage();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,30 @@ export class GridSizeStore {

gridContainerHeight?: number;

private lockedAtPageSize?: number;
private lockedAtLayoutKey?: string;

constructor(
private readonly hasMoreItemsAtom: ComputedAtom<boolean | undefined>,
private readonly paginationConfig: PaginationConfig,
private readonly setPageAction: SetPageAction,
private readonly pageSizeAtom: ComputedAtom<number>
private readonly pageSizeAtom: ComputedAtom<number>,
private readonly visibleColumnsCountAtom: ComputedAtom<number>
) {
makeAutoObservable<GridSizeStore, "lockedAtPageSize">(this, {
makeAutoObservable<GridSizeStore, "lockedAtLayoutKey">(this, {
gridContainerRef: false,
gridBodyRef: false,
gridHeaderRef: false,
lockedAtPageSize: false,
lockedAtLayoutKey: false,

gridContainerHeight: observable,
lockGridContainerHeight: action
});
}

private get layoutKey(): string {
return `${this.pageSizeAtom.get()}-${this.visibleColumnsCountAtom.get()}`;
}

get hasMoreItems(): boolean {
return this.hasMoreItemsAtom.get() ?? false;
}
Expand Down Expand Up @@ -80,37 +85,33 @@ export class GridSizeStore {
return;
}

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

const gridContainer = this.gridContainerRef.current;
if (!gridContainer || this.gridContainerHeight !== undefined) {
if (!gridContainer || this.lockedAtLayoutKey !== undefined) {
return;
}

const bodyViewportHeight = this.computeBodyViewport();
const headerViewportHeight = this.computeHeaderViewport();

// Don't lock height before the grid body has rendered content.
// clientHeight is 0 when the element has no layout yet, which would
// produce a negative height and break scrolling.
// Don't lock before the grid body has rendered content — clientHeight is 0
// before layout, which would produce a negative height and break scrolling.
if (bodyViewportHeight <= 0) {
return;
}

const fullHeight = bodyViewportHeight + headerViewportHeight;

// If content already overflows the container (fixed-height grid), do not subtract the
// pre-fetch offset — that would hide the last rows and trigger the next page too early.
// Only subtract the offset when the grid does not yet overflow (auto-height grid) so
// that we create a small synthetic overflow that makes the body scrollable.
const overflows = gridContainer.scrollHeight > fullHeight;
// clientHeight is vertical-only (excludes horizontal scrollbar overflow).
// scrollHeight would be inflated by many-column grids and produce false positives.
const overflows = gridContainer.clientHeight < fullHeight;
this.gridContainerHeight = fullHeight - (overflows ? 0 : VIRTUAL_SCROLLING_OFFSET);
this.lockedAtPageSize = currentPageSize;
this.lockedAtLayoutKey = currentKey;
}
}
Loading
Loading