Skip to content

Commit 4931791

Browse files
authored
optimize PopupMenu with memoization and lazy rendering (#1101)
1 parent 4982281 commit 4931791

File tree

2 files changed

+80
-50
lines changed

2 files changed

+80
-50
lines changed

.changeset/tasty-taxis-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ensembleui/react-runtime": patch
3+
---
4+
5+
optimize PopupMenu with memoization and lazy rendering

packages/runtime/src/widgets/PopupMenu.tsx

Lines changed: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
1-
import React, { useCallback, useMemo } from "react";
1+
import React, { useCallback, useMemo, memo, useState } from "react";
22
import type { MenuProps } from "antd";
33
import { Dropdown as AntdDropdown } from "antd";
4-
import {
5-
cloneDeep,
6-
isEmpty,
7-
isObject,
8-
isString,
9-
compact,
10-
join,
11-
tail,
12-
} from "lodash-es";
4+
import { isEmpty, isObject, isString, compact, join, tail } from "lodash-es";
135
import {
146
CustomScopeProvider,
157
unwrapWidget,
@@ -61,6 +53,26 @@ export type PopupMenuProps = {
6153
} & EnsembleWidgetProps<PopupMenuStyles & EnsembleWidgetStyles> &
6254
HasItemTemplate & { "item-template"?: { value: Expression<string> } };
6355

56+
// memoized component for rendering menu item labels to prevent expensive re-renders
57+
const MenuItemLabel = memo<{
58+
label: Expression<string> | { [key: string]: unknown };
59+
hasBeenOpened: boolean;
60+
isContextMenu: boolean;
61+
}>(({ label, hasBeenOpened, isContextMenu }) => {
62+
if (isString(label)) {
63+
return <span>{label}</span>;
64+
}
65+
66+
// for context menus, render immediately
67+
// for other triggers, only render complex widgets after menu has been opened
68+
if (!hasBeenOpened && !isContextMenu) {
69+
return <span style={{ opacity: 0.6 }}>...</span>;
70+
}
71+
72+
return <>{EnsembleRuntime.render([unwrapWidget(label)])}</>;
73+
});
74+
MenuItemLabel.displayName = "MenuItemLabel";
75+
6476
export const PopupMenu: React.FC<PopupMenuProps> = ({
6577
onTriggered,
6678
onItemSelect,
@@ -74,6 +86,12 @@ export const PopupMenu: React.FC<PopupMenuProps> = ({
7486
const action = useEnsembleAction(onItemSelect);
7587
const onTriggerAction = useEnsembleAction(onTriggered);
7688

89+
// track if menu has been opened to enable lazy rendering
90+
const [hasBeenOpened, setHasBeenOpened] = useState(false);
91+
92+
// for context menus, we need to detect when they're opened differently
93+
const isContextMenu = values?.trigger === "contextMenu";
94+
7795
const { namedData } = useTemplateData({
7896
data: itemTemplate?.data,
7997
name: itemTemplate?.name,
@@ -87,9 +105,13 @@ export const PopupMenu: React.FC<PopupMenuProps> = ({
87105

88106
const menuItem: ItemType = {
89107
key: `popupmenu_item_${index}`,
90-
label: isString(rawItem.label)
91-
? rawItem.label
92-
: EnsembleRuntime.render([unwrapWidget(rawItem.label)]),
108+
label: (
109+
<MenuItemLabel
110+
label={rawItem.label}
111+
hasBeenOpened={hasBeenOpened}
112+
isContextMenu={isContextMenu}
113+
/>
114+
),
93115
disabled: rawItem.enabled === false,
94116
...(rawItem.items && {
95117
children: rawItem.items.map((itm, childIndex) =>
@@ -100,77 +122,77 @@ export const PopupMenu: React.FC<PopupMenuProps> = ({
100122
};
101123
return menuItem;
102124
},
103-
[],
125+
[hasBeenOpened, isContextMenu],
104126
);
105127

106-
const popupMenuItems = useMemo(() => {
107-
const popupItems: MenuProps["items"] = [];
108-
109-
const items = values?.items;
110-
if (items) {
111-
const tempItems = compact(
112-
items.map((rawItem, index) => getMenuItem(rawItem, index)),
113-
);
114-
115-
popupItems.push(...tempItems);
128+
const templateItems = useMemo(() => {
129+
if (!isObject(itemTemplate) || isEmpty(namedData)) {
130+
return [];
116131
}
117132

118-
if (isObject(itemTemplate) && !isEmpty(namedData)) {
119-
const tempItems = namedData.map((item, index) => {
120-
const itm: ItemType = {
121-
key: `popupmenu_itemTemplate_${index}`,
122-
label: (
133+
return namedData.map((item, index) => {
134+
const itm: ItemType = {
135+
key: `popupmenu_itemTemplate_${index}`,
136+
label:
137+
hasBeenOpened || isContextMenu ? (
123138
<CustomScopeProvider value={item as CustomScope}>
124139
{EnsembleRuntime.render([itemTemplate.template])}
125140
</CustomScopeProvider>
141+
) : (
142+
<span style={{ opacity: 0.6 }}>...</span>
126143
),
127-
};
128-
return itm;
129-
});
144+
};
145+
return itm;
146+
});
147+
}, [itemTemplate, namedData, hasBeenOpened, isContextMenu]);
130148

131-
popupItems.push(...tempItems);
149+
const regularItems = useMemo(() => {
150+
const items = values?.items;
151+
if (!items || items.length === 0) {
152+
return [];
132153
}
133154

134-
if (values?.showDivider) {
155+
return compact(items.map((rawItem, index) => getMenuItem(rawItem, index)));
156+
}, [values?.items, getMenuItem]);
157+
158+
const popupMenuItems = useMemo(() => {
159+
const popupItems: MenuProps["items"] = [...regularItems, ...templateItems];
160+
161+
if (values?.showDivider && popupItems.length > 1) {
135162
for (let i = 1; i < popupItems.length; i += 2) {
136163
popupItems.splice(i, 0, { type: "divider" });
137164
}
138165
}
139166

140167
return popupItems;
141-
}, [
142-
values?.items,
143-
values?.showDivider,
144-
itemTemplate,
145-
namedData,
146-
getMenuItem,
147-
]);
168+
}, [regularItems, templateItems, values?.showDivider]);
148169

149170
const widgetToRender = useMemo(() => {
150171
if (!values?.widget) {
151172
throw Error("PopupMenu requires a widget to render the anchor.");
152173
}
153-
const widget = cloneDeep(values.widget);
154-
const actualWidget = unwrapWidget(widget);
174+
const actualWidget = unwrapWidget(values.widget);
155175
return EnsembleRuntime.render([actualWidget]);
156176
}, [values?.widget]);
157177

158178
const itemsMap = useMemo(() => {
159179
const map = new Map<string, PopupMenuItem>();
160180

161-
namedData.forEach((item, index) => {
162-
map.set(`itemTemplate_${index}`, item as PopupMenuItem);
163-
});
181+
if (namedData.length > 0) {
182+
namedData.forEach((item, index) => {
183+
map.set(`itemTemplate_${index}`, item as PopupMenuItem);
184+
});
185+
}
164186

165-
if (values?.items) {
187+
if (values?.items && values.items.length > 0) {
166188
const traverseItems = (
167189
items: PopupMenuItem[],
168190
path: number[] = [],
169191
): void => {
170192
items.forEach((item, index) => {
171193
const newPath = [...path, index];
172194
map.set(`item_${newPath.join("_")}`, item);
173-
if (item.items) {
195+
if (item.items && item.items.length > 0) {
174196
// handle nested items
175197
traverseItems(item.items, newPath);
176198
}
@@ -194,10 +216,13 @@ export const PopupMenu: React.FC<PopupMenuProps> = ({
194216
const handleOnOpenChange = useCallback(
195217
(open: boolean) => {
196218
if (open) {
197-
onTriggerAction?.callback({ open });
219+
setHasBeenOpened(true);
220+
if (onTriggerAction?.callback) {
221+
onTriggerAction.callback({ open });
222+
}
198223
}
199224
},
200-
[onTriggerAction],
225+
[onTriggerAction?.callback],
201226
);
202227

203228
return (

0 commit comments

Comments
 (0)