Skip to content

Commit cc646b9

Browse files
faster avatar (#28633)
1 parent 909d731 commit cc646b9

File tree

6 files changed

+182
-24
lines changed

6 files changed

+182
-24
lines changed

shared/common-adapters/avatar/avatar.css

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,131 @@
11
/* Used by avatar on desktop to speed up perf */
2+
/* Outer container allows follow icons to extend beyond bounds */
3+
/* Inner wrapper has overflow:hidden + border-radius to clip images without re-rasterizing */
24

35
.avatar {
46
user-select: none;
57
flex-shrink: 0;
68
position: relative;
79
}
810

9-
.avatar-team-size-128 {
11+
.avatar-inner {
12+
overflow: hidden;
13+
position: relative;
14+
/* Help browser optimize repaints/compositing */
15+
contain: layout style paint;
16+
}
17+
18+
/* Outer container sizes - no border-radius so follow icons aren't clipped */
19+
.avatar.avatar-team-size-128,
20+
.avatar.avatar-user-size-128 {
21+
width: 128px;
22+
height: 128px;
23+
}
24+
.avatar.avatar-team-size-16,
25+
.avatar.avatar-user-size-16 {
26+
width: 16px;
27+
height: 16px;
28+
}
29+
.avatar.avatar-team-size-24,
30+
.avatar.avatar-user-size-24 {
31+
width: 24px;
32+
height: 24px;
33+
}
34+
.avatar.avatar-team-size-32,
35+
.avatar.avatar-user-size-32 {
36+
width: 32px;
37+
height: 32px;
38+
}
39+
.avatar.avatar-team-size-48,
40+
.avatar.avatar-user-size-48 {
41+
width: 48px;
42+
height: 48px;
43+
}
44+
.avatar.avatar-team-size-64,
45+
.avatar.avatar-user-size-64 {
46+
width: 64px;
47+
height: 64px;
48+
}
49+
.avatar.avatar-team-size-96,
50+
.avatar.avatar-user-size-96 {
51+
width: 96px;
52+
height: 96px;
53+
}
54+
55+
/* Inner wrapper sizes with border-radius for clipping */
56+
.avatar-inner.avatar-team-size-128 {
1057
border-radius: 16px;
1158
width: 128px;
1259
height: 128px;
1360
}
14-
.avatar-team-size-16 {
61+
.avatar-inner.avatar-team-size-16 {
1562
border-radius: 4px;
1663
width: 16px;
1764
height: 16px;
1865
}
19-
.avatar-team-size-24 {
66+
.avatar-inner.avatar-team-size-24 {
2067
border-radius: 6px;
2168
width: 24px;
2269
height: 24px;
2370
}
24-
.avatar-team-size-32 {
71+
.avatar-inner.avatar-team-size-32 {
2572
border-radius: 6px;
2673
width: 32px;
2774
height: 32px;
2875
}
29-
.avatar-team-size-48 {
76+
.avatar-inner.avatar-team-size-48 {
3077
border-radius: 8px;
3178
width: 48px;
3279
height: 48px;
3380
}
34-
.avatar-team-size-64 {
81+
.avatar-inner.avatar-team-size-64 {
3582
border-radius: 10px;
3683
width: 64px;
3784
height: 64px;
3885
}
39-
.avatar-team-size-96 {
86+
.avatar-inner.avatar-team-size-96 {
4087
border-radius: 12px;
4188
width: 96px;
4289
height: 96px;
4390
}
4491

45-
.avatar-user-size-128 {
92+
.avatar-inner.avatar-user-size-128 {
4693
border-radius: 50%;
4794
width: 128px;
4895
height: 128px;
4996
}
50-
.avatar-user-size-16 {
97+
.avatar-inner.avatar-user-size-16 {
5198
border-radius: 50%;
5299
width: 16px;
53100
height: 16px;
54101
}
55-
.avatar-user-size-24 {
102+
.avatar-inner.avatar-user-size-24 {
56103
border-radius: 50%;
57104
width: 24px;
58105
height: 24px;
59106
}
60-
.avatar-user-size-32 {
107+
.avatar-inner.avatar-user-size-32 {
61108
border-radius: 50%;
62109
width: 32px;
63110
height: 32px;
64111
}
65-
.avatar-user-size-48 {
112+
.avatar-inner.avatar-user-size-48 {
66113
border-radius: 50%;
67114
width: 48px;
68115
height: 48px;
69116
}
70-
.avatar-user-size-64 {
117+
.avatar-inner.avatar-user-size-64 {
71118
border-radius: 50%;
72119
width: 64px;
73120
height: 64px;
74121
}
75-
.avatar-user-size-96 {
122+
.avatar-inner.avatar-user-size-96 {
76123
border-radius: 50%;
77124
width: 96px;
78125
height: 96px;
79126
}
80127

128+
/* Background layer - no border-radius needed, parent clips via overflow */
81129
.avatar-background {
82130
background-color: var(--color-greyLight);
83131
bottom: 0;
@@ -90,6 +138,7 @@
90138
}
91139
}
92140

141+
/* Image layer - no border-radius needed, parent clips via overflow */
93142
.avatar-user-image {
94143
flex-shrink: 0;
95144
background-size: cover;
@@ -100,6 +149,23 @@
100149
top: 0;
101150
}
102151

152+
/* For img tag avatars - better caching and performance */
153+
/* CRITICAL: NO border-radius on img - parent container clips via overflow */
154+
/* This allows Chrome to share the decoded bitmap across all instances */
155+
img.avatar-user-image {
156+
width: 100%;
157+
height: 100%;
158+
object-fit: cover;
159+
border-radius: 0 !important;
160+
/* Prevent dragging which can trigger decode */
161+
user-drag: none;
162+
-webkit-user-drag: none;
163+
/* Optimize for static images */
164+
image-rendering: auto;
165+
/* Content is static, help Chrome cache decoded bitmap */
166+
content-visibility: auto;
167+
}
168+
103169
.avatar-border-team {
104170
background: rgba(0, 0, 0, 0);
105171
bottom: 0;

shared/common-adapters/avatar/hooks.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// High level avatar class. Handdles converting from usernames to urls. Deals with testing mode.
22
import * as C from '@/constants'
33
import * as React from 'react'
4-
import {iconTypeToImgSet, urlsToImgSet, type IconType, type IconStyle} from '../icon'
4+
import {iconTypeToImgSet, urlsToImgSet, urlsToSrcSet, urlsToBaseSrc, type IconType, type IconStyle} from '../icon'
55
import * as Styles from '@/styles'
66
import * as AvatarZus from './store'
77
import './avatar.css'
@@ -104,6 +104,32 @@ export default (ownProps: Props) => {
104104
),
105105
[address, name, imageOverrideUrl, lighterPlaceholders, size, urlMap, isTeam]
106106
)
107+
108+
// For <img> tags (desktop only): extract src and srcset
109+
const src = React.useMemo(
110+
() =>
111+
Styles.isMobile
112+
? null
113+
: imageOverrideUrl
114+
? imageOverrideUrl
115+
: address && name
116+
? urlsToBaseSrc(urlMap, size)
117+
: null,
118+
[address, name, imageOverrideUrl, size, urlMap]
119+
)
120+
121+
const srcset = React.useMemo(
122+
() =>
123+
Styles.isMobile
124+
? undefined
125+
: imageOverrideUrl
126+
? undefined
127+
: address && name
128+
? urlsToSrcSet(urlMap, size)
129+
: undefined,
130+
[address, name, imageOverrideUrl, size, urlMap]
131+
)
132+
107133
const iconInfo = React.useMemo(
108134
() => followIconHelper(size, followsYou, following),
109135
[size, followsYou, following]
@@ -126,6 +152,8 @@ export default (ownProps: Props) => {
126152
opacity: ownProps.opacity,
127153
size: size,
128154
skipBackground: ownProps.skipBackground,
155+
src: src,
156+
srcset: srcset,
129157
style: ownProps.style,
130158
url: url,
131159
}

shared/common-adapters/avatar/index.desktop.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as React from 'react'
12
import Icon, {type IconType} from '../icon'
23
import * as Styles from '@/styles'
34
import type {Props, AvatarSize} from '.'
@@ -24,28 +25,54 @@ const Avatar = (p: Props) => {
2425

2526
const scaledAvatarRatio = props.size / AVATAR_SIZE
2627
const avatarScaledWidth = props.crop?.scaledWidth ? props.crop.scaledWidth * scaledAvatarRatio : null
28+
29+
// Stable style object to prevent img re-render
30+
const imgOpacity = props.opacity === undefined || props.opacity === 1
31+
? props.blocked
32+
? 0.1
33+
: 1
34+
: props.opacity
35+
const imgStyle = React.useMemo(
36+
() => (imgOpacity !== 1 ? {opacity: imgOpacity} : undefined),
37+
[imgOpacity]
38+
)
39+
2740
return (
2841
<div
2942
className={Styles.classNames('avatar', avatarSizeClasName)}
3043
onClick={props.onClick}
3144
style={Styles.collapseStyles([props.style, props.onClick && styles.clickable]) as React.CSSProperties}
3245
>
33-
{!props.skipBackground && (
34-
<div className={Styles.classNames('avatar-background', avatarSizeClasName)} />
35-
)}
46+
{/* Inner wrapper clips avatar image content, outer container allows follow icons to extend */}
47+
<div className={Styles.classNames('avatar-inner', avatarSizeClasName)}>
48+
{!props.skipBackground && (
49+
<div className="avatar-background" />
50+
)}
3651
{!!props.blocked && !!avatarSizeToPoopIconType(props.size) && (
3752
<div
38-
className={Styles.classNames('avatar-user-image', avatarSizeClasName)}
53+
className="avatar-user-image"
3954
style={styles.poopContainer}
4055
>
4156
{/* ts messes up here without the || 'icon-poop-32' even though it
4257
can't happen due to the !!avatarSizeToPoopIconType() check above */}
4358
<Icon type={avatarSizeToPoopIconType(props.size) || 'icon-poop-32'} />
4459
</div>
4560
)}
46-
{!!props.url && props.crop === undefined && (
61+
{!!props.src && props.crop === undefined && (
62+
<img
63+
key={props.src}
64+
src={props.src}
65+
srcSet={props.size <= 32 ? undefined : props.srcset || undefined}
66+
decoding="async"
67+
className="avatar-user-image"
68+
style={imgStyle as React.CSSProperties}
69+
alt=""
70+
draggable={false}
71+
/>
72+
)}
73+
{!!props.url && !props.src && props.crop === undefined && (
4774
<div
48-
className={Styles.classNames('avatar-user-image', avatarSizeClasName)}
75+
className="avatar-user-image"
4976
style={{
5077
backgroundImage: props.url,
5178
opacity:
@@ -58,9 +85,8 @@ const Avatar = (p: Props) => {
5885
/>
5986
)}
6087
{!!props.url && props.crop?.offsetLeft !== undefined && props.crop.offsetTop !== undefined && (
61-
<img
62-
loading="lazy"
63-
className={Styles.classNames('avatar-user-image', avatarSizeClasName)}
88+
<div
89+
className="avatar-user-image"
6490
style={{
6591
backgroundImage: props.url,
6692
backgroundPositionX: props.crop.offsetLeft * scaledAvatarRatio,
@@ -95,6 +121,7 @@ const Avatar = (p: Props) => {
95121
)}
96122
/>
97123
)}
124+
</div>
98125
{props.followIconType && <Icon type={props.followIconType} style={props.followIconStyle} />}
99126
{props.editable && <Icon type="iconfont-edit" style={props.isTeam ? styles.editTeam : styles.edit} />}
100127
{props.children}

shared/common-adapters/icon.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ export default Icon
4848

4949
export declare function iconTypeToImgSet(imgMap: {[K in string]: IconType}, targetSize: number): string
5050
export declare function urlsToImgSet(imgMap: {[K in string]: string}, size: number): string | null
51+
export declare function urlsToSrcSet(_imgMap: {[key: number]: string}, _targetSize: number): string | null
52+
export declare function urlsToBaseSrc(_imgMap: {[key: number]: string}, _targetSize: number): string | null
5153
export type {IconType} from './icon.constants-gen'

shared/common-adapters/icon.desktop.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,32 @@ export function urlsToImgSet(imgMap: {[key: number]: string}, targetSize: number
228228
return sets ? `-webkit-image-set(${sets})` : null
229229
}
230230

231+
// Generate srcset attribute for <img> tags (no url() wrapper)
232+
export function urlsToSrcSet(imgMap: {[key: number]: string}, targetSize: number) {
233+
const multsMap = Shared.getMultsMap(imgMap, targetSize)
234+
const keys = Object.keys(multsMap) as unknown as Array<keyof typeof multsMap>
235+
const srcset = keys
236+
.map(mult => {
237+
const m = multsMap[mult]
238+
if (!m) return null
239+
const url = imgMap[m]
240+
if (!url) {
241+
return null
242+
}
243+
return `${url} ${mult}x`
244+
})
245+
.filter(Boolean)
246+
.join(', ')
247+
return srcset || null
248+
}
249+
250+
// Get base URL for img src attribute (1x version)
251+
export function urlsToBaseSrc(imgMap: {[key: number]: string}, targetSize: number) {
252+
const multsMap = Shared.getMultsMap(imgMap, targetSize)
253+
const baseSize = multsMap[1]
254+
return baseSize ? imgMap[baseSize] : null
255+
}
256+
231257
const styles = Styles.styleSheetCreate(() => ({
232258
// Needed because otherwise the containing box doesn't calculate the size of
233259
// the inner span (incl padding) properly

shared/common-adapters/icon.native.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,15 @@ export function urlsToImgSet(imgMap: {[size: string]: string}, targetSize: numbe
219219
return imgSet.length ? imgSet : null
220220
}
221221

222+
// Desktop-only functions - not used on native but needed for import compatibility
223+
export function urlsToSrcSet(_imgMap: {[key: number]: string}, _targetSize: number): null {
224+
return null
225+
}
226+
227+
export function urlsToBaseSrc(_imgMap: {[key: number]: string}, _targetSize: number): null {
228+
return null
229+
}
230+
222231
const styles = Styles.styleSheetCreate(() => ({
223232
fixOverdraw: {
224233
backgroundColor: Styles.globalColors.fastBlank,

0 commit comments

Comments
 (0)