perf: group owned cosmetic variants (#4671)

> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.

## Description:

The owned Cosmetics grid rendered every pattern/palette combination as
an independent tile. For accounts with large cosmetics entitlements this
generated more than 1,200 canvas-backed PNG previews and caused a
multi-second main-thread microtask storm.

This change groups owned palettes into one tile per pattern, matching
the existing Store UI. Palettes remain selectable through the existing
swatch row, and the player's currently selected palette remains the
tile's initial preview and selection.

Validation:
- `vitest run tests/client/CosmeticButton.test.ts
tests/ResolveCosmetics.test.ts` (28 tests)
- `npm run build-prod`

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

jish
This commit is contained in:
Josh Harris
2026-07-22 12:47:07 -07:00
committed by GitHub
parent 42be9db71c
commit ebd22af95e
3 changed files with 61 additions and 16 deletions
+16 -15
View File
@@ -18,6 +18,7 @@ import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
groupCosmeticVariants,
resolveCosmetics,
ResolvedCosmetic,
resolvedToPlayerPattern,
@@ -129,24 +130,24 @@ export class CosmeticsModal extends BaseModal {
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map((r) => {
const isSelected =
${groupCosmeticVariants(items).map((group) => {
const selectedVariant = group.find((r) =>
r.type === "pattern"
? (r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null))
: (() => {
const skinName = (r.cosmetic as Skin | null)?.name ?? null;
return (
(skinName === null && this.selectedSkinName === null) ||
(skinName !== null && this.selectedSkinName === skinName)
);
})();
? r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null)
: (r.cosmetic as Skin | null)?.name === this.selectedSkinName,
);
const isSelected =
selectedVariant !== undefined ||
(group[0].cosmetic === null &&
this.selectedPattern === null &&
this.selectedSkinName === null);
return html`
<cosmetic-button
.resolved=${r}
.resolved=${selectedVariant ?? group[0]}
.variants=${group}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
></cosmetic-button>
+1 -1
View File
@@ -58,7 +58,7 @@ export class CosmeticButton extends LitElement {
const variants = this.variants;
if (variants && variants.length > 0) {
return (
variants.find((v) => v.key === this.activeVariantKey) ?? variants[0]
variants.find((v) => v.key === this.activeVariantKey) ?? this.resolved
);
}
return this.resolved;
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ResolvedCosmetic } from "../../src/client/Cosmetics";
import { CosmeticButton } from "../../src/client/components/CosmeticButton";
function patternVariant(name: string, palette: string): ResolvedCosmetic {
return {
type: "pattern",
cosmetic: { name, pattern: "AAAAAA" } as never,
colorPalette: {
name: palette,
primaryColor: "#ffffff",
secondaryColor: "#000000",
},
relationship: "owned",
key: `pattern:${name}:${palette}`,
};
}
describe("CosmeticButton variants", () => {
let button: CosmeticButton | undefined;
afterEach(() => {
button?.remove();
button = undefined;
});
it("uses the resolved variant until a swatch is selected", () => {
const red = patternVariant("stripes", "red");
const blue = patternVariant("stripes", "blue");
const onSelect = vi.fn();
button = new CosmeticButton();
button.resolved = blue;
button.variants = [red, blue];
button.onSelect = onSelect;
(
button as unknown as {
handleClick(): void;
}
).handleClick();
expect(onSelect).toHaveBeenCalledWith(blue);
});
});