From a46d34bd6094cc817674425ad06a0041b5ab280e Mon Sep 17 00:00:00 2001 From: David Walker Date: Sun, 4 Dec 2022 18:05:55 -0800 Subject: [PATCH] UI: Break SidebarRoot into smaller components, and memoize (#246) --- src/Sidebar/ui/SidebarAccordion.tsx | 90 ++++ src/Sidebar/ui/SidebarItem.tsx | 53 +++ src/Sidebar/ui/SidebarRoot.tsx | 681 ++++++---------------------- 3 files changed, 280 insertions(+), 544 deletions(-) create mode 100644 src/Sidebar/ui/SidebarAccordion.tsx create mode 100644 src/Sidebar/ui/SidebarItem.tsx diff --git a/src/Sidebar/ui/SidebarAccordion.tsx b/src/Sidebar/ui/SidebarAccordion.tsx new file mode 100644 index 000000000..4e505caba --- /dev/null +++ b/src/Sidebar/ui/SidebarAccordion.tsx @@ -0,0 +1,90 @@ +import React, { useMemo, useState } from "react"; +import Collapse from "@mui/material/Collapse"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +import { SidebarItem, ICreateProps as IItemProps } from "./SidebarItem"; +import type { Page } from "../../ui/Router"; + +interface IProps { + key_: string; + page: Page; + clickPage: (page: Page) => void; + flash: Page | null; + items: (IItemProps | boolean)[]; + icon: React.ReactElement; + sidebarOpen: boolean; + classes: any; +} + +// We can't useCallback for this, because in the items map it would be +// called a changing number of times, and hooks can't be called in loops. So +// we set up this explicit cache of function objects instead. +// This is at module scope, because it's fine for all Accordions to share the +// same cache. +// WeakMap prevents memory leaks. We won't drop slices of the cache too soon, +// because the fn keys are themselves memoized elsewhere, which keeps them +// alive and thus keeps the WeakMap entries alive. +const clickFnCache = new WeakMap(); +function getClickFn(toWrap: (page: Page) => void, page: Page) { + let first = clickFnCache.get(toWrap); + if (first === undefined) { + first = {}; + clickFnCache.set(toWrap, first); + } + // Short-circuit: Avoid assign/eval of function on found + return (first[page] ??= () => toWrap(page)); +} + +// This can't be usefully memoized, because props.items is a new array every time. +export function SidebarAccordion(props: IProps): React.ReactElement { + const [open, setOpen] = useState(true); + // Obnoxious, because we can't modify props at all. + const li_classes = useMemo(() => ({ root: props.classes.listitem }), [props.classes.listitem]); + const icon = Object.assign({}, props.icon); + icon.props = Object.assign({ color: "primary" }, icon.props); + + // Explicitily useMemo() to save rerendering deep chunks of this tree. + // memo() can't be (easily) used on components like , because the + // props.children array will be a different object every time. + return ( + <> + {useMemo( + () => ( + setOpen((open) => !open)}> + + + + {props.key_}} /> + {open ? : } + + ), + [li_classes, props.sidebarOpen, props.key_, open, props.icon.type], + )} + + {props.items.map((x) => { + if (typeof x !== "object") return null; + const { key_, icon, count, active } = x; + return ( + + ); + })} + + + ); +} diff --git a/src/Sidebar/ui/SidebarItem.tsx b/src/Sidebar/ui/SidebarItem.tsx new file mode 100644 index 000000000..8ce637388 --- /dev/null +++ b/src/Sidebar/ui/SidebarItem.tsx @@ -0,0 +1,53 @@ +import React, { memo } from "react"; +import Badge from "@mui/material/Badge"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +import type { Page } from "../../ui/Router"; + +export interface ICreateProps { + key_: Page; + icon: React.ReactElement; + count?: number; + active?: boolean; +} + +export interface IProps extends ICreateProps { + clickFn: () => void; + flash: boolean; + classes: any; + sidebarOpen: boolean; +} + +export const SidebarItem = memo(function (props: IProps): React.ReactElement { + // Use icon as a template. (We can't modify props) + const icon: React.ReactElement = { + type: props.icon.type, + key: props.icon.key, + props: { + color: props.flash ? "error" : !props.active ? "secondary" : "primary", + ...props.icon.props, + }, + }; + return ( + + + 0 ? props.count : undefined} color="error"> + + + + + + + + ); +}); diff --git a/src/Sidebar/ui/SidebarRoot.tsx b/src/Sidebar/ui/SidebarRoot.tsx index ee6b0ca04..a5b30b926 100644 --- a/src/Sidebar/ui/SidebarRoot.tsx +++ b/src/Sidebar/ui/SidebarRoot.tsx @@ -1,6 +1,5 @@ -import React, { useCallback, useState, useEffect } from "react"; +import React, { useMemo, useCallback, useState, useEffect } from "react"; import { KEYCODE } from "../../utils/helpers/keyCodes"; -import clsx from "clsx"; import { styled, Theme, CSSObject } from "@mui/material/styles"; import createStyles from "@mui/styles/createStyles"; import makeStyles from "@mui/styles/makeStyles"; @@ -14,8 +13,6 @@ import ListItem from "@mui/material/ListItem"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Typography from "@mui/material/Typography"; -import Collapse from "@mui/material/Collapse"; -import Badge from "@mui/material/Badge"; import ComputerIcon from "@mui/icons-material/Computer"; import LastPageIcon from "@mui/icons-material/LastPage"; // Terminal @@ -42,11 +39,10 @@ import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; // Achievements import AccountBoxIcon from "@mui/icons-material/AccountBox"; import PublicIcon from "@mui/icons-material/Public"; import LiveHelpIcon from "@mui/icons-material/LiveHelp"; -import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Router } from "../../ui/GameRoot"; import { Page, SimplePage } from "../../ui/Router"; +import { SidebarAccordion } from "./SidebarAccordion"; import { Player } from "@player"; import { CONSTANTS } from "../../Constants"; import { iTutorialSteps, iTutorialNextStep, ITutorial } from "../../InteractiveTutorial"; @@ -59,6 +55,39 @@ import { InvitationsSeen } from "../../Faction/ui/FactionsRoot"; import { hash } from "../../hash/hash"; import { Locations } from "../../Locations/Locations"; +// All icon instances need to be constant, so they have stable object identity. +// Otherwise, the memoization of all the higher-level components doesn't work. +const computerIcon = ; +const lastPageIcon = ; +const createIcon = ; +const storageIcon = ; +const bugReportIcon = ; +const equalizerIcon = ; +const contactsIcon = ; +const doubleArrowIcon = ; +const accountTreeIcon = ; +const peopleAltIcon = ; +const locationCityIcon = ; +const airplanemodeActiveIcon = ; +const workIcon = ; +const trendingUpIcon = ; +const formatBoldIcon = ; +const businessIcon = ; +const sportsMmaIcon = ; +const checkIcon = ; +const helpIcon = ; +const settingsIcon = ; +const developerBoardIcon = ; +const emojiEventsIcon = ; +const accountBoxIcon = ; +const publicIcon = ; +const liveHelpIcon = ; +const chevronLeftIcon = ; +const chevronRightIcon = ; + +// Use constant Dividers just for performance +const divider = ; + const openedMixin = (theme: Theme): CSSObject => ({ width: theme.spacing(31), transition: theme.transitions.create("width", { @@ -118,11 +147,6 @@ export function SidebarRoot(props: IProps): React.ReactElement { return () => clearInterval(id); }, []); - const [hackingOpen, setHackingOpen] = useState(true); - const [characterOpen, setCharacterOpen] = useState(true); - const [worldOpen, setWorldOpen] = useState(true); - const [helpOpen, setHelpOpen] = useState(true); - let flash: Page | null = null; switch (ITutorial.currStep) { case iTutorialSteps.CharacterGoToTerminalPage: @@ -270,543 +294,112 @@ export function SidebarRoot(props: IProps): React.ReactElement { Settings.IsSidebarOpened = !old; return !old; }); + const li_classes = useMemo(() => ({ root: classes.listitem }), [classes.listitem]); + // Explicitily useMemo() to save rerendering deep chunks of this tree. + // memo() can't be (easily) used on components like , because the + // props.children array will be a different object every time. return ( - - - {!open ? : } - - - Bitburner v{CONSTANTS.VersionString} - - } - /> - - + {useMemo( + () => ( + + {!open ? chevronRightIcon : chevronLeftIcon} + + Bitburner v{CONSTANTS.VersionString} + + } + /> + + ), + [li_classes, open], + )} + {divider} - setHackingOpen((old) => !old)}> - - - - - - Hacking} /> - {hackingOpen ? : } - - - - clickPage(Page.Terminal)} - > - - - - - - - - Terminal - - - - clickPage(Page.ScriptEditor)} - > - - - - - - - - Script Editor - - - - clickPage(Page.ActiveScripts)} - > - - - - - - - - Active Scripts - - - - clickPage(Page.CreateProgram)} - > - - 0 ? programCount : undefined} color="error"> - - - - - - - - Create Program - - - - {canStaneksGift && ( - clickPage(Page.StaneksGift)} - > - - - - - - - - Stanek's Gift - - - - )} - - - - - setCharacterOpen((old) => !old)}> - - - - - - Character} /> - {characterOpen ? : } - - - clickPage(Page.Stats)} - > - - - - - - - - Stats - - - - {canOpenFactions && ( - clickPage(Page.Factions)} - > - - - - - - - - - - Factions - - - - )} - {canOpenAugmentations && ( - clickPage(Page.Augmentations)} - > - - - - - - - - - - Augmentations - - - - )} - clickPage(Page.Hacknet)} - > - - - - - - - - Hacknet - - - - {canOpenSleeves && ( - clickPage(Page.Sleeves)} - > - - - - - - - Sleeves - - - )} - - - - setWorldOpen((old) => !old)}> - - - - - - World} /> - {worldOpen ? : } - - - clickPage(Page.City)} - > - - - - - - - - City - - - - clickPage(Page.Travel)} - > - - - - - - - Travel - - - {canJob && ( - clickPage(Page.Job)} - > - - - - - - - Job - - - )} - {canStockMarket && ( - clickPage(Page.StockMarket)} - > - - - - - - - Stock Market - - - )} - {canBladeburner && ( - clickPage(Page.Bladeburner)} - > - - - - - - - Bladeburner - - - )} - {canCorporation && ( - clickPage(Page.Corporation)} - > - - - - - - - Corp - - - )} - {canGang && ( - clickPage(Page.Gang)} - > - - - - - - - Gang - - - )} - - - - setHelpOpen((old) => !old)}> - - - - - - Help} /> - {helpOpen ? : } - - - clickPage(Page.Milestones)} - > - - - - - - - Milestones - - - clickPage(Page.Tutorial)} - > - - - - - - - - Tutorial - - - - clickPage(Page.Achievements)} - > - - - - - - - Achievements - - - clickPage(Page.Options)} - > - - - - - - - Options - - - {process.env.NODE_ENV === "development" && ( - clickPage(Page.DevMenu)} - > - - - - - - - Dev - - - )} - + + {divider} + + {divider} + + {divider} + );