memoize Item, clicks on mobile are fast now!

This was a surprisingly big win! Item is heavier than it looks, because it has like 6 Chakra components, which aren't expensive but aren't _cheap_ in a re-rendered list that needs to be fast, you know?

And it's even more important on search, where there's a lot of items on the page. (we should virtualize it too but that's a thing for another day)
This commit is contained in:
Emi Matchu 2020-08-05 00:25:25 -07:00
parent bf76065faf
commit 8c653ce879
6 changed files with 84 additions and 63 deletions

View file

@ -29,12 +29,15 @@ const LoadableItemSupportDrawer = loadable(() =>
* In fact, this component can't trigger wear or unwear events! When you click
* it in the app, you're actually clicking a <label> that wraps the radio or
* checkbox. We _do_ control the Remove button in here, though!
*
* NOTE: This component is memoized with React.memo. It's surpisingly expensive
* to re-render, because Chakra components are a lil bit expensive from
* their internal complexity, and we have a lot of them here. And it can
* add up when there's a lot of Items in the list. This contributes to
* wearing/unwearing items being noticeably slower on lower-power
* devices.
*/
export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
const { wornItemIds, allItemIds } = outfitState;
const isWorn = wornItemIds.includes(item.id);
const isInOutfit = allItemIds.includes(item.id);
function Item({ item, itemNameId, isWorn, isInOutfit, dispatchToOutfit }) {
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return (
@ -80,7 +83,7 @@ export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
outfitState={outfitState}
outfitState="STOPSHIP"
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
@ -290,3 +293,5 @@ export function ItemListSkeleton({ count }) {
*/
const containerHasFocus =
".item-container:hover &, input:focus + .item-container &";
export default React.memo(Item);

View file

@ -14,7 +14,7 @@ import { EditIcon } from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "../util";
import { Item, ItemListContainer, ItemListSkeleton } from "./Item";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
/**
* ItemsPanel shows the items in the current outfit, and lets the user toggle
@ -120,7 +120,8 @@ function ItemZoneGroup({ zoneLabel, items, outfitState, dispatchToOutfit }) {
<Item
item={item}
itemNameId={itemNameId}
outfitState={outfitState}
isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit}
/>
</label>

View file

@ -4,7 +4,7 @@ import { Box, Text, VisuallyHidden } from "@chakra-ui/core";
import { useQuery } from "@apollo/client";
import { Delay, Heading1, useDebounce } from "../util";
import { Item, ItemListContainer, ItemListSkeleton } from "./Item";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
/**
@ -173,7 +173,8 @@ function SearchResults({
/>
<Item
item={item}
outfitState={outfitState}
isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit}
/>
</label>

View file

@ -4,7 +4,7 @@ import loadable from "@loadable/component";
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
import OutfitPreview from "../components/OutfitPreview";
import useOutfitState from "./useOutfitState.js";
import useOutfitState, { OutfitStateContext } from "./useOutfitState.js";
import { usePageTitle } from "../util";
const OutfitControls = loadable(() =>
@ -44,58 +44,64 @@ function WardrobePage() {
}, [error, toast]);
return (
<Box
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
// Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here!
zIndex="0"
>
<Grid
templateAreas={{
base: `"previewAndControls"
"itemsAndSearch"`,
lg: `"previewAndControls itemsAndSearch"`,
}}
templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)",
lg: "100%",
}}
templateColumns={{
base: "100%",
lg: "50% 50%",
}}
height="100%"
width="100%"
// NOTE: Most components pass around outfitState directly, to make the data
// relationships more explicit... but there are some deep components
// that need it, where it's more useful and more performant to access
// via context.
<OutfitStateContext.Provider value={outfitState}>
<Box
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
// Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here!
zIndex="0"
>
<Box gridArea="previewAndControls" bg="gray.900" pos="relative">
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitPreview
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
wornItemIds={outfitState.wornItemIds}
/>
<Grid
templateAreas={{
base: `"previewAndControls"
"itemsAndSearch"`,
lg: `"previewAndControls itemsAndSearch"`,
}}
templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)",
lg: "100%",
}}
templateColumns={{
base: "100%",
lg: "50% 50%",
}}
height="100%"
width="100%"
>
<Box gridArea="previewAndControls" bg="gray.900" pos="relative">
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitPreview
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
wornItemIds={outfitState.wornItemIds}
/>
</Box>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls
<Box gridArea="itemsAndSearch">
<ItemsAndSearchPanels
loading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
<Box gridArea="itemsAndSearch">
<ItemsAndSearchPanels
loading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Grid>
</Box>
</Grid>
</Box>
</OutfitStateContext.Provider>
);
}

View file

@ -28,6 +28,7 @@ import { CheckCircleIcon, EditIcon, ExternalLinkIcon } from "@chakra-ui/icons";
import ItemLayerSupportModal from "./ItemLayerSupportModal";
import { OutfitLayers } from "../../components/OutfitPreview";
import useOutfitAppearance from "../../components/useOutfitAppearance";
import { OutfitStateContext } from "../useOutfitState";
import useSupportSecret from "./useSupportSecret";
/**
@ -36,7 +37,7 @@ import useSupportSecret from "./useSupportSecret";
* This component controls the drawer element. The actual content is imported
* from another lazy-loaded component!
*/
function ItemSupportDrawer({ item, outfitState, isOpen, onClose }) {
function ItemSupportDrawer({ item, isOpen, onClose }) {
const placement = useBreakpointValue({
base: "bottom",
lg: "right",
@ -75,10 +76,7 @@ function ItemSupportDrawer({ item, outfitState, isOpen, onClose }) {
<Box paddingBottom="5">
<Stack spacing="8">
<ItemSupportSpecialColorFields item={item} />
<ItemSupportAppearanceFields
item={item}
outfitState={outfitState}
/>
<ItemSupportAppearanceFields item={item} />
</Stack>
</Box>
</DrawerBody>
@ -231,7 +229,15 @@ function ItemSupportSpecialColorFields({ item }) {
);
}
function ItemSupportAppearanceFields({ item, outfitState }) {
/**
* NOTE: This component takes `outfitState` from context, rather than as a prop
* from its parent, for performance reasons. We want `Item` to memoize
* and generally skip re-rendering on `outfitState` changes, and to make
* sure the context isn't accessed when the drawer is closed. So we use
* it here, only when the drawer is open!
*/
function ItemSupportAppearanceFields({ item }) {
const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({
speciesId,

View file

@ -7,6 +7,8 @@ import { itemAppearanceFragment } from "../components/useOutfitAppearance";
enableMapSet();
export const OutfitStateContext = React.createContext(null);
function useOutfitState() {
const apolloClient = useApolloClient();
const initialState = parseOutfitUrl();