Item page perf: memoize species faces
This is a pretty easy change, that makes re-renders faster when something about the item preview state changes! That said, the initial render is still pretty slow, too, and that's the one that's bothering me more lol
This commit is contained in:
parent
07bf555a02
commit
ab2dbeb02a
2 changed files with 242 additions and 222 deletions
|
@ -528,30 +528,40 @@ function ItemPageOutfitPreview({ itemId }) {
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPetStateFromUserAction = (newPetState) => {
|
const setPetStateFromUserAction = React.useCallback(
|
||||||
setPetState(newPetState);
|
(newPetState) =>
|
||||||
|
setPetState((prevPetState) => {
|
||||||
|
// When the user _intentionally_ chooses a species or color, save it in
|
||||||
|
// local storage for next time. (This won't update when e.g. their
|
||||||
|
// preferred species or color isn't available for this item, so we update
|
||||||
|
// to the canonical species or color automatically.)
|
||||||
|
//
|
||||||
|
// Re the "ifs", I have no reason to expect null to come in here, but,
|
||||||
|
// since this is touching client-persisted data, I want it to be even more
|
||||||
|
// reliable than usual!
|
||||||
|
if (
|
||||||
|
newPetState.speciesId &&
|
||||||
|
newPetState.speciesId !== prevPetState.speciesId
|
||||||
|
) {
|
||||||
|
setPreferredSpeciesId(newPetState.speciesId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newPetState.colorId &&
|
||||||
|
newPetState.colorId !== prevPetState.colorId
|
||||||
|
) {
|
||||||
|
if (colorIsBasic(newPetState.colorId)) {
|
||||||
|
// When the user chooses a basic color, don't index on it specifically,
|
||||||
|
// and instead reset to use default colors.
|
||||||
|
setPreferredColorId(null);
|
||||||
|
} else {
|
||||||
|
setPreferredColorId(newPetState.colorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When the user _intentionally_ chooses a species or color, save it in
|
return newPetState;
|
||||||
// local storage for next time. (This won't update when e.g. their
|
}),
|
||||||
// preferred species or color isn't available for this item, so we update
|
[setPreferredColorId, setPreferredSpeciesId]
|
||||||
// to the canonical species or color automatically.)
|
);
|
||||||
//
|
|
||||||
// Re the "ifs", I have no reason to expect null to come in here, but,
|
|
||||||
// since this is touching client-persisted data, I want it to be even more
|
|
||||||
// reliable than usual!
|
|
||||||
if (newPetState.speciesId && newPetState.speciesId !== petState.speciesId) {
|
|
||||||
setPreferredSpeciesId(newPetState.speciesId);
|
|
||||||
}
|
|
||||||
if (newPetState.colorId && newPetState.colorId !== petState.colorId) {
|
|
||||||
if (colorIsBasic(newPetState.colorId)) {
|
|
||||||
// When the user chooses a basic color, don't index on it specifically,
|
|
||||||
// and instead reset to use default colors.
|
|
||||||
setPreferredColorId(null);
|
|
||||||
} else {
|
|
||||||
setPreferredColorId(newPetState.colorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// We don't need to reload this query when preferred species/color change, so
|
// We don't need to reload this query when preferred species/color change, so
|
||||||
// cache their initial values here to use as query arguments.
|
// cache their initial values here to use as query arguments.
|
||||||
|
@ -694,6 +704,21 @@ function ItemPageOutfitPreview({ itemId }) {
|
||||||
const isCompatible = itemLayers.length > 0;
|
const isCompatible = itemLayers.length > 0;
|
||||||
const usesHTML5 = itemLayers.every(layerUsesHTML5);
|
const usesHTML5 = itemLayers.every(layerUsesHTML5);
|
||||||
|
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
({ speciesId, colorId }) => {
|
||||||
|
const validPoses = getValidPoses(valids, speciesId, colorId);
|
||||||
|
const pose = getClosestPose(validPoses, idealPose);
|
||||||
|
setPetStateFromUserAction({
|
||||||
|
speciesId,
|
||||||
|
colorId,
|
||||||
|
pose,
|
||||||
|
isValid: true,
|
||||||
|
appearanceId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[valids, idealPose, setPetStateFromUserAction]
|
||||||
|
);
|
||||||
|
|
||||||
const borderColor = useColorModeValue("green.700", "green.400");
|
const borderColor = useColorModeValue("green.700", "green.400");
|
||||||
const errorColor = useColorModeValue("red.600", "red.400");
|
const errorColor = useColorModeValue("red.600", "red.400");
|
||||||
|
|
||||||
|
@ -824,17 +849,7 @@ function ItemPageOutfitPreview({ itemId }) {
|
||||||
selectedColorId={petState.colorId}
|
selectedColorId={petState.colorId}
|
||||||
compatibleBodies={compatibleBodies}
|
compatibleBodies={compatibleBodies}
|
||||||
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||||
onChange={({ speciesId, colorId }) => {
|
onChange={onChange}
|
||||||
const validPoses = getValidPoses(valids, speciesId, colorId);
|
|
||||||
const pose = getClosestPose(validPoses, idealPose);
|
|
||||||
setPetStateFromUserAction({
|
|
||||||
speciesId,
|
|
||||||
colorId,
|
|
||||||
pose,
|
|
||||||
isValid: true,
|
|
||||||
appearanceId: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
isLoading={loadingGQL || loadingValids}
|
isLoading={loadingGQL || loadingValids}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -1087,195 +1102,197 @@ function SpeciesFacesPicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpeciesFaceOption({
|
const SpeciesFaceOption = React.memo(
|
||||||
speciesId,
|
({
|
||||||
speciesName,
|
speciesId,
|
||||||
colorId,
|
speciesName,
|
||||||
neopetsImageHash,
|
colorId,
|
||||||
isSelected,
|
neopetsImageHash,
|
||||||
bodyIsCompatible,
|
isSelected,
|
||||||
isValid,
|
bodyIsCompatible,
|
||||||
couldProbablyModelMoreData,
|
isValid,
|
||||||
onChange,
|
couldProbablyModelMoreData,
|
||||||
isLoading,
|
onChange,
|
||||||
}) {
|
isLoading,
|
||||||
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
}) => {
|
||||||
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
||||||
const focusBorderColor = "blue.400";
|
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
||||||
const focusBackgroundColor = "blue.100";
|
const focusBorderColor = "blue.400";
|
||||||
const [
|
const focusBackgroundColor = "blue.100";
|
||||||
selectedBorderColorValue,
|
const [
|
||||||
selectedBackgroundColorValue,
|
selectedBorderColorValue,
|
||||||
focusBorderColorValue,
|
selectedBackgroundColorValue,
|
||||||
focusBackgroundColorValue,
|
focusBorderColorValue,
|
||||||
] = useToken("colors", [
|
focusBackgroundColorValue,
|
||||||
selectedBorderColor,
|
] = useToken("colors", [
|
||||||
selectedBackgroundColor,
|
selectedBorderColor,
|
||||||
focusBorderColor,
|
selectedBackgroundColor,
|
||||||
focusBackgroundColor,
|
focusBorderColor,
|
||||||
]);
|
focusBackgroundColor,
|
||||||
const xlShadow = useToken("shadows", "xl");
|
]);
|
||||||
|
const xlShadow = useToken("shadows", "xl");
|
||||||
|
|
||||||
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
||||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||||
|
|
||||||
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
||||||
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
||||||
const emotionId = isHappy ? "1" : "2";
|
const emotionId = isHappy ? "1" : "2";
|
||||||
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
||||||
|
|
||||||
let disabledExplanation = null;
|
let disabledExplanation = null;
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
// If we're still loading, don't try to explain anything yet!
|
// If we're still loading, don't try to explain anything yet!
|
||||||
} else if (!isValid) {
|
} else if (!isValid) {
|
||||||
disabledExplanation = "(Can't be this color)";
|
disabledExplanation = "(Can't be this color)";
|
||||||
} else if (!bodyIsCompatible) {
|
} else if (!bodyIsCompatible) {
|
||||||
disabledExplanation = couldProbablyModelMoreData
|
disabledExplanation = couldProbablyModelMoreData
|
||||||
? "(Item needs models)"
|
? "(Item needs models)"
|
||||||
: "(Not compatible)";
|
: "(Not compatible)";
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLabel = (
|
const tooltipLabel = (
|
||||||
<div style={{ textAlign: "center" }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
{speciesName}
|
{speciesName}
|
||||||
{disabledExplanation && (
|
{disabledExplanation && (
|
||||||
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
||||||
{disabledExplanation}
|
{disabledExplanation}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
||||||
// elements like Box helps with render performance!
|
// elements like Box helps with render performance!
|
||||||
return (
|
return (
|
||||||
<ClassNames>
|
<ClassNames>
|
||||||
{({ css }) => (
|
{({ css }) => (
|
||||||
<DeferredTooltip
|
<DeferredTooltip
|
||||||
label={tooltipLabel}
|
label={tooltipLabel}
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={-10}
|
gutter={-10}
|
||||||
// We track hover and focus state manually for the tooltip, so that
|
// We track hover and focus state manually for the tooltip, so that
|
||||||
// keyboard nav to switch between options causes the tooltip to
|
// keyboard nav to switch between options causes the tooltip to
|
||||||
// follow. (By default, the tooltip appears on the first tab focus,
|
// follow. (By default, the tooltip appears on the first tab focus,
|
||||||
// but not when you _change_ options!)
|
// but not when you _change_ options!)
|
||||||
isOpen={labelIsHovered || inputIsFocused}
|
isOpen={labelIsHovered || inputIsFocused}
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{ cursor }}
|
|
||||||
onMouseEnter={() => setLabelIsHovered(true)}
|
|
||||||
onMouseLeave={() => setLabelIsHovered(false)}
|
|
||||||
>
|
>
|
||||||
<input
|
<label
|
||||||
type="radio"
|
style={{ cursor }}
|
||||||
aria-label={speciesName}
|
onMouseEnter={() => setLabelIsHovered(true)}
|
||||||
name="species-faces-picker"
|
onMouseLeave={() => setLabelIsHovered(false)}
|
||||||
value={speciesId}
|
|
||||||
checked={isSelected}
|
|
||||||
// It's possible to get this selected via the SpeciesColorPicker,
|
|
||||||
// even if this would normally be disabled. If so, make this
|
|
||||||
// option enabled, so keyboard users can focus and change it.
|
|
||||||
disabled={isDisabled && !isSelected}
|
|
||||||
onChange={() => onChange({ speciesId, colorId })}
|
|
||||||
onFocus={() => setInputIsFocused(true)}
|
|
||||||
onBlur={() => setInputIsFocused(false)}
|
|
||||||
className={css`
|
|
||||||
/* Copied from Chakra's <VisuallyHidden /> */
|
|
||||||
border: 0px;
|
|
||||||
clip: rect(0px, 0px, 0px, 0px);
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0px;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: absolute;
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
input:checked + & {
|
|
||||||
background: ${selectedBackgroundColorValue};
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: ${xlShadow},
|
|
||||||
${selectedBorderColorValue} 0 0 2px 2px;
|
|
||||||
transform: scale(1.2);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus + & {
|
|
||||||
background: ${focusBackgroundColorValue};
|
|
||||||
box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<CrossFadeImage
|
<input
|
||||||
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
type="radio"
|
||||||
srcSet={
|
aria-label={speciesName}
|
||||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
name="species-faces-picker"
|
||||||
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
value={speciesId}
|
||||||
}
|
checked={isSelected}
|
||||||
alt={speciesName}
|
// It's possible to get this selected via the SpeciesColorPicker,
|
||||||
width={55}
|
// even if this would normally be disabled. If so, make this
|
||||||
height={55}
|
// option enabled, so keyboard users can focus and change it.
|
||||||
data-is-loading={isLoading}
|
disabled={isDisabled && !isSelected}
|
||||||
data-is-disabled={isDisabled}
|
onChange={() => onChange({ speciesId, colorId })}
|
||||||
|
onFocus={() => setInputIsFocused(true)}
|
||||||
|
onBlur={() => setInputIsFocused(false)}
|
||||||
className={css`
|
className={css`
|
||||||
filter: saturate(90%);
|
/* Copied from Chakra's <VisuallyHidden /> */
|
||||||
opacity: 0.9;
|
border: 0px;
|
||||||
transition: all 0.2s;
|
clip: rect(0px, 0px, 0px, 0px);
|
||||||
|
height: 1px;
|
||||||
&[data-is-disabled="true"] {
|
width: 1px;
|
||||||
filter: saturate(0%);
|
margin: -1px;
|
||||||
opacity: 0.6;
|
padding: 0px;
|
||||||
}
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
&[data-is-loading="true"] {
|
position: absolute;
|
||||||
animation: 0.8s linear 0s infinite alternate none running
|
|
||||||
pulse;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + * &[data-body-is-disabled="false"] {
|
|
||||||
opacity: 1;
|
|
||||||
filter: saturate(110%);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + * &[data-body-is-disabled="true"] {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
from {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alt text for when the image fails to load! We hide it
|
|
||||||
* while still loading though! */
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
&:-moz-loading {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
&:-moz-broken {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div
|
||||||
</label>
|
className={css`
|
||||||
</DeferredTooltip>
|
overflow: hidden;
|
||||||
)}
|
transition: all 0.2s;
|
||||||
</ClassNames>
|
position: relative;
|
||||||
);
|
|
||||||
}
|
input:checked + & {
|
||||||
|
background: ${selectedBackgroundColorValue};
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: ${xlShadow},
|
||||||
|
${selectedBorderColorValue} 0 0 2px 2px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus + & {
|
||||||
|
background: ${focusBackgroundColorValue};
|
||||||
|
box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CrossFadeImage
|
||||||
|
src={`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
||||||
|
srcSet={
|
||||||
|
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
||||||
|
`https://pets.neopets-asset-proxy.openneo.net/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
||||||
|
}
|
||||||
|
alt={speciesName}
|
||||||
|
width={55}
|
||||||
|
height={55}
|
||||||
|
data-is-loading={isLoading}
|
||||||
|
data-is-disabled={isDisabled}
|
||||||
|
className={css`
|
||||||
|
filter: saturate(90%);
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&[data-is-disabled="true"] {
|
||||||
|
filter: saturate(0%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-is-loading="true"] {
|
||||||
|
animation: 0.8s linear 0s infinite alternate none running
|
||||||
|
pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="false"] {
|
||||||
|
opacity: 1;
|
||||||
|
filter: saturate(110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="true"] {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alt text for when the image fails to load! We hide it
|
||||||
|
* while still loading though! */
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
&:-moz-loading {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&:-moz-broken {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</DeferredTooltip>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) {
|
function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) {
|
||||||
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
||||||
|
|
|
@ -309,15 +309,18 @@ export function useLocalStorage(key, initialValue) {
|
||||||
|
|
||||||
const [storedValue, setStoredValue] = React.useState(loadValue);
|
const [storedValue, setStoredValue] = React.useState(loadValue);
|
||||||
|
|
||||||
const setValue = (value) => {
|
const setValue = React.useCallback(
|
||||||
try {
|
(value) => {
|
||||||
setStoredValue(value);
|
try {
|
||||||
window.localStorage.setItem(key, JSON.stringify(value));
|
setStoredValue(value);
|
||||||
storageListeners.forEach((l) => l());
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
} catch (error) {
|
storageListeners.forEach((l) => l());
|
||||||
console.error(error);
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
|
||||||
const reloadValue = React.useCallback(() => {
|
const reloadValue = React.useCallback(() => {
|
||||||
setStoredValue(loadValue());
|
setStoredValue(loadValue());
|
||||||
|
|
Loading…
Reference in a new issue