Compare commits

...

5 Commits

Author SHA1 Message Date
Raphael Krut-Landau
147039c7ac simplify 2024-05-28 10:42:38 -04:00
Raphael Krut-Landau
fefb9a69b6 simplify 2024-05-28 10:40:29 -04:00
Raphael Krut-Landau
f72c94817b simplify 2024-05-28 10:37:13 -04:00
Raphael Krut-Landau
46b8169976 simplify 2024-05-28 10:32:46 -04:00
Raphael Krut-Landau
74ffb96aac Use RenderGradually approach to improve performance of browse models table 2024-05-28 10:31:03 -04:00
13 changed files with 252 additions and 139 deletions

View File

@@ -17,6 +17,7 @@ export const PolicyToken = styled(Button)<
border-width: 1px;
border-style: solid;
${({ variant }) =>
// NOTE: emotion's css function can be a memory hog
css`
border-color: ${color(
["filled", "outline"].includes(variant || "") ? "brand" : "border",

View File

@@ -82,7 +82,17 @@ export const BrowseModelsBody = ({
actualModelFilters,
availableModelFilters,
);
return filteredModels;
// FIXME: increased to test performance
const lots = Array(40)
.fill(null)
.flatMap((_, i) =>
filteredModels.map(model => ({
...model,
// Ensure ids are distinct to avoid duplicate React keys
id: model.id + i * 1000,
})),
);
return lots;
}, [data, actualModelFilters]);
if (error || isLoading) {

View File

@@ -4,7 +4,7 @@ import type { AnchorHTMLAttributes } from "react";
import { ResponsiveChild } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { color } from "metabase/lib/colors";
import type { AnchorProps } from "metabase/ui";
import { Anchor, FixedSizeIcon, Group } from "metabase/ui";
import { Anchor, Group, FixedSizeIcon } from "metabase/ui";
import type { RefProp } from "./types";
@@ -28,31 +28,6 @@ export const Breadcrumb = styled(Anchor)<
export const CollectionBreadcrumbsWrapper = styled(ResponsiveChild)`
line-height: 1;
${props => {
const breakpoint = "10rem";
return `
.initial-ellipsis {
display: none;
}
@container ${props.containerName} (width < ${breakpoint}) {
.ellipsis-and-separator {
display: none;
}
.initial-ellipsis {
display: inline;
}
.for-index-0:not(.sole-breadcrumb) {
display: none;
}
.breadcrumb {
max-width: calc(95cqw - 3rem) ! important;
}
.sole-breadcrumb {
max-width: calc(95cqw - 1rem) ! important;
}
}
`;
}}
`;
export const BreadcrumbGroup = styled(Group)`

View File

@@ -1,10 +1,9 @@
import classNames from "classnames";
import type { FC } from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { forwardRef } from "react";
import { ResponsiveContainer } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import resizeObserver from "metabase/lib/resize-observer";
import * as Urls from "metabase/lib/urls";
import type { FlexProps } from "metabase/ui";
import { Flex, Text, Tooltip } from "metabase/ui";
@@ -37,40 +36,15 @@ export const CollectionBreadcrumbsWithTooltip = ({
const shownCollections = ellipsifyPath
? [collections[0], collections[collections.length - 1]]
: collections;
const justOneShown = shownCollections.length === 1;
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLAnchorElement>({
tolerance: 1,
lazy: true,
});
const initialEllipsisRef = useRef<HTMLDivElement | null>(null);
const [
isFirstCollectionDisplayedAsEllipsis,
setIsFirstCollectionDisplayedAsEllipsis,
] = useState(false);
useEffect(() => {
const initialEllipsis = initialEllipsisRef.current;
if (!initialEllipsis) {
return;
}
const handleResize = () => {
// The initial ellipsis might be hidden via CSS,
// so we need to check whether it is displayed via getComputedStyle
const style = window.getComputedStyle(initialEllipsis);
setIsFirstCollectionDisplayedAsEllipsis(style.display !== "none");
};
resizeObserver.subscribe(initialEllipsis, handleResize);
return () => {
resizeObserver.unsubscribe(initialEllipsis, handleResize);
};
}, [initialEllipsisRef]);
const isTooltipEnabled =
areAnyTruncated || ellipsifyPath || isFirstCollectionDisplayedAsEllipsis;
const isTooltipEnabled = areAnyTruncated || ellipsifyPath;
const maxWidths = getBreadcrumbMaxWidths(shownCollections, 96, ellipsifyPath);
return (
<Tooltip
label={pathString}
@@ -81,8 +55,9 @@ export const CollectionBreadcrumbsWithTooltip = ({
<ResponsiveContainer
aria-label={pathString}
data-testid={`breadcrumbs-for-collection: ${collection.name}`}
name={containerName}
w="auto"
name={containerName}
// FIXME: Combine with the Flex?
>
<Flex align="center" w="100%" lh="1" style={{ flexFlow: "row nowrap" }}>
<CollectionsIcon
@@ -101,18 +76,11 @@ export const CollectionBreadcrumbsWithTooltip = ({
>
{index > 0 && <PathSeparator />}
<CollectionBreadcrumbsWrapper
containerName={containerName}
style={{ alignItems: "center" }}
w="auto"
display="flex"
containerName={containerName}
>
{index === 0 && !justOneShown && (
<Ellipsis
ref={initialEllipsisRef}
includeSep={false}
className="initial-ellipsis"
/>
)}
{index > 0 && ellipsifyPath && <Ellipsis />}
<Breadcrumb
href={Urls.collection(collection)}

View File

@@ -1,11 +1,13 @@
import { Ellipsified } from "metabase/core/components/Ellipsified";
import type { EllipsifiedProps } from "metabase/core/components/Ellipsified/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
export const EllipsifiedWithMarkdownTooltip = ({
children,
...props
}: {
children: string;
}) => {
} & Partial<EllipsifiedProps>) => {
return (
<Ellipsified
tooltip={
@@ -13,6 +15,7 @@ export const EllipsifiedWithMarkdownTooltip = ({
{children}
</Markdown>
}
{...props}
>
{children}
</Ellipsified>

View File

@@ -21,7 +21,8 @@ import { color } from "metabase/lib/colors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getLocale } from "metabase/setup/selectors";
import { Icon, type IconProps } from "metabase/ui";
import type { IconProps } from "metabase/ui";
import { Flex, Icon } from "metabase/ui";
import type { ModelResult } from "metabase-types/api";
import { trackModelClick } from "../analytics";
@@ -34,6 +35,7 @@ import {
ModelNameColumn,
ModelTableRow,
} from "./ModelsTable.styled";
import { RenderGradually } from "./RenderGradually";
import { getModelDescription, sortModels } from "./utils";
export interface ModelsTableProps {
@@ -109,7 +111,7 @@ export const ModelsTable = ({ models }: ModelsTableProps) => {
},
}}
>
<Ellipsified>{t`Collection`}</Ellipsified>
<Ellipsified lazy>{t`Collection`}</Ellipsified>
</SortableColumnHeader>
<SortableColumnHeader
name="description"
@@ -126,20 +128,48 @@ export const ModelsTable = ({ models }: ModelsTableProps) => {
</tr>
</thead>
<TBody>
{sortedModels.map((model: ModelResult) => (
<TBodyRow model={model} key={`${model.model}-${model.id}`} />
))}
<RenderGradually
// Motivation for the RenderGradually pattern:
// The table loads very slowly when there are 1K+ models.
// It should be able to handle that many, since in large enterprises
// a lot of users might use the CSV upload function to create a lot of models.
// With this pattern, page load and sorting are fast and take O(1) time.
// Contrast with other approaches:
// * Virtualization and pagination:
// These might be worth exploring even though they prevent searching with Cmd+F.
// * Simplifying the the table-row component when the model count is high:
// Easy to maintain, though with this approach, page load and
// sorting both are semi-slow and have worse than O(1) time.
items={sortedModels}
Loading={ModelsLoadingIndicator}
key={JSON.stringify(sortingOptions)}
enabled={false}
>
{items =>
items.map(model => (
<TBodyRow
model={model}
key={`model-${model.id}`}
collectionContainerName="collection-container"
/>
))
}
</RenderGradually>
</TBody>
</Table>
);
};
const TBodyRow = ({ model }: { model: ModelResult }) => {
const TBodyRow = ({
model,
collectionContainerName,
}: {
model: ModelResult;
collectionContainerName: string;
}) => {
const icon = getIcon(model);
const containerName = `collections-path-for-${model.id}`;
const dispatch = useDispatch();
const { id, name } = model;
return (
<ModelTableRow
onClick={(e: React.MouseEvent) => {
@@ -149,6 +179,7 @@ const TBodyRow = ({ model }: { model: ModelResult }) => {
} else {
dispatch(push(url));
}
trackModelClick(model.id);
}}
tabIndex={0}
key={model.id}
@@ -173,15 +204,15 @@ const TBodyRow = ({ model }: { model: ModelResult }) => {
>
{model.collection && (
<CollectionBreadcrumbsWithTooltip
containerName={containerName}
collection={model.collection}
containerName={collectionContainerName}
/>
)}
</ModelCell>
{/* Description */}
<ModelCell {...descriptionProps}>
<EllipsifiedWithMarkdownTooltip>
<EllipsifiedWithMarkdownTooltip lazy>
{getModelDescription(model) || ""}
</EllipsifiedWithMarkdownTooltip>
</ModelCell>
@@ -226,3 +257,15 @@ const NameCell = ({
</ItemNameCell>
);
};
const ModelsLoadingIndicator = () => {
return (
<tr>
<td colSpan={99} style={{ backgroundColor: "transparent" }}>
<Flex fw="bold" gap="sm" justify="center" align="center">
{t`Loading…`}
</Flex>
</td>
</tr>
);
};

View File

@@ -0,0 +1,60 @@
import type { FC, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
const defaultIncreaseFunction = (prev: number) => {
const increasedValue = prev * 1.5;
return increasedValue > 300 ? prev + 300 : Math.floor(increasedValue);
};
type NoProps = Record<string, never>;
/** Lazily render a list of items in chunks */
export const RenderGradually = <T,>({
items,
children,
Loading,
key,
increaseFunction = defaultIncreaseFunction,
initialBatchSize = 30,
wait = 1000,
enabled = true,
}: {
items: T[];
children: (items: T[]) => ReactNode;
Loading: FC<NoProps>;
key?: string;
initialBatchSize?: number;
increaseFunction?: (prev: number) => number;
wait?: number;
enabled?: boolean;
}) => {
if (!enabled) {
initialBatchSize = items.length;
}
const [visibleItems, setVisibleItems] = useState([] as T[]);
useEffect(() => {
setVisibleItems(items.slice(0, initialBatchSize));
}, [items, initialBatchSize]);
useEffect(() => {
if (visibleItems.length >= items.length) {
return;
}
const timerId = setInterval(() => {
setVisibleItems(prev => {
if (prev.length >= items.length) {
return prev;
}
return items.slice(0, increaseFunction(prev.length));
});
}, wait);
return () => clearInterval(timerId);
}, [visibleItems.length, items, increaseFunction, wait]);
return (
<Fragment key={key}>
{children(visibleItems)}
{visibleItems.length < items.length && <Loading />}
</Fragment>
);
};

View File

@@ -84,26 +84,53 @@ export const sortModels = (
sortingOptions: SortingOptions,
localeCode: string = "en",
) => {
const { sort_column, sort_direction } = sortingOptions;
// FIXME: This is probably over-optimized
const { sort_column: primarySortColumn, sort_direction } = sortingOptions;
if (!isValidSortColumn(sort_column)) {
console.error("Invalid sort column", sort_column);
if (!isValidSortColumn(primarySortColumn)) {
console.error("Invalid sort column", primarySortColumn);
return models;
}
const compare = (a: string, b: string) =>
a.localeCompare(b, localeCode, { sensitivity: "base" });
return [...models].sort((modelA, modelB) => {
const a = getValueForSorting(modelA, sort_column);
const b = getValueForSorting(modelB, sort_column);
const secondarySortColumn = getSecondarySortColumn(primarySortColumn);
let result = compare(a, b);
const primaryValues = Array.from(
new Set(models.map(model => getValueForSorting(model, primarySortColumn))),
);
const secondaryValues = Array.from(
new Set(
models.map(model => getValueForSorting(model, secondarySortColumn)),
),
);
primaryValues.sort(compare);
secondaryValues.sort(compare);
const primaryOrderMap = new Map(
primaryValues.map((value, index) => [value, index]),
);
const secondaryOrderMap = new Map(
secondaryValues.map((value, index) => [value, index]),
);
// Use pre-computed value orders for fast comparison
const comparePrimary = (a: string, b: string) =>
primaryOrderMap.get(a)! - primaryOrderMap.get(b)!;
const compareSecondary = (a: string, b: string) =>
secondaryOrderMap.get(a)! - secondaryOrderMap.get(b)!;
return [...models].sort((modelA, modelB) => {
const a = getValueForSorting(modelA, primarySortColumn);
const b = getValueForSorting(modelB, primarySortColumn);
let result = comparePrimary(a, b);
if (result === 0) {
const sort_column2 = getSecondarySortColumn(sort_column);
const a2 = getValueForSorting(modelA, sort_column2);
const b2 = getValueForSorting(modelB, sort_column2);
result = compare(a2, b2);
const a2 = getValueForSorting(modelA, secondarySortColumn);
const b2 = getValueForSorting(modelB, secondarySortColumn);
result = compareSecondary(a2, b2);
}
return sort_direction === SortDirection.Asc ? result : -result;

View File

@@ -0,0 +1,6 @@
export const callNow = (func: () => void) => func();
export const callLater = (callback: () => void) => {
"requestIdleCallback" in window
? window.requestIdleCallback(callback)
: setTimeout(callback, 1);
};

View File

@@ -103,7 +103,7 @@ function EntityItemName({ name, variant }: { name: string; variant?: string }) {
[CS.textList]: variant === "list",
})}
>
<Ellipsified>{name}</Ellipsified>
<Ellipsified lazy>{name}</Ellipsified>
</h3>
);
}

View File

@@ -1,5 +1,5 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import _ from "underscore";
import EntityItem from "metabase/components/EntityItem";
import IconButtonWrapper from "metabase/components/IconButtonWrapper";
@@ -40,16 +40,15 @@ export const Table = styled.table<{ isInDragLayer?: boolean }>`
Table.defaultProps = { className: AdminS.ContentTable };
export const hideResponsively = ({
hideAtContainerBreakpoint,
containerName,
}: ResponsiveProps) =>
css`
${getContainerQuery({
export const hideResponsively = _.memoize(
({ hideAtContainerBreakpoint, containerName }: ResponsiveProps) =>
getContainerQuery({
hideAtContainerBreakpoint,
containerName,
})}
`;
}),
({ hideAtContainerBreakpoint, containerName }: ResponsiveProps) =>
`${hideAtContainerBreakpoint}-${containerName}`,
);
export const ColumnHeader = styled.th<ResponsiveProps>`
th& {

View File

@@ -6,7 +6,7 @@ import { useIsTruncated } from "metabase/hooks/use-is-truncated";
import { EllipsifiedRoot } from "./Ellipsified.styled";
interface EllipsifiedProps {
export interface EllipsifiedProps {
style?: CSSProperties;
className?: string;
showTooltip?: boolean;
@@ -18,6 +18,8 @@ interface EllipsifiedProps {
placement?: Placement;
"data-testid"?: string;
id?: string;
/** Evaluate truncation lazily for the sake of performance? */
lazy?: boolean;
}
export const Ellipsified = ({
@@ -32,10 +34,12 @@ export const Ellipsified = ({
placement = "top",
"data-testid": dataTestId,
id,
lazy = false,
}: EllipsifiedProps) => {
const canSkipTooltipRendering = !showTooltip && !alwaysShowTooltip;
const { isTruncated, ref } = useIsTruncated<HTMLDivElement>({
disabled: canSkipTooltipRendering,
lazy,
});
return (

View File

@@ -1,39 +1,48 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import _ from "underscore";
import { callLater, callNow } from "metabase/common/utils/lazy";
import resizeObserver from "metabase/lib/resize-observer";
type UseIsTruncatedProps = {
disabled?: boolean;
/** To avoid rounding errors, we can require that the truncation is at least a certain number of pixels */
tolerance?: number;
/** If true, will perform key operations only during the browser's idle period */
lazy?: boolean;
};
export const useIsTruncated = <E extends Element>({
disabled = false,
tolerance = 0,
lazy = false,
}: UseIsTruncatedProps = {}) => {
const ref = useRef<E | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
const call = lazy ? callLater : callNow;
useLayoutEffect(() => {
const element = ref.current;
useEffect(
function startCheckingOnResize() {
const element = ref.current;
if (!element || disabled) {
return;
}
if (!element || disabled) {
return;
}
const handleResize = () => {
setIsTruncated(getIsTruncated(element, tolerance));
};
const handleResize = () => {
call(() => {
setIsTruncated(getIsTruncated(element, tolerance));
});
};
handleResize();
resizeObserver.subscribe(element, handleResize);
return () => {
resizeObserver.unsubscribe(element, handleResize);
};
}, [disabled, tolerance]);
handleResize();
() => resizeObserver.subscribe(element, handleResize);
return () => {
resizeObserver.unsubscribe(element, handleResize);
};
},
[disabled, tolerance, call],
);
return { isTruncated, ref };
};
@@ -48,40 +57,48 @@ const getIsTruncated = (element: Element, tolerance: number): boolean => {
export const useAreAnyTruncated = <E extends Element>({
disabled = false,
tolerance = 0,
lazy = false,
}: UseIsTruncatedProps = {}) => {
const ref = useRef(new Map<string, E>());
const [truncationStatusByKey, setTruncationStatusByKey] = useState<
Map<string, boolean>
>(new Map());
useLayoutEffect(() => {
const elementsMap = ref.current;
const call = lazy ? callLater : callNow;
if (!elementsMap.size || disabled) {
return;
}
const unsubscribeFns: (() => void)[] = [];
useEffect(
function startCheckingOnResize() {
const elementsMap = ref.current;
[...elementsMap.entries()].forEach(([elementKey, element]) => {
const handleResize = () => {
const isTruncated = getIsTruncated(element, tolerance);
setTruncationStatusByKey(statuses => {
const newStatuses = new Map(statuses);
newStatuses.set(elementKey, isTruncated);
return newStatuses;
});
if (!elementsMap.size || disabled) {
return;
}
const unsubscribeFns: (() => void)[] = [];
elementsMap.forEach((element, elementKey) => {
const handleResize = () => {
call(() => {
const isTruncated = getIsTruncated(element, tolerance);
setTruncationStatusByKey(statuses => {
const newStatuses = new Map(statuses);
newStatuses.set(elementKey, isTruncated);
return newStatuses;
});
});
};
const handleResizeDebounced = _.debounce(handleResize, 200);
call(() => resizeObserver.subscribe(element, handleResizeDebounced));
unsubscribeFns.push(() =>
resizeObserver.unsubscribe(element, handleResizeDebounced),
);
});
return () => {
unsubscribeFns.forEach(fn => fn());
};
const handleResizeDebounced = _.debounce(handleResize, 200);
resizeObserver.subscribe(element, handleResizeDebounced);
unsubscribeFns.push(() =>
resizeObserver.unsubscribe(element, handleResizeDebounced),
);
});
return () => {
unsubscribeFns.forEach(fn => fn());
};
}, [disabled, tolerance]);
},
[disabled, tolerance, call],
);
const areAnyTruncated = [...truncationStatusByKey.values()].some(Boolean);
return { areAnyTruncated, ref };