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 * 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 * 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! * 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 }) { function Item({ item, itemNameId, isWorn, isInOutfit, dispatchToOutfit }) {
const { wornItemIds, allItemIds } = outfitState;
const isWorn = wornItemIds.includes(item.id);
const isInOutfit = allItemIds.includes(item.id);
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false); const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return ( return (
@ -80,7 +83,7 @@ export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
<SupportOnly> <SupportOnly>
<LoadableItemSupportDrawer <LoadableItemSupportDrawer
item={item} item={item}
outfitState={outfitState} outfitState="STOPSHIP"
isOpen={supportDrawerIsOpen} isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)} onClose={() => setSupportDrawerIsOpen(false)}
/> />
@ -290,3 +293,5 @@ export function ItemListSkeleton({ count }) {
*/ */
const containerHasFocus = const containerHasFocus =
".item-container:hover &, input:focus + .item-container &"; ".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 { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "../util"; 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 * 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={item} item={item}
itemNameId={itemNameId} itemNameId={itemNameId}
outfitState={outfitState} isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</label> </label>

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import { CheckCircleIcon, EditIcon, ExternalLinkIcon } from "@chakra-ui/icons";
import ItemLayerSupportModal from "./ItemLayerSupportModal"; import ItemLayerSupportModal from "./ItemLayerSupportModal";
import { OutfitLayers } from "../../components/OutfitPreview"; import { OutfitLayers } from "../../components/OutfitPreview";
import useOutfitAppearance from "../../components/useOutfitAppearance"; import useOutfitAppearance from "../../components/useOutfitAppearance";
import { OutfitStateContext } from "../useOutfitState";
import useSupportSecret from "./useSupportSecret"; import useSupportSecret from "./useSupportSecret";
/** /**
@ -36,7 +37,7 @@ import useSupportSecret from "./useSupportSecret";
* This component controls the drawer element. The actual content is imported * This component controls the drawer element. The actual content is imported
* from another lazy-loaded component! * from another lazy-loaded component!
*/ */
function ItemSupportDrawer({ item, outfitState, isOpen, onClose }) { function ItemSupportDrawer({ item, isOpen, onClose }) {
const placement = useBreakpointValue({ const placement = useBreakpointValue({
base: "bottom", base: "bottom",
lg: "right", lg: "right",
@ -75,10 +76,7 @@ function ItemSupportDrawer({ item, outfitState, isOpen, onClose }) {
<Box paddingBottom="5"> <Box paddingBottom="5">
<Stack spacing="8"> <Stack spacing="8">
<ItemSupportSpecialColorFields item={item} /> <ItemSupportSpecialColorFields item={item} />
<ItemSupportAppearanceFields <ItemSupportAppearanceFields item={item} />
item={item}
outfitState={outfitState}
/>
</Stack> </Stack>
</Box> </Box>
</DrawerBody> </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 { speciesId, colorId, pose } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({ const { error, visibleLayers } = useOutfitAppearance({
speciesId, speciesId,

View file

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