From bf76065faf4d8af638888f6e0cb3d891d9ff50d5 Mon Sep 17 00:00:00 2001 From: Matchu Date: Tue, 4 Aug 2020 23:58:52 -0700 Subject: [PATCH] Perf: memoize some OutfitControls components I noticed that item wear/unwear is slow on mobile, because we re-render the whole app tree, and my laptop handles that super fine, but my few-years-old fun takes ~300ms, which is very noticeable. There's some hacks we could do to get faster feedback, but first I'm diving into the render tree to find the unnecessary renders and stop 'em! That should help build perf across the board, rather than in just one spot, and hopefully be less of a weird sore spot :) --- src/app/WardrobePage/OutfitControls.js | 59 ++++++++++++++++-------- src/app/WardrobePage/PosePicker.js | 33 +++++++++---- src/app/components/SpeciesColorPicker.js | 7 ++- 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/app/WardrobePage/OutfitControls.js b/src/app/WardrobePage/OutfitControls.js index 80e8d5e..b0c67be 100644 --- a/src/app/WardrobePage/OutfitControls.js +++ b/src/app/WardrobePage/OutfitControls.js @@ -27,7 +27,40 @@ import { Link } from "react-router-dom"; */ function OutfitControls({ outfitState, dispatchToOutfit }) { const [focusIsLocked, setFocusIsLocked] = React.useState(false); - const toast = useToast(); + const onLockFocus = React.useCallback(() => setFocusIsLocked(true), [ + setFocusIsLocked, + ]); + const onUnlockFocus = React.useCallback(() => setFocusIsLocked(false), [ + setFocusIsLocked, + ]); + + // HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily, + // which triggers unnecessary rebuilds of the `onSpeciesColorChange` + // callback, which causes the `React.memo` on `SpeciesColorPicker` to + // fail, which harms performance. But it seems to work just fine if we + // hold onto the first copy of the function we get! :/ + const _toast = useToast(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const toast = React.useMemo(() => _toast, []); + + const onSpeciesColorChange = React.useCallback( + (species, color, isValid, closestPose) => { + if (isValid) { + dispatchToOutfit({ + type: "setSpeciesAndColor", + speciesId: species.id, + colorId: color.id, + pose: closestPose, + }); + } else { + toast({ + title: `We haven't seen a ${color.name} ${species.name} before! 😓`, + status: "warning", + }); + } + }, + [dispatchToOutfit, toast] + ); return ( { - if (isValid) { - dispatchToOutfit({ - type: "setSpeciesAndColor", - speciesId: species.id, - colorId: color.id, - pose: closestPose, - }); - } else { - toast({ - title: `We haven't seen a ${color.name} ${species.name} before! 😓`, - status: "warning", - }); - } - }} + onChange={onSpeciesColorChange} /> setFocusIsLocked(true)} - onUnlockFocus={() => setFocusIsLocked(false)} + onLockFocus={onLockFocus} + onUnlockFocus={onUnlockFocus} /> diff --git a/src/app/WardrobePage/PosePicker.js b/src/app/WardrobePage/PosePicker.js index db65bd0..8acff36 100644 --- a/src/app/WardrobePage/PosePicker.js +++ b/src/app/WardrobePage/PosePicker.js @@ -28,15 +28,30 @@ import twemojiSick from "../../images/twemoji/sick.svg"; import twemojiMasc from "../../images/twemoji/masc.svg"; import twemojiFem from "../../images/twemoji/fem.svg"; +/** + * PosePicker shows the pet poses available on the current species/color, and + * lets the user choose which want they want! + * + * NOTE: This component is memoized with React.memo. It's relatively expensive + * to re-render on every outfit change - the contents update even if the + * popover is closed! This makes wearing/unwearing items noticeably + * slower on lower-power devices. + * + * So, instead of using `outfitState` like most components, we specify + * exactly which props we need, so that `React.memo` can see the changes + * that matter, and skip updates that don't. + */ function PosePicker({ - outfitState, + speciesId, + colorId, + pose, dispatchToOutfit, onLockFocus, onUnlockFocus, }) { const theme = useTheme(); const checkedInputRef = React.useRef(); - const { loading, error, poseInfos } = usePoses(outfitState); + const { loading, error, poseInfos } = usePoses(speciesId, colorId); if (loading) { return null; @@ -98,13 +113,13 @@ function PosePicker({ isOpen && "is-open" )} > - {getEmotion(outfitState.pose) === "HAPPY" && ( + {getEmotion(pose) === "HAPPY" && ( )} - {getEmotion(outfitState.pose) === "SAD" && ( + {getEmotion(pose) === "SAD" && ( )} - {getEmotion(outfitState.pose) === "SICK" && ( + {getEmotion(pose) === "SICK" && ( )} @@ -345,9 +360,7 @@ function EmojiImage({ src, alt }) { return {alt}; } -function usePoses(outfitState) { - const { speciesId, colorId } = outfitState; - +function usePoses(speciesId, colorId, selectedPose) { const { loading, error, data } = useQuery( gql` query PosePicker($speciesId: ID!, $colorId: ID!) { @@ -370,7 +383,7 @@ function usePoses(outfitState) { return { ...appearance, isAvailable: Boolean(appearance), - isSelected: outfitState.pose === pose, + isSelected: selectedPose === pose, }; }; @@ -459,4 +472,4 @@ const transformsByBodyId = { default: "scale(2.5)", }; -export default PosePicker; +export default React.memo(PosePicker); diff --git a/src/app/components/SpeciesColorPicker.js b/src/app/components/SpeciesColorPicker.js index 0d6a953..d3f6351 100644 --- a/src/app/components/SpeciesColorPicker.js +++ b/src/app/components/SpeciesColorPicker.js @@ -10,6 +10,11 @@ import { Delay, useFetch } from "../util"; * * It preloads all species, colors, and valid species/color pairs; and then * ensures that the outfit is always in a valid state. + * + * NOTE: This component is memoized with React.memo. It's not the cheapest to + * re-render on every outfit change. This contributes to + * wearing/unwearing items being noticeably slower on lower-power + * devices. */ function SpeciesColorPicker({ speciesId, @@ -303,4 +308,4 @@ const closestPosesInOrder = { ], }; -export default SpeciesColorPicker; +export default React.memo(SpeciesColorPicker);