Skip to content

Commit 8c549b3

Browse files
Merge pull request #275 from eccenca/feature/overviewItemPerformance-CMEM-6573
Improve performance of Tooltip and ContextMenu (CMEM-6620)
2 parents b021978 + 687bbef commit 8c549b3

10 files changed

Lines changed: 462 additions & 39 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
88

99
### Added
1010

11+
- `<ContextOverlay />`
12+
- `usePlaceholder` property: can be used to display the target but include the component later when the first interaction happens, this can improve performance
13+
- `<ContextMenu />`
14+
- `preventPlaceholder` property to prevent the default usage of placeholders waiting for the first user interaction before inserting the full context menu
15+
- `<Tooltip />`
16+
- `usePlaceholder` property: can be used to display the target but include the full component later when the first interaction happens, this can improve performance. It is turned on for text tooltips by default.
17+
- `<OverviewItemActions />`
18+
- `delayDisplayChildren` property: set a time (in ms) to delay the actual rendering of elements inside the actions container. When enabled the containing `OverviewItem` can be displayed faster. Can be used e.g. to boost performance when rendering `OverviewItemActions` with `hiddenInteractions` set to `true`.
19+
- `delaySkeleton` property to set the placeholder/skeleton as long as the delayed display is waiting to get processed
1120
- `intent` property to `Button`, `FieldItem`, `FieldSet`, `Notification`, and `Spinner`
1221

1322
### Fixed

src/components/ContextOverlay/ContextMenu.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default {
1010
subcomponents: { MenuItem },
1111
argTypes: {
1212
children: {
13-
control: "none",
13+
control: false,
1414
},
1515
},
1616
} as Meta<typeof ContextMenu>;

src/components/ContextOverlay/ContextMenu.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ export interface ContextMenuProps extends TestableComponent {
4040
* Props to spread to `ContextOverlay` that is used to display the dropdown.
4141
*/
4242
contextOverlayProps?: Partial<Omit<ContextOverlayProps, "content" | "children" | "className">>;
43-
/** Disables the button to open the menu. */
43+
/**
44+
* Disables the button to open the menu.
45+
*/
4446
disabled?: boolean;
47+
/**
48+
* We use the target as placeholder before the real `<ContextMenu /` is rendered on first hover or focus event.
49+
* In case of problems set this property to `true`.
50+
*/
51+
preventPlaceholder?: boolean;
4552
}
4653

4754
/**
@@ -58,27 +65,33 @@ export const ContextMenu = ({
5865
/* FIXME: The Tooltip component can interfere with the opened menu, since it is implemented via portal and may cover the menu,
5966
so by default we use the title attribute instead of Tooltip. */
6067
tooltipAsTitle = true,
68+
preventPlaceholder = false,
6169
...restProps
6270
}: ContextMenuProps) => {
71+
const toggleButton =
72+
typeof togglerElement === "string" ? (
73+
<IconButton
74+
tooltipAsTitle={tooltipAsTitle}
75+
name={[togglerElement]}
76+
text={togglerText}
77+
large={togglerLarge}
78+
disabled={!!disabled}
79+
data-test-id={restProps["data-test-id"]}
80+
/>
81+
) : (
82+
(togglerElement as ReactElement)
83+
);
84+
6385
return (
6486
<ContextOverlay
6587
{...restProps}
6688
{...contextOverlayProps}
6789
className={`${eccgui}-contextmenu ` + className}
6890
content={<Menu>{children}</Menu>}
91+
disabled={!!disabled}
92+
usePlaceholder={!preventPlaceholder}
6993
>
70-
{typeof togglerElement === "string" ? (
71-
<IconButton
72-
tooltipAsTitle={tooltipAsTitle}
73-
name={[togglerElement]}
74-
text={togglerText}
75-
large={togglerLarge}
76-
disabled={!!disabled}
77-
data-test-id={restProps["data-test-id"]}
78-
/>
79-
) : (
80-
(togglerElement as ReactElement)
81-
)}
94+
{toggleButton}
8295
</ContextOverlay>
8396
);
8497
};

src/components/ContextOverlay/ContextOverlay.tsx

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import React from "react";
2-
import { Popover as BlueprintPopover, PopoverProps as BlueprintPopoverProps } from "@blueprintjs/core";
2+
import {
3+
Classes as BlueprintClasses,
4+
Popover as BlueprintPopover,
5+
PopoverProps as BlueprintPopoverProps,
6+
Utils as BlueprintUtils,
7+
} from "@blueprintjs/core";
38

49
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
510

@@ -13,6 +18,11 @@ export interface ContextOverlayProps extends Omit<BlueprintPopoverProps, "positi
1318
* Use it when you need to display modal dialogs out of the context overlay.
1419
*/
1520
preventTopPosition?: boolean;
21+
/**
22+
* Use the overlay target as placeholder before the real `<ContextOverlay /` is rendered on first hover or focus event.
23+
* Currently experimental.
24+
*/
25+
usePlaceholder?: boolean;
1626
}
1727

1828
/**
@@ -24,18 +34,86 @@ export const ContextOverlay = ({
2434
portalClassName,
2535
preventTopPosition,
2636
className = "",
27-
...restProps
37+
usePlaceholder = false,
38+
...otherPopoverProps
2839
}: ContextOverlayProps) => {
40+
const placeholderRef = React.useRef(null);
41+
const eventMemory = React.useRef<undefined | "afterhover" | "afterfocus">(undefined);
42+
const [placeholder, setPlaceholder] = React.useState<boolean>(
43+
// use placeholder only for "simple" overlays without special states
44+
!otherPopoverProps.disabled &&
45+
!otherPopoverProps.defaultIsOpen &&
46+
!otherPopoverProps.isOpen &&
47+
otherPopoverProps.renderTarget === undefined &&
48+
usePlaceholder
49+
);
50+
51+
React.useEffect(() => {
52+
if (placeholderRef.current) {
53+
const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
54+
eventMemory.current = ev.type === "focusin" ? "afterfocus" : "afterhover";
55+
setPlaceholder(false);
56+
};
57+
(placeholderRef.current as HTMLElement).addEventListener("mouseenter", swap);
58+
(placeholderRef.current as HTMLElement).addEventListener("focusin", swap);
59+
return () => {
60+
if (placeholderRef.current) {
61+
(placeholderRef.current as HTMLElement).removeEventListener("mouseenter", swap);
62+
(placeholderRef.current as HTMLElement).removeEventListener("focusin", swap);
63+
}
64+
};
65+
}
66+
return () => {};
67+
}, [!!placeholderRef.current]);
68+
69+
const refocus = React.useCallback((node) => {
70+
if (eventMemory.current === "afterfocus" && node) {
71+
const target = node.targetRef.current.children[0];
72+
if (target) {
73+
target.focus();
74+
}
75+
}
76+
}, []);
77+
78+
const targetClassName = `${eccgui}-contextoverlay` + (className ? ` ${className}` : "");
79+
80+
const displayPlaceholder = () => {
81+
const PlaceholderElement = otherPopoverProps?.targetTagName ?? (otherPopoverProps?.fill ? "div" : "span");
82+
const childTarget = BlueprintUtils.ensureElement(React.Children.toArray(children)[0]);
83+
if (!childTarget) {
84+
return null;
85+
}
86+
return React.createElement(
87+
PlaceholderElement,
88+
{
89+
...otherPopoverProps?.targetProps,
90+
className: `${BlueprintClasses.POPOVER_TARGET} ${targetClassName}`,
91+
ref: placeholderRef,
92+
},
93+
React.cloneElement(childTarget, {
94+
...childTarget.props,
95+
className:
96+
childTarget.props.className ?? "" + (otherPopoverProps.fill ? ` ${BlueprintClasses.FILL}` : ""),
97+
tabIndex:
98+
childTarget.props.tabIndex ??
99+
(!otherPopoverProps?.disabled && otherPopoverProps?.openOnTargetFocus ? 0 : undefined),
100+
})
101+
);
102+
};
103+
29104
const portalClassNameFinal =
30105
(preventTopPosition ? `${eccgui}-contextoverlay__portal--lowertop` : "") +
31106
(portalClassName ? ` ${portalClassName}` : "");
32107

33-
return (
108+
return placeholder ? (
109+
displayPlaceholder()
110+
) : (
34111
<BlueprintPopover
35112
placement="bottom"
36-
{...restProps}
37-
className={`${eccgui}-contextoverlay` + (className ? ` ${className}` : "")}
113+
{...otherPopoverProps}
114+
className={targetClassName}
38115
portalClassName={portalClassNameFinal.trim() ?? undefined}
116+
ref={refocus}
39117
>
40118
{children}
41119
</BlueprintPopover>

src/components/OverviewItem/OverviewItemActions.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ export interface OverviewItemActionsProps extends React.HTMLAttributes<HTMLDivEl
77
* Display it only when the parent `OverviewItem` is hovered or focused.
88
*/
99
hiddenInteractions?: boolean;
10+
/**
11+
* Delay the rendering of the children by a time in milliseconds.
12+
* Could be used to prevent browser freezes for the initial `OverviewItem` rendering.
13+
* In general, it is better to fix the cause, i.e. action elements that are expensive to initialize/render should be
14+
* optimized or replaced etc. This workaround only prevents the browser from getting blocked completely and does NOT
15+
* solve the actual performance issue.
16+
*/
17+
delayDisplayChildren?: number;
18+
/**
19+
* Display element while the rendering of the actual children is delayed.
20+
*/
21+
delaySkeleton?: JSX.Element;
1022
}
1123

1224
/**
@@ -17,8 +29,19 @@ export const OverviewItemActions = ({
1729
children,
1830
className = "",
1931
hiddenInteractions = false,
32+
delayDisplayChildren = 0,
33+
delaySkeleton = <></>,
2034
...restProps
2135
}: OverviewItemActionsProps) => {
36+
const [showActions, setShowActions] = React.useState(!(delayDisplayChildren > 0));
37+
38+
React.useEffect(() => {
39+
// Delay rendering of item actions when they are hidden anyways, because rendering interaction elements like context menus currently has a large performance impact.
40+
if (!showActions && delayDisplayChildren > 0) {
41+
setTimeout(() => setShowActions(true), delayDisplayChildren);
42+
}
43+
}, []);
44+
2245
return (
2346
<div
2447
{...restProps}
@@ -28,7 +51,7 @@ export const OverviewItemActions = ({
2851
(className ? ` ${className}` : "")
2952
}
3053
>
31-
{children}
54+
{showActions ? children : delaySkeleton}
3255
</div>
3356
);
3457
};

src/components/OverviewItem/stories/OverviewItemList.stories.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default {
1212
},
1313
argTypes: {
1414
children: {
15-
control: "none",
15+
control: false,
1616
description: "Should contain only `OverviewItem` elements, maybe wrapped inside cards.",
1717
},
1818
},
@@ -26,10 +26,5 @@ ItemList.args = {
2626
hasDivider: true,
2727
densityHigh: false,
2828
columns: 1,
29-
children: [
30-
<OverviewItem {...ItemExample.args} />,
31-
<OverviewItem {...ItemExample.args} />,
32-
<OverviewItem {...ItemExample.args} />,
33-
<OverviewItem {...ItemExample.args} />,
34-
],
29+
children: Array(4).fill(<OverviewItem {...ItemExample.args} />),
3530
};

0 commit comments

Comments
 (0)