Skip to content
Merged
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 .changeset/smooth-horizontal-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prisma/studio-core": patch
---

Fix broken horizontal scrolling in wide data tables. The column virtualization window now follows scrolling synchronously and only re-renders the grid when the set of mounted columns changes, so scrolling no longer jumps between columns and the last column is reachable. Focused-cell auto-scroll now runs at most once per focus change, so clicking a cell to edit no longer snaps the viewport back and off-screen focus targets no longer fight user scrolling. Also corrects the virtualization window offset when columns are pinned.
25 changes: 25 additions & 0 deletions Architecture/wide-grid-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@ Expected impact: lower baseline render cost.

Expected impact: prevents future regressions.

## Column Virtualization Update Model (implemented)

Center-column virtualization is driven by scroll events, not render-time state:

- The virtualization window (`computeColumnVirtualizationWindow`) is computed
inside the scroll/resize handlers from the live `scrollLeft`/`clientWidth`
of the grid scroll container, synchronously and without an animation-frame
hop. Deferring the window behind `requestAnimationFrame` plus a raw
scroll-position state update let fast scrolling outrun the overscan area and
caused blank columns and jumpy repaints.
- Only the resolved window (`startIndex`/`endIndex`/spacer widths) is stored
in React state, and state is only updated when the window actually changes.
Plain scrolling inside the overscan area therefore never re-renders the
grid.
- The window is computed in center-column coordinates. Left-pinned columns
occupy the start of the scrollable row and overlay the same amount of
viewport width (they are sticky), so the container `scrollLeft` maps 1:1
onto center-column offsets and must not be shifted by the pinned width.
- Focused-cell auto-scroll runs at most once per focused-cell change. The
focused column can be outside the mounted window (its cell element does not
exist), so retrying until the element appears would re-apply the computed
scroll position on every scroll update and fight user scrolling. Vertical
reveal falls back to any rendered cell of the focused row because rows are
never virtualized.

## Rollout Plan

1. Implement column virtualization for center (non-pinned) columns.
Expand Down
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ Table data is shown in a grid with server-backed pagination, filtered-row counts
The footer keeps page navigation, a page jump field, a fixed rows-per-page dropdown, and infinite-scroll mode in one compact control group, so users can either jump directly to a page, switch page density from a known preset, or turn on lazy-loading without leaving the grid.
Rows-per-page and infinite-scroll preferences persist across tables through local storage, while the known filtered-row count keeps the footer stable during page transitions for the same filtered result set. Infinite scroll preloads before the hard bottom edge, always appends in fixed 25-row chunks regardless of the paginated page-size setting, keeps filling tall viewports until the grid is actually scrollable, and appends new rows in place without snapping the grid back to the top.
Rapid sort and filter changes keep the latest request authoritative, and superseded table reads are aborted so a slower older result cannot overwrite the visible grid.
Wide tables virtualize their non-pinned columns so only the columns near the viewport are mounted. The virtualization window follows horizontal scrolling synchronously and grid re-renders only happen when the set of mounted columns actually changes, which keeps horizontal scrolling smooth, makes the last column reachable, and lets cell focus changes reveal off-screen columns without fighting user-initiated scrolling.

## PostgreSQL Stored Temporal Values

Expand Down
184 changes: 184 additions & 0 deletions ui/studio/grid/DataGrid.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,190 @@ describe("DataGrid interactions", () => {
}
});

it("auto-scrolls once and never fights user scrolling when the focused column is outside the virtualization window", async () => {
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"clientWidth",
);

Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get() {
return 400;
},
});

let cleanupGrid: (() => void) | null = null;

try {
const columnIds = Array.from(
{ length: 20 },
(_, index) => `col_${index}`,
);
const row: GridRow = { __ps_rowid: "row_1" };

for (const columnId of columnIds) {
row[columnId] = `${columnId} value`;
}

const { cleanup, container, setFocusedCell } = renderGrid({
columnDefs: createReadOnlyColumns({ columnIds }),
focusedCell: null,
manageFocusedCell: true,
rows: [row],
});
cleanupGrid = cleanup;

const scrollContainer = container.querySelector(
'[data-grid-scroll-container="true"]',
);

if (!(scrollContainer instanceof HTMLDivElement)) {
throw new Error("Could not find table scroll container");
}

await flushMicrotasks();

// The virtualization window at scrollLeft 0 with a 400px viewport only
// renders the first few 200px columns, so col_10 has no cell element.
expect(
container.querySelector('td[data-grid-column-id="col_10"]'),
).toBeNull();

// Focus a cell whose element never renders: the column sits outside
// the virtualization window and the visual row index is not on the
// current page.
setFocusedCell({
columnId: "col_10",
rowIndex: 5,
});
await flushMicrotasks();

// Focusing the off-window column auto-scrolls once to reveal it:
// columnEnd (11 * 200) minus the 400px viewport.
expect(scrollContainer.scrollLeft).toBe(1800);

act(() => {
scrollContainer.scrollLeft = 2600;
scrollContainer.dispatchEvent(new Event("scroll"));
});
await flushMicrotasks();

// Subsequent user scrolling must win even though the focused cell was
// not rendered when the auto-scroll ran.
expect(scrollContainer.scrollLeft).toBe(2600);

act(() => {
scrollContainer.scrollLeft = 3600;
scrollContainer.dispatchEvent(new Event("scroll"));
});
await flushMicrotasks();

expect(scrollContainer.scrollLeft).toBe(3600);
} finally {
cleanupGrid?.();

if (clientWidthDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"clientWidth",
clientWidthDescriptor,
);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
}
}
});

it("runs the focused-cell auto-scroll once the grid becomes measurable after being hidden", async () => {
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"clientWidth",
);
let mockedClientWidth = 0;

Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get() {
return mockedClientWidth;
},
});

let cleanupGrid: (() => void) | null = null;

try {
const columnIds = Array.from(
{ length: 20 },
(_, index) => `col_${index}`,
);
const row: GridRow = { __ps_rowid: "row_1" };

for (const columnId of columnIds) {
row[columnId] = `${columnId} value`;
}

const { cleanup, container, setFocusedCell } = renderGrid({
columnDefs: createReadOnlyColumns({ columnIds }),
focusedCell: null,
manageFocusedCell: true,
rows: [row],
});
cleanupGrid = cleanup;

const scrollContainer = container.querySelector(
'[data-grid-scroll-container="true"]',
);

if (!(scrollContainer instanceof HTMLDivElement)) {
throw new Error("Could not find table scroll container");
}

await flushMicrotasks();

// Focus arrives while the grid is hidden (clientWidth 0), so the
// auto-scroll cannot run yet and must stay pending.
setFocusedCell({
columnId: "col_10",
rowIndex: 0,
});
await flushMicrotasks();

expect(scrollContainer.scrollLeft).toBe(0);

// The grid becomes visible and layout observers fire.
mockedClientWidth = 400;
act(() => {
window.dispatchEvent(new Event("resize"));
});
await flushMicrotasks();

// The pending focused-cell auto-scroll now runs exactly once:
// columnEnd (11 * 200) minus the 400px viewport.
expect(scrollContainer.scrollLeft).toBe(1800);

// Subsequent user scrolling still wins.
act(() => {
scrollContainer.scrollLeft = 2600;
scrollContainer.dispatchEvent(new Event("scroll"));
});
await flushMicrotasks();

expect(scrollContainer.scrollLeft).toBe(2600);
} finally {
cleanupGrid?.();

if (clientWidthDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"clientWidth",
clientWidthDescriptor,
);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
}
}
});

it("loads more rows when infinite scroll reaches the bottom threshold", async () => {
const onLoadMoreRows = vi.fn();
const { cleanup, container } = renderGrid({
Expand Down
Loading