Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions .changeset/rich-avocados-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ensembleui/react-framework": patch
"@ensembleui/react-kitchen-sink": patch
"@ensembleui/react-runtime": patch
---

reduce runtime bundle size by modularize icons
1 change: 1 addition & 0 deletions apps/kitchen-sink/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@ensembleui/react-framework": "workspace:*",
"@ensembleui/react-runtime": "workspace:*",
"@mui/icons-material": "^6.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"web-vitals": "^2.1.4"
Expand Down
9 changes: 9 additions & 0 deletions apps/kitchen-sink/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ApplicationDTO } from "@ensembleui/react-framework";
import { EnsembleApp } from "@ensembleui/react-runtime";
import * as Icons from "@mui/icons-material";
// Screens
import React from "react";
import MenuYAML from "./ensemble/screens/menu.yaml";
import HomeYAML from "./ensemble/screens/home.yaml";
import WidgetsYAML from "./ensemble/screens/widgets.yaml";
Expand Down Expand Up @@ -142,6 +144,13 @@ const testApp: ApplicationDTO = {
content: String(TestActionsYAML),
},
],
icons: {
mui: { icons: Icons as { [key: string]: React.ComponentType } },
custom: {
prefix: "Mui",
icons: Icons as { [key: string]: React.ComponentType },
},
},
config: EnsembleConfig,
};

Expand Down
1 change: 1 addition & 0 deletions apps/preview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@ensembleui/react-framework": "workspace:*",
"@ensembleui/react-runtime": "workspace:*",
"@mui/icons-material": "^6.4.1",
"antd": "^5.9.0",
"firebase": "9.10.0",
"lodash-es": "^4.17.21",
Expand Down
8 changes: 8 additions & 0 deletions apps/preview/src/AppPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ApplicationLoader,
EnsembleDocument,
} from "@ensembleui/react-framework";
import * as Icons from "@mui/icons-material";
import { getFirestoreApplicationLoader } from "@ensembleui/react-framework";
import { EnsembleApp } from "@ensembleui/react-runtime";
import { Alert } from "antd";
Expand Down Expand Up @@ -32,6 +33,13 @@ const customPreviewWidgetApp: ApplicationDTO = {
scripts: [],
id: "customWidgetPreview",
name: "customWidgetPreview",
icons: {
mui: { icons: Icons as { [key: string]: React.ComponentType } },
custom: {
prefix: "Mui",
icons: Icons as { [key: string]: React.ComponentType },
},
},
};

const createCustomWidgetPreviewApp = (
Expand Down
1 change: 1 addition & 0 deletions apps/starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@ensembleui/react-framework": "workspace:*",
"@ensembleui/react-runtime": "workspace:*",
"@mui/icons-material": "^6.4.1",
"firebase": "9.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
8 changes: 8 additions & 0 deletions apps/starter/src/ensemble/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ApplicationDTO } from "@ensembleui/react-framework";
import * as Icons from "@mui/icons-material";
// Screens
import MenuYAML from "./screens/menu.yaml";
import HomeYAML from "./screens/home.yaml";
Expand Down Expand Up @@ -55,4 +56,11 @@ export const starterApp: ApplicationDTO = {
content: String(HelpYAML),
},
],
icons: {
mui: { icons: Icons as { [key: string]: React.ComponentType } },
custom: {
prefix: "Mui",
icons: Icons as { [key: string]: React.ComponentType },
},
},
};
12 changes: 11 additions & 1 deletion packages/framework/src/shared/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ApplicationDTO extends Omit<EnsembleDocument, "content"> {
readonly languages?: LanguageDTO[];
readonly config?: string | EnsembleConfigYAML;
readonly fonts?: FontDTO[];

readonly icons: IconDTO;
readonly description?: string;
readonly isPublic?: boolean;
readonly isAutoGenerated?: boolean;
Expand Down Expand Up @@ -63,3 +63,13 @@ export interface FontDTO {
readonly fontWeight: string;
readonly fontStyle: string;
}

export interface IconSet {
readonly prefix?: string;
readonly icons?: { [key: string]: unknown };
}

export interface IconDTO {
readonly mui: IconSet;
readonly custom?: IconSet;
}
1 change: 0 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"@emotion/styled": "^11.11.0",
"@ensembleui/react-framework": "workspace:*",
"@lottiefiles/react-lottie-player": "^3.5.3",
"@mui/icons-material": "^5.14.9",
"@mui/material": "^5.14.9",
"@mui/x-date-pickers": "^6.18.3",
"@react-oauth/google": "^0.12.1",
Expand Down
22 changes: 20 additions & 2 deletions packages/runtime/src/EnsembleApp.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import type {
ApplicationDTO,
EnsembleAppModel,
ApplicationLoader,
IconSet,
} from "@ensembleui/react-framework";
import {
ApplicationContextProvider,
Expand All @@ -19,7 +20,7 @@ import { EnsembleScreen } from "./runtime/screen";
import { ErrorPage } from "./runtime/error";
// Register built in widgets;
import "./widgets";
import { WidgetRegistry } from "./registry";
import { IconRegistry, WidgetRegistry } from "./registry";
import { createCustomWidget } from "./runtime/customWidget";
import { ModalWrapper } from "./runtime/modal";

Expand Down Expand Up @@ -49,6 +50,16 @@ export const EnsembleApp: React.FC<EnsembleAppProps> = ({
return;
}

const registerIcons = (iconSet?: IconSet): void => {
const { prefix = "", icons = {} } = (iconSet || {}) as {
prefix?: string;
icons?: { [key: string]: React.ComponentType<unknown> };
};
Object.entries(icons).forEach(([name, icon]) => {
IconRegistry.register(`${prefix}${name}`, icon);
});
};

const parseApp = (appDto: ApplicationDTO): void => {
const parsedApp = EnsembleParser.parseApplication(appDto);
parsedApp.customWidgets.forEach((customWidget) => {
Expand All @@ -58,6 +69,13 @@ export const EnsembleApp: React.FC<EnsembleAppProps> = ({
);
});

if (!appDto.icons?.mui) {
throw new Error("An mui icons must be provided");
}

registerIcons(appDto.icons.mui);
registerIcons(appDto.icons?.custom);

setApp(parsedApp);
};

Expand Down
30 changes: 29 additions & 1 deletion packages/runtime/src/__tests__/EnsembleApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ test("Renders error page", () => {
themes: {},
});
try {
render(<EnsembleApp appId="test" application={{} as ApplicationDTO} />);
render(
<EnsembleApp
appId="test"
application={{ icons: { mui: {} } } as ApplicationDTO}
/>,
);
} catch (e) {
// no-op
}
Expand Down Expand Up @@ -98,6 +103,7 @@ test("Renders view widget of home screen", () => {
application={
{
screens: [{ content: "" }],
icons: { mui: {} },
} as ApplicationDTO
}
/>,
Expand Down Expand Up @@ -143,6 +149,7 @@ test("Bind data from other widgets", async () => {
application={
{
screens: [{ content: "" }],
icons: { mui: {} },
} as ApplicationDTO
}
/>,
Expand Down Expand Up @@ -192,6 +199,7 @@ test("Updates values through Ensemble state", async () => {
application={
{
screens: [{ content: "" }],
icons: { mui: {} },
} as ApplicationDTO
}
/>,
Expand All @@ -206,3 +214,23 @@ test("Updates values through Ensemble state", async () => {
const updatedText = await screen.findByText("Spiderman");
expect(updatedText).not.toBeNull();
});

test("Renders mui icon error", () => {
const logSpy = jest.spyOn(console, "error").mockImplementation(jest.fn());

parseApplicationMock.mockReturnValue({
home: {},
screens: [],
customWidgets: [],
themes: {},
});
try {
render(<EnsembleApp appId="test" application={{} as ApplicationDTO} />);
} catch (e) {
// no-op
}

expect(logSpy.mock.calls.toString()).toContain(
"An mui icons must be provided",
);
});
28 changes: 27 additions & 1 deletion packages/runtime/src/registry.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Alert } from "antd";
import type { ReactElement } from "react";

export type WidgetComponent<T> = React.FC<T>;
export type WidgetComponent<T> = React.ComponentType<T>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const registry: { [key: string]: WidgetComponent<any> | undefined } = {};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const iconRegistry: { [key: string]: WidgetComponent<any> | undefined } = {};

export const WidgetRegistry = {
register: <T,>(name: string, component: WidgetComponent<T>): void => {
registry[name] = component;
Expand All @@ -25,6 +28,29 @@ export const WidgetRegistry = {
},
};

export const IconRegistry = {
register: <T,>(name: string, component: WidgetComponent<T>): void => {
iconRegistry[name] = component;
},
find: (name: string): WidgetComponent<any> | ReactElement => {
const Icon = iconRegistry[name];
if (!Icon) {
return UnknownIcon;
}
return Icon;
},
findOrNull: (name: string): WidgetComponent<any> | null => {
return iconRegistry[name] || null;
},
unregister: (name: string): void => {
delete iconRegistry[name];
},
};

const UnknownWidget: React.FC<{ missingName: string }> = ({ missingName }) => {
return <Alert message={`Unknown widget: ${missingName}`} type="error" />;
};

const UnknownIcon: React.FC<{ name: string }> = ({ name }) => {
return <Alert message={`Unknown icon: ${name}`} type="error" />;
};
58 changes: 17 additions & 41 deletions packages/runtime/src/runtime/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { PropsWithChildren, ReactNode } from "react";
import type { PropsWithChildren } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Menu as AntMenu,
Col,
Drawer as AntDrawer,
ConfigProvider,
} from "antd";
import * as MuiIcons from "@mui/icons-material";
import {
unwrapWidget,
useRegisterBindings,
Expand All @@ -15,8 +14,9 @@ import {
EnsembleMenuModelType,
} from "@ensembleui/react-framework";
import { Outlet, Link, useLocation } from "react-router-dom";
import { cloneDeep, omit } from "lodash-es";
import { cloneDeep, isString, omit } from "lodash-es";
import { getColor } from "../shared/styles";
import type { IconProps } from "../shared/types";
import { EnsembleRuntime } from "./runtime";
// eslint-disable-next-line import/no-cycle
import { useEnsembleAction } from "./hooks";
Expand Down Expand Up @@ -48,8 +48,8 @@ export interface EnsembleMenuContext {
interface MenuItemProps {
id?: string;
testId?: string;
icon?: string | { [key: string]: unknown };
activeIcon?: string | { [key: string]: unknown };
icon?: string | IconProps;
activeIcon?: string | IconProps;
iconLibrary?: "default" | "fontAwesome";
label?: string;
url?: string;
Expand Down Expand Up @@ -98,29 +98,6 @@ interface DrawerMenuStyles extends MenuStyles {
position?: "left" | "right" | "top" | "bottom";
}

const renderMuiIcon = (
iconName?: string,
width = "15px",
height = "15px",
): ReactNode => {
if (!iconName) {
return null;
}

const MuiIconComponent = MuiIcons[iconName as keyof typeof MuiIcons];
if (MuiIconComponent) {
return (
<MuiIconComponent
style={{
width,
height,
}}
/>
);
}
return null;
};

const CustomLink: React.FC<PropsWithChildren & { item: MenuItemProps }> = ({
item,
children,
Expand Down Expand Up @@ -358,27 +335,26 @@ const MenuItems: React.FC<{
setSelectedItem,
isCollapsed = false,
}) => {
const getIcon = useCallback(
const getCustomIcon = useCallback(
(item: MenuItemProps) => {
const key = selectedItem === item.page ? "activeIcon" : "icon";
const icon =
const iconProps =
selectedItem === item.page && item.activeIcon
? item.activeIcon
: item.icon;

if (!icon) {
if (!iconProps) {
return null;
}

if (typeof icon === "string") {
return renderMuiIcon(icon, styles.iconWidth, styles.iconHeight);
}
return EnsembleRuntime.render([
{
...unwrapWidget({ Icon: icon }),
key,
},
]);
const icon = isString(iconProps)
? {
name: iconProps,
styles: { width: styles.iconWidth, height: styles.iconHeight },
}
: iconProps;

return EnsembleRuntime.render([{ ...unwrapWidget({ Icon: icon }), key }]);
},
[styles.iconHeight, styles.iconWidth, selectedItem],
);
Expand Down Expand Up @@ -425,7 +401,7 @@ const MenuItems: React.FC<{
{items.map((item, itemIndex) => (
<AntMenu.Item
data-testid={item.id ?? item.testId}
icon={getIcon(item)}
icon={getCustomIcon(item)}
key={item.page || item.url || `customItem${itemIndex}`}
onClick={(): void => {
if (!item.openNewTab && item.page) {
Expand Down
Loading
Loading