Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [Sc-63907] allow skipping scrollToIndex if row is already in view #1099

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
13 changes: 11 additions & 2 deletions src/components/Table/GridTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import memoizeOne from "memoize-one";
import { runInAction } from "mobx";
import React, { MutableRefObject, ReactElement, useEffect, useMemo, useRef, useState } from "react";
import { Components, Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Components, ListRange, Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { getTableRefWidthStyles, Loader } from "src/components";
import { DiscriminateUnion, GridRowKind } from "src/components/index";
import { PresentationFieldProps, PresentationProvider } from "src/components/PresentationContext";
Expand Down Expand Up @@ -231,14 +231,16 @@ export function GridTable<R extends Kinded, X extends Only<GridTableXss, X> = an

// We only use this in as=virtual mode, but keep this here for rowLookup to use
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
// Stores the current rendered range of rows from virtuoso (used for determining if we can skip re-scrolling to a row if already in view)
const virtuosoRangeRef = useRef<ListRange | null>(null);
// Use this ref to watch for changes in the GridTable's container and resize columns accordingly.
const resizeRef = useRef<HTMLDivElement>(null);

const api = useMemo<GridTableApiImpl<R>>(
() => {
// Let the user pass in their own api handle, otherwise make our own
const api = (props.api as GridTableApiImpl<R>) ?? new GridTableApiImpl();
api.init(persistCollapse, virtuosoRef);
api.init(persistCollapse, virtuosoRef, virtuosoRangeRef);
api.setActiveRowId(activeRowId);
api.setActiveCellId(activeCellId);
// Push the initial columns directly into tableState, b/c that is what
Expand Down Expand Up @@ -505,6 +507,7 @@ export function GridTable<R extends Kinded, X extends Only<GridTableXss, X> = an
stickyHeader,
xss,
virtuosoRef,
virtuosoRangeRef,
tableHeadRows,
stickyOffset,
infiniteScroll,
Expand Down Expand Up @@ -532,6 +535,7 @@ function renderDiv<R extends Kinded>(
stickyHeader: boolean,
xss: any,
_virtuosoRef: MutableRefObject<VirtuosoHandle | null>,
_virtuosoRangeRef: MutableRefObject<ListRange | null>,
tableHeadRows: ReactElement[],
stickyOffset: number,
_infiniteScroll?: InfiniteScroll,
Expand Down Expand Up @@ -592,6 +596,7 @@ function renderTable<R extends Kinded>(
stickyHeader: boolean,
xss: any,
_virtuosoRef: MutableRefObject<VirtuosoHandle | null>,
_virtuosoRangeRef: MutableRefObject<ListRange | null>,
tableHeadRows: ReactElement[],
stickyOffset: number,
_infiniteScroll?: InfiniteScroll,
Expand Down Expand Up @@ -659,6 +664,7 @@ function renderVirtual<R extends Kinded>(
stickyHeader: boolean,
xss: any,
virtuosoRef: MutableRefObject<VirtuosoHandle | null>,
virtuosoRangeRef: MutableRefObject<ListRange | null>,
tableHeadRows: ReactElement[],
_stickyOffset: number,
infiniteScroll?: InfiniteScroll,
Expand Down Expand Up @@ -737,6 +743,9 @@ function renderVirtual<R extends Kinded>(
// Lastly render the table body rows
return visibleDataRows[index];
}}
rangeChanged={(newRange) => {
virtuosoRangeRef.current = newRange;
}}
totalCount={tableHeadRows.length + (firstRowMessage ? 1 : 0) + visibleDataRows.length + keptSelectedRows.length}
// When implementing infinite scroll, default the bottom `increaseViewportBy` to 500px. This creates the "infinite"
// effect such that the next page of data is (hopefully) loaded before the user reaches the true bottom
Expand Down
27 changes: 21 additions & 6 deletions src/components/Table/GridTableApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { comparer } from "mobx";
import { computedFn } from "mobx-utils";
import { MutableRefObject, useMemo } from "react";
import { VirtuosoHandle } from "react-virtuoso";
import { ListRange, VirtuosoHandle } from "react-virtuoso";
import {
applyRowFn,
createRowLookup,
Expand All @@ -10,6 +10,7 @@ import {
isGridCellContent,
isJSX,
MaybeFn,
shouldSkipScrollTo,
} from "src/components/index";
import { GridDataRow } from "src/components/Table/components/Row";
import { DiscriminateUnion, Kinded } from "src/components/Table/types";
Expand Down Expand Up @@ -104,6 +105,7 @@ export class GridTableApiImpl<R extends Kinded> implements GridTableApi<R> {
// This is public to GridTable but not exported outside of Beam
readonly tableState: TableState<R> = new TableState(this);
virtuosoRef: MutableRefObject<VirtuosoHandle | null> = { current: null };
virtuosoRangeRef: MutableRefObject<ListRange | null> = { current: null };
lookup!: GridRowLookup<R>;

constructor() {
Expand All @@ -118,16 +120,29 @@ export class GridTableApiImpl<R extends Kinded> implements GridTableApi<R> {
}

/** Called once by the GridTable when it takes ownership of this api instance. */
init(persistCollapse: string | undefined, virtuosoRef: MutableRefObject<VirtuosoHandle | null>) {
init(
persistCollapse: string | undefined,
virtuosoRef: MutableRefObject<VirtuosoHandle | null>,
virtuosoRangeRef: MutableRefObject<ListRange | null>,
) {
// Technically this drives both row-collapse and column-expanded
if (persistCollapse) this.tableState.loadCollapse(persistCollapse);
this.virtuosoRef = virtuosoRef;
this.lookup = createRowLookup(this, virtuosoRef);
this.virtuosoRangeRef = virtuosoRangeRef;
this.lookup = createRowLookup(this, virtuosoRef, virtuosoRangeRef);
}

public scrollToIndex(index: GridTableScrollOptions): void {
this.virtuosoRef.current &&
this.virtuosoRef.current.scrollToIndex(typeof index === "number" ? { index, behavior: "smooth" } : index);
public scrollToIndex(indexOrOptions: GridTableScrollOptions): void {
if (!this.virtuosoRef.current) return;

const { forceRescroll = true, ...scrollToOpts } =
typeof indexOrOptions === "number"
? { index: indexOrOptions, behavior: "smooth" as const, forceRescroll: false }
: indexOrOptions;

if (shouldSkipScrollTo(scrollToOpts.index, this.virtuosoRangeRef, forceRescroll)) return;

this.virtuosoRef.current.scrollToIndex(scrollToOpts);
}

public getSelectedRowIds(kind?: string): string[] {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export { cardStyle, condensedStyle, defaultStyle, getTableStyles } from "src/com
export type { GridStyle, RowStyle, RowStyles } from "src/components/Table/TableStyles";
export * from "src/components/Table/types";
export * from "src/components/Table/utils/columns";
export { createRowLookup } from "src/components/Table/utils/GridRowLookup";
export { createRowLookup, shouldSkipScrollTo } from "src/components/Table/utils/GridRowLookup";
export type { GridRowLookup } from "src/components/Table/utils/GridRowLookup";
export { simpleDataRows, simpleHeader } from "src/components/Table/utils/simpleHelpers";
export type { SimpleHeaderAndData } from "src/components/Table/utils/simpleHelpers";
Expand Down
2 changes: 2 additions & 0 deletions src/components/Table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type GridTableScrollOptions =
* How to position the row in the viewport
*/
align?: "start" | "center" | "end";
/** Determines if the row should be re-scrolled to even if it's already in view (scrolled to top) */
forceRescroll?: boolean;
};

/**
Expand Down
33 changes: 29 additions & 4 deletions src/components/Table/utils/GridRowLookup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MutableRefObject } from "react";
import { VirtuosoHandle } from "react-virtuoso";
import { ListRange, VirtuosoHandle } from "react-virtuoso";
import { GridDataRow } from "src/components/Table/components/Row";
import { GridTableApiImpl } from "src/components/Table/GridTableApi";
import { DiscriminateUnion, GridColumnWithId, Kinded, nonKindGridColumnKeys } from "src/components/Table/types";
Expand All @@ -22,8 +22,11 @@ export interface GridRowLookup<R extends Kinded> {
/** Returns the list of currently filtered/sorted rows, without headers. */
currentList(): readonly GridDataRow<R>[];

/** Scroll's to the row with the given kind + id. Requires using `as=virtual`. */
scrollTo(kind: R["kind"], id: string): void;
/**
* Scroll's to the row with the given kind + id. Requires using `as=virtual`.
* `forceRescroll` controls if an element should be re-scrolled to the top even if it's already visible. (Default false)
*/
scrollTo(kind: R["kind"], id: string, forceRescroll?: boolean): void;
}

interface NextPrev<R extends Kinded> {
Expand All @@ -34,15 +37,20 @@ interface NextPrev<R extends Kinded> {
export function createRowLookup<R extends Kinded>(
api: GridTableApiImpl<R>,
virtuosoRef: MutableRefObject<VirtuosoHandle | null>,
virtuosoRangeRef: MutableRefObject<ListRange | null>,
): GridRowLookup<R> {
return {
scrollTo(kind, id) {
scrollTo(kind, id, forceRescroll = true) {
if (virtuosoRef.current === null) {
// In theory we could support as=div and as=table by finding the DOM
// element and calling .scrollIntoView, just not doing that yet.
throw new Error("scrollTo is only supported for as=virtual");
}

const index = api.tableState.visibleRows.findIndex((r) => r && r.kind === kind && r.row.id === id);

if (shouldSkipScrollTo(index, virtuosoRangeRef, forceRescroll)) return;

virtuosoRef.current.scrollToIndex({ index, behavior: "smooth" });
},
currentList() {
Expand Down Expand Up @@ -83,3 +91,20 @@ export function getKinds<R extends Kinded>(columns: GridColumnWithId<R>[]): R[]
(key) => !nonKindGridColumnKeys.includes(key),
) as any;
}

/** Optionally takes into consideration if a row is already in view before attempting to scroll to it. */
export function shouldSkipScrollTo(
index: number,
virtuosoRangeRef: MutableRefObject<ListRange | null>,
forceRescroll: boolean,
) {
if (!virtuosoRangeRef.current || forceRescroll) return false;

const isAlreadyInView =
// Add 1 on each end to account for "overscan" where the next out of view row is usually already rendered. This isn't a perfect solution,
// but our current "overscan" is only set to 50px, so it should be close enough and the library recommended alternative of adding an
// intersection observer to each row seems like a not worth it performance hit (https://github.com/petyosi/react-virtuoso/issues/118)
index >= virtuosoRangeRef.current.startIndex - 1 && index <= virtuosoRangeRef.current.endIndex + 1;

return isAlreadyInView;
}
Loading