Skip to content

Commit 6c9e625

Browse files
sorenbsclaude
andauthored
fix: smooth horizontal scrolling in wide data tables (#1544)
* fix: smooth horizontal scrolling in wide data tables Fixes #1476 (duplicate: #1514). Three defects in center-column virtualization made horizontal scrolling in wide tables jumpy, made the last column unreachable, and snapped the viewport left when clicking a cell: - The virtualization window was derived from a raw scroll-position state that was updated behind a requestAnimationFrame hop, so every scrolled pixel re-rendered the whole grid while the mounted window lagged the real scroll position, showing blank columns and jumpy repaints. The window is now computed synchronously in the scroll/resize handlers and stored in state only when the set of mounted columns actually changes. - The focused-cell auto-scroll effect re-ran on every scroll update while the focused cell's element was not rendered (focused column outside the virtualization window, or focused row not on the current page), re-applying a column-aligned scrollLeft each time and fighting user scrolling. It now runs at most once per focused-cell change and falls back to any rendered cell in the focused row for the vertical reveal. - The window was computed from scrollLeft shifted left by the pinned-column width, under-rendering columns at the right viewport edge. Sticky pinned columns occupy as much viewport as they occupy row start, so container scrollLeft maps 1:1 onto center-column offsets. Verified in the ppg demo against the 61-column all_data_types table: the window follows scrolling to the very end, the last column renders fully, and clicking or focusing cells no longer moves the viewport. Regression tests cover the auto-scroll fight, animation-frame-free window updates, and end-of-grid window coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address PR review feedback on virtualization key and pending focus - Serialize the center-column virtualization inputs key structurally with JSON.stringify instead of delimiter joining, so column ids containing the delimiter characters cannot produce colliding keys. - Keep a focused-cell auto-scroll pending while its prerequisites are missing (no scroll container, unknown column, or an unmeasured/hidden grid) instead of marking it handled up-front. Column changes and a new viewport-readiness tick re-trigger the effect when layout becomes ready; neither signal changes on plain scrolling, so the at-most-once-per-focus guarantee against fighting user scrolling is preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent df3f210 commit 6c9e625

8 files changed

Lines changed: 517 additions & 85 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": patch
3+
---
4+
5+
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.

Architecture/wide-grid-performance.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@ Expected impact: lower baseline render cost.
6060

6161
Expected impact: prevents future regressions.
6262

63+
## Column Virtualization Update Model (implemented)
64+
65+
Center-column virtualization is driven by scroll events, not render-time state:
66+
67+
- The virtualization window (`computeColumnVirtualizationWindow`) is computed
68+
inside the scroll/resize handlers from the live `scrollLeft`/`clientWidth`
69+
of the grid scroll container, synchronously and without an animation-frame
70+
hop. Deferring the window behind `requestAnimationFrame` plus a raw
71+
scroll-position state update let fast scrolling outrun the overscan area and
72+
caused blank columns and jumpy repaints.
73+
- Only the resolved window (`startIndex`/`endIndex`/spacer widths) is stored
74+
in React state, and state is only updated when the window actually changes.
75+
Plain scrolling inside the overscan area therefore never re-renders the
76+
grid.
77+
- The window is computed in center-column coordinates. Left-pinned columns
78+
occupy the start of the scrollable row and overlay the same amount of
79+
viewport width (they are sticky), so the container `scrollLeft` maps 1:1
80+
onto center-column offsets and must not be shifted by the pinned width.
81+
- Focused-cell auto-scroll runs at most once per focused-cell change. The
82+
focused column can be outside the mounted window (its cell element does not
83+
exist), so retrying until the element appears would re-apply the computed
84+
scroll position on every scroll update and fight user scrolling. Vertical
85+
reveal falls back to any rendered cell of the focused row because rows are
86+
never virtualized.
87+
6388
## Rollout Plan
6489

6590
1. Implement column virtualization for center (non-pinned) columns.

FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ The footer keeps page navigation, a page jump field, a fixed rows-per-page dropd
185185
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.
186186
Row editing, deletion, and insertion operate on the same loaded row window the grid displays, so with infinite scroll enabled staged cell edits save correctly even for rows loaded beyond the first 25-row batch.
187187
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.
188+
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.
188189

189190
## Table Row Count Display
190191

ui/studio/grid/DataGrid.interactions.test.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,190 @@ describe("DataGrid interactions", () => {
693693
}
694694
});
695695

696+
it("auto-scrolls once and never fights user scrolling when the focused column is outside the virtualization window", async () => {
697+
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(
698+
HTMLElement.prototype,
699+
"clientWidth",
700+
);
701+
702+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
703+
configurable: true,
704+
get() {
705+
return 400;
706+
},
707+
});
708+
709+
let cleanupGrid: (() => void) | null = null;
710+
711+
try {
712+
const columnIds = Array.from(
713+
{ length: 20 },
714+
(_, index) => `col_${index}`,
715+
);
716+
const row: GridRow = { __ps_rowid: "row_1" };
717+
718+
for (const columnId of columnIds) {
719+
row[columnId] = `${columnId} value`;
720+
}
721+
722+
const { cleanup, container, setFocusedCell } = renderGrid({
723+
columnDefs: createReadOnlyColumns({ columnIds }),
724+
focusedCell: null,
725+
manageFocusedCell: true,
726+
rows: [row],
727+
});
728+
cleanupGrid = cleanup;
729+
730+
const scrollContainer = container.querySelector(
731+
'[data-grid-scroll-container="true"]',
732+
);
733+
734+
if (!(scrollContainer instanceof HTMLDivElement)) {
735+
throw new Error("Could not find table scroll container");
736+
}
737+
738+
await flushMicrotasks();
739+
740+
// The virtualization window at scrollLeft 0 with a 400px viewport only
741+
// renders the first few 200px columns, so col_10 has no cell element.
742+
expect(
743+
container.querySelector('td[data-grid-column-id="col_10"]'),
744+
).toBeNull();
745+
746+
// Focus a cell whose element never renders: the column sits outside
747+
// the virtualization window and the visual row index is not on the
748+
// current page.
749+
setFocusedCell({
750+
columnId: "col_10",
751+
rowIndex: 5,
752+
});
753+
await flushMicrotasks();
754+
755+
// Focusing the off-window column auto-scrolls once to reveal it:
756+
// columnEnd (11 * 200) minus the 400px viewport.
757+
expect(scrollContainer.scrollLeft).toBe(1800);
758+
759+
act(() => {
760+
scrollContainer.scrollLeft = 2600;
761+
scrollContainer.dispatchEvent(new Event("scroll"));
762+
});
763+
await flushMicrotasks();
764+
765+
// Subsequent user scrolling must win even though the focused cell was
766+
// not rendered when the auto-scroll ran.
767+
expect(scrollContainer.scrollLeft).toBe(2600);
768+
769+
act(() => {
770+
scrollContainer.scrollLeft = 3600;
771+
scrollContainer.dispatchEvent(new Event("scroll"));
772+
});
773+
await flushMicrotasks();
774+
775+
expect(scrollContainer.scrollLeft).toBe(3600);
776+
} finally {
777+
cleanupGrid?.();
778+
779+
if (clientWidthDescriptor) {
780+
Object.defineProperty(
781+
HTMLElement.prototype,
782+
"clientWidth",
783+
clientWidthDescriptor,
784+
);
785+
} else {
786+
Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
787+
}
788+
}
789+
});
790+
791+
it("runs the focused-cell auto-scroll once the grid becomes measurable after being hidden", async () => {
792+
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(
793+
HTMLElement.prototype,
794+
"clientWidth",
795+
);
796+
let mockedClientWidth = 0;
797+
798+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
799+
configurable: true,
800+
get() {
801+
return mockedClientWidth;
802+
},
803+
});
804+
805+
let cleanupGrid: (() => void) | null = null;
806+
807+
try {
808+
const columnIds = Array.from(
809+
{ length: 20 },
810+
(_, index) => `col_${index}`,
811+
);
812+
const row: GridRow = { __ps_rowid: "row_1" };
813+
814+
for (const columnId of columnIds) {
815+
row[columnId] = `${columnId} value`;
816+
}
817+
818+
const { cleanup, container, setFocusedCell } = renderGrid({
819+
columnDefs: createReadOnlyColumns({ columnIds }),
820+
focusedCell: null,
821+
manageFocusedCell: true,
822+
rows: [row],
823+
});
824+
cleanupGrid = cleanup;
825+
826+
const scrollContainer = container.querySelector(
827+
'[data-grid-scroll-container="true"]',
828+
);
829+
830+
if (!(scrollContainer instanceof HTMLDivElement)) {
831+
throw new Error("Could not find table scroll container");
832+
}
833+
834+
await flushMicrotasks();
835+
836+
// Focus arrives while the grid is hidden (clientWidth 0), so the
837+
// auto-scroll cannot run yet and must stay pending.
838+
setFocusedCell({
839+
columnId: "col_10",
840+
rowIndex: 0,
841+
});
842+
await flushMicrotasks();
843+
844+
expect(scrollContainer.scrollLeft).toBe(0);
845+
846+
// The grid becomes visible and layout observers fire.
847+
mockedClientWidth = 400;
848+
act(() => {
849+
window.dispatchEvent(new Event("resize"));
850+
});
851+
await flushMicrotasks();
852+
853+
// The pending focused-cell auto-scroll now runs exactly once:
854+
// columnEnd (11 * 200) minus the 400px viewport.
855+
expect(scrollContainer.scrollLeft).toBe(1800);
856+
857+
// Subsequent user scrolling still wins.
858+
act(() => {
859+
scrollContainer.scrollLeft = 2600;
860+
scrollContainer.dispatchEvent(new Event("scroll"));
861+
});
862+
await flushMicrotasks();
863+
864+
expect(scrollContainer.scrollLeft).toBe(2600);
865+
} finally {
866+
cleanupGrid?.();
867+
868+
if (clientWidthDescriptor) {
869+
Object.defineProperty(
870+
HTMLElement.prototype,
871+
"clientWidth",
872+
clientWidthDescriptor,
873+
);
874+
} else {
875+
Reflect.deleteProperty(HTMLElement.prototype, "clientWidth");
876+
}
877+
}
878+
});
879+
696880
it("loads more rows when infinite scroll reaches the bottom threshold", async () => {
697881
const onLoadMoreRows = vi.fn();
698882
const { cleanup, container } = renderGrid({

0 commit comments

Comments
 (0)