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 :)
This commit is contained in:
Emi Matchu 2020-08-04 23:58:52 -07:00
parent 0e09510c54
commit bf76065faf
3 changed files with 69 additions and 30 deletions

View file

@ -27,7 +27,40 @@ import { Link } from "react-router-dom";
*/ */
function OutfitControls({ outfitState, dispatchToOutfit }) { function OutfitControls({ outfitState, dispatchToOutfit }) {
const [focusIsLocked, setFocusIsLocked] = React.useState(false); 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 ( return (
<Box <Box
@ -94,29 +127,17 @@ function OutfitControls({ outfitState, dispatchToOutfit }) {
colorId={outfitState.colorId} colorId={outfitState.colorId}
idealPose={outfitState.pose} idealPose={outfitState.pose}
dark dark
onChange={(species, color, isValid, closestPose) => { onChange={onSpeciesColorChange}
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",
});
}
}}
/> />
</Box> </Box>
<Flex flex="1 1 0" align="center" pl="4"> <Flex flex="1 1 0" align="center" pl="4">
<PosePicker <PosePicker
outfitState={outfitState} speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
onLockFocus={() => setFocusIsLocked(true)} onLockFocus={onLockFocus}
onUnlockFocus={() => setFocusIsLocked(false)} onUnlockFocus={onUnlockFocus}
/> />
</Flex> </Flex>
</Flex> </Flex>

View file

@ -28,15 +28,30 @@ import twemojiSick from "../../images/twemoji/sick.svg";
import twemojiMasc from "../../images/twemoji/masc.svg"; import twemojiMasc from "../../images/twemoji/masc.svg";
import twemojiFem from "../../images/twemoji/fem.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({ function PosePicker({
outfitState, speciesId,
colorId,
pose,
dispatchToOutfit, dispatchToOutfit,
onLockFocus, onLockFocus,
onUnlockFocus, onUnlockFocus,
}) { }) {
const theme = useTheme(); const theme = useTheme();
const checkedInputRef = React.useRef(); const checkedInputRef = React.useRef();
const { loading, error, poseInfos } = usePoses(outfitState); const { loading, error, poseInfos } = usePoses(speciesId, colorId);
if (loading) { if (loading) {
return null; return null;
@ -98,13 +113,13 @@ function PosePicker({
isOpen && "is-open" isOpen && "is-open"
)} )}
> >
{getEmotion(outfitState.pose) === "HAPPY" && ( {getEmotion(pose) === "HAPPY" && (
<EmojiImage src={twemojiSmile} alt="Choose a pose" /> <EmojiImage src={twemojiSmile} alt="Choose a pose" />
)} )}
{getEmotion(outfitState.pose) === "SAD" && ( {getEmotion(pose) === "SAD" && (
<EmojiImage src={twemojiCry} alt="Choose a pose" /> <EmojiImage src={twemojiCry} alt="Choose a pose" />
)} )}
{getEmotion(outfitState.pose) === "SICK" && ( {getEmotion(pose) === "SICK" && (
<EmojiImage src={twemojiSick} alt="Choose a pose" /> <EmojiImage src={twemojiSick} alt="Choose a pose" />
)} )}
</Button> </Button>
@ -345,9 +360,7 @@ function EmojiImage({ src, alt }) {
return <img src={src} alt={alt} width="16px" height="16px" />; return <img src={src} alt={alt} width="16px" height="16px" />;
} }
function usePoses(outfitState) { function usePoses(speciesId, colorId, selectedPose) {
const { speciesId, colorId } = outfitState;
const { loading, error, data } = useQuery( const { loading, error, data } = useQuery(
gql` gql`
query PosePicker($speciesId: ID!, $colorId: ID!) { query PosePicker($speciesId: ID!, $colorId: ID!) {
@ -370,7 +383,7 @@ function usePoses(outfitState) {
return { return {
...appearance, ...appearance,
isAvailable: Boolean(appearance), isAvailable: Boolean(appearance),
isSelected: outfitState.pose === pose, isSelected: selectedPose === pose,
}; };
}; };
@ -459,4 +472,4 @@ const transformsByBodyId = {
default: "scale(2.5)", default: "scale(2.5)",
}; };
export default PosePicker; export default React.memo(PosePicker);

View file

@ -10,6 +10,11 @@ import { Delay, useFetch } from "../util";
* *
* It preloads all species, colors, and valid species/color pairs; and then * It preloads all species, colors, and valid species/color pairs; and then
* ensures that the outfit is always in a valid state. * 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({ function SpeciesColorPicker({
speciesId, speciesId,
@ -303,4 +308,4 @@ const closestPosesInOrder = {
], ],
}; };
export default SpeciesColorPicker; export default React.memo(SpeciesColorPicker);