From 02855848ea1bd40e9c0883c71280be814958ee37 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:02:46 +0000
Subject: [PATCH] perf(web): downsample static account avatars (#41655)
---
.../account-page/AvatarWithEdit.tsx | 4 +-
.../__tests__/AvatarWithEdit.spec.tsx | 92 +++++++++++++++++++
.../__tests__/avatar-image.spec.ts | 61 ++++++++++++
.../account-page/avatar-image.ts | 57 ++++++++++++
4 files changed, 212 insertions(+), 2 deletions(-)
create mode 100644 web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx
create mode 100644 web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts
create mode 100644 web/app/account/(commonLayout)/account-page/avatar-image.ts
diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
index 70c44d1b8a1..84d019f174c 100644
--- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
+++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
@@ -12,11 +12,11 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
-import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import Divider from '@/app/components/base/divider'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import { updateUserProfile } from '@/service/common'
+import { createCroppedAvatarImage } from './avatar-image'
type InputImageInfo =
| { file: File }
@@ -106,7 +106,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
handleLocalFileUpload(inputImageInfo.file)
return
}
- const blob = await getCroppedImg(
+ const blob = await createCroppedAvatarImage(
inputImageInfo.tempUrl,
inputImageInfo.croppedAreaPixels,
inputImageInfo.fileName,
diff --git a/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx b/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx
new file mode 100644
index 00000000000..f57b5ac4328
--- /dev/null
+++ b/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx
@@ -0,0 +1,92 @@
+import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
+import type { ImageFile } from '@/types/app'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { createCroppedAvatarImage } from '../avatar-image'
+import AvatarWithEdit from '../AvatarWithEdit'
+
+type LocalFileUploaderOptions = {
+ onUpload: (imageFile: ImageFile) => void
+}
+
+const mocks = vi.hoisted(() => ({
+ animatedFile: new File(['animated'], 'avatar.gif', { type: 'image/gif' }),
+ handleLocalFileUpload: vi.fn<(file: File) => void>(),
+}))
+
+vi.mock('@/config', () => ({ DISABLE_UPLOAD_IMAGE_AS_ICON: false }))
+
+vi.mock('@/app/components/base/app-icon-picker/ImageInput', () => ({
+ default: ({ onImageInput }: { onImageInput?: OnImageInput }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/image-uploader/hooks', () => ({
+ useLocalFileUploader: (_options: LocalFileUploaderOptions) => ({
+ handleLocalFileUpload: mocks.handleLocalFileUpload,
+ }),
+}))
+
+vi.mock('../avatar-image', () => ({
+ createCroppedAvatarImage: vi.fn(),
+}))
+
+const mockedCreateCroppedAvatarImage = vi.mocked(createCroppedAvatarImage)
+
+describe('AvatarWithEdit', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('uploads the bounded crop for a static avatar', async () => {
+ const user = userEvent.setup()
+ const blob = new Blob(['bounded-avatar'], { type: 'image/png' })
+ mockedCreateCroppedAvatarImage.mockResolvedValue(blob)
+ render()
+
+ await user.click(screen.getByRole('button', { name: /avatar\.editAction/i }))
+ await user.click(screen.getByRole('button', { name: 'Select static avatar' }))
+ await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i }))
+
+ await waitFor(() => {
+ expect(mockedCreateCroppedAvatarImage).toHaveBeenCalledWith(
+ 'blob:static-avatar',
+ { x: 10, y: 20, width: 1000, height: 1000 },
+ 'avatar.png',
+ )
+ expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'avatar.png', type: 'image/png' }),
+ )
+ })
+ })
+
+ it('keeps the existing original-file upload path for animated avatars', async () => {
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByRole('button', { name: /avatar\.editAction/i }))
+ await user.click(screen.getByRole('button', { name: 'Select animated avatar' }))
+ await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i }))
+
+ expect(mockedCreateCroppedAvatarImage).not.toHaveBeenCalled()
+ expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(mocks.animatedFile)
+ })
+})
diff --git a/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts b/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts
new file mode 100644
index 00000000000..74308136813
--- /dev/null
+++ b/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts
@@ -0,0 +1,61 @@
+import type { Area } from 'react-easy-crop'
+import { createImage } from '@/app/components/base/app-icon-picker/utils'
+import { createCroppedAvatarImage, getBoundedAvatarImageSize } from '../avatar-image'
+
+vi.mock('@/app/components/base/app-icon-picker/utils', async (importOriginal) => {
+ const actual =
+ await importOriginal()
+ return {
+ ...actual,
+ createImage: vi.fn(),
+ }
+})
+
+const mockedCreateImage = vi.mocked(createImage)
+
+describe('avatar image', () => {
+ describe('getBoundedAvatarImageSize', () => {
+ it('downsamples a large square crop to 256 pixels', () => {
+ expect(getBoundedAvatarImageSize({ width: 1000, height: 1000 })).toEqual({
+ width: 256,
+ height: 256,
+ })
+ })
+
+ it('does not upscale a crop that is already within the bound', () => {
+ expect(getBoundedAvatarImageSize({ width: 128, height: 128 })).toEqual({
+ width: 128,
+ height: 128,
+ })
+ })
+ })
+
+ it('draws the selected crop into a bounded high-quality canvas', async () => {
+ const image = {} as HTMLImageElement
+ const expectedBlob = new Blob(['avatar'], { type: 'image/png' })
+ const context = {
+ drawImage: vi.fn(),
+ imageSmoothingEnabled: false,
+ imageSmoothingQuality: 'low',
+ } as unknown as CanvasRenderingContext2D
+ const canvas = {
+ width: 0,
+ height: 0,
+ getContext: vi.fn(() => context),
+ toBlob: vi.fn((callback: BlobCallback) => callback(expectedBlob)),
+ } as unknown as HTMLCanvasElement
+ vi.spyOn(document, 'createElement').mockReturnValue(canvas)
+ mockedCreateImage.mockResolvedValue(image)
+ const crop: Area = { x: 40, y: 20, width: 1000, height: 1000 }
+
+ const result = await createCroppedAvatarImage('blob:avatar', crop, 'avatar.png')
+
+ expect(result).toBe(expectedBlob)
+ expect(canvas.width).toBe(256)
+ expect(canvas.height).toBe(256)
+ expect(context.imageSmoothingEnabled).toBe(true)
+ expect(context.imageSmoothingQuality).toBe('high')
+ expect(context.drawImage).toHaveBeenCalledWith(image, 40, 20, 1000, 1000, 0, 0, 256, 256)
+ expect(canvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', 0.85)
+ })
+})
diff --git a/web/app/account/(commonLayout)/account-page/avatar-image.ts b/web/app/account/(commonLayout)/account-page/avatar-image.ts
new file mode 100644
index 00000000000..84240d4211a
--- /dev/null
+++ b/web/app/account/(commonLayout)/account-page/avatar-image.ts
@@ -0,0 +1,57 @@
+import type { Area } from 'react-easy-crop'
+import { createImage, getMimeType } from '@/app/components/base/app-icon-picker/utils'
+
+const AVATAR_IMAGE_MAX_SIZE = 256
+const AVATAR_IMAGE_QUALITY = 0.85
+
+export const getBoundedAvatarImageSize = (
+ crop: Pick,
+ maxSize = AVATAR_IMAGE_MAX_SIZE,
+) => {
+ const scale = Math.min(1, maxSize / Math.max(crop.width, crop.height))
+
+ return {
+ width: Math.max(1, Math.round(crop.width * scale)),
+ height: Math.max(1, Math.round(crop.height * scale)),
+ }
+}
+
+export const createCroppedAvatarImage = async (
+ imageSrc: string,
+ pixelCrop: Area,
+ fileName: string,
+): Promise => {
+ const image = await createImage(imageSrc)
+ const canvas = document.createElement('canvas')
+ const context = canvas.getContext('2d')
+
+ if (!context) throw new Error('Could not create a canvas context')
+
+ const outputSize = getBoundedAvatarImageSize(pixelCrop)
+ canvas.width = outputSize.width
+ canvas.height = outputSize.height
+ context.imageSmoothingEnabled = true
+ context.imageSmoothingQuality = 'high'
+ context.drawImage(
+ image,
+ pixelCrop.x,
+ pixelCrop.y,
+ pixelCrop.width,
+ pixelCrop.height,
+ 0,
+ 0,
+ outputSize.width,
+ outputSize.height,
+ )
+
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (file) => {
+ if (file) resolve(file)
+ else reject(new Error('Could not create a blob'))
+ },
+ getMimeType(fileName),
+ AVATAR_IMAGE_QUALITY,
+ )
+ })
+}