Skip to content

Commit a738d6b

Browse files
[Mobile] Eliminate low-res→high-res image flash (#14277)
When `useImageSize` had a smaller cached size (e.g. 150×150) but needed a larger one (e.g. 480×480 on a track page), it set `imageUrl` to the small URL then switched to the large URL once fetched. The source change caused `Image.tsx` to reset `opacity = 0` and re-fade — a visible flash on track, profile, and playlist pages. `useImageSize` now sets `imageUrl` to the target URL optimistically and returns `priorityLowResUrl` as a separate value. `TrackImage`, `CollectionImage`, and `UserImage` pass it as `priorityLowResSource` to `Artwork`/`Image`, which renders it as a blurred backdrop while the high-res crossfades in. ## Test plan - [ ] Navigate to a track page — artwork should crossfade from blurred low-res to full-res, no flash - [ ] Same on profile and playlist pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7fed047 commit a738d6b

4 files changed

Lines changed: 56 additions & 10 deletions

File tree

packages/common/src/hooks/useImageSize.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export const useImageSize = <
5757
preloadImageFn?: (url: string) => Promise<void>
5858
}) => {
5959
const [imageUrl, setImageUrl] = useState<Maybe<string>>(undefined)
60+
// When upgrading from a smaller cached image to the target size, holds the
61+
// smaller URL so callers can show it as a blurred backdrop while the
62+
// high-res crossfades in (avoids the opacity-reset flash on source change).
63+
const [priorityLowResUrl, setPriorityLowResUrl] =
64+
useState<Maybe<string>>(undefined)
6065
const [failedUrls, setFailedUrls] = useState<Set<string>>(new Set())
6166

6267
const fetchWithFallback = useCallback(
@@ -150,10 +155,21 @@ export const useImageSize = <
150155
}
151156

152157
if (smallerSize) {
153-
setImageUrl(artwork[smallerSize])
154-
const finalUrl = await fetchWithFallback(targetUrl)
155-
IMAGE_CACHE.add(finalUrl)
156-
setImageUrl(finalUrl)
158+
// Set the target URL optimistically so the main image slot starts
159+
// loading the high-res immediately. The smaller cached URL is passed
160+
// back as priorityLowResUrl so callers can show it as a blurred
161+
// backdrop — this eliminates the opacity-reset flash that occurred
162+
// when we previously did setImageUrl(small) then setImageUrl(large).
163+
setPriorityLowResUrl(artwork[smallerSize])
164+
setImageUrl(targetUrl)
165+
try {
166+
const finalUrl = await fetchWithFallback(targetUrl)
167+
IMAGE_CACHE.add(finalUrl)
168+
if (finalUrl !== targetUrl) setImageUrl(finalUrl)
169+
} catch (e) {
170+
// Fall back to the smaller size if high-res is unreachable.
171+
setImageUrl(artwork[smallerSize])
172+
}
157173
return
158174
}
159175

@@ -190,5 +206,5 @@ export const useImageSize = <
190206
resolveImageUrl()
191207
}, [resolveImageUrl])
192208

193-
return { imageUrl, onError }
209+
return { imageUrl, priorityLowResUrl, onError }
194210
}

packages/mobile/src/components/image/CollectionImage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ export const useCollectionImage = ({
7171
})
7272
const artwork = artworkData?.artwork
7373
const hasNoArtwork = artworkData?.hasNoArtwork ?? false
74-
const { imageUrl, onError: onImageError } = useImageSize({
74+
const {
75+
imageUrl,
76+
priorityLowResUrl,
77+
onError: onImageError
78+
} = useImageSize({
7579
artwork,
7680
targetSize: size,
7781
defaultImage: '',
@@ -98,6 +102,7 @@ export const useCollectionImage = ({
98102

99103
return {
100104
source: primitiveToImageSource(imageUrl),
105+
priorityLowResSource: primitiveToImageSource(priorityLowResUrl),
101106
hasNoArtwork: false,
102107
onError: onImageError
103108
}
@@ -121,6 +126,7 @@ export const CollectionImage = (props: CollectionImageProps) => {
121126
const collectionImageSource = useCollectionImage({ collectionId, size })
122127
const {
123128
source: loadedSource,
129+
priorityLowResSource,
124130
onError: onImageError,
125131
hasNoArtwork
126132
} = collectionImageSource
@@ -160,6 +166,7 @@ export const CollectionImage = (props: CollectionImageProps) => {
160166
<Artwork
161167
{...other}
162168
source={source}
169+
priorityLowResSource={priorityLowResSource}
163170
onLoad={onLoad}
164171
onError={handleError}
165172
style={style}

packages/mobile/src/components/image/TrackImage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export const useTrackImage = ({
6666
})
6767
const artwork = artworkData?.artwork
6868
const hasNoArtwork = artworkData?.hasNoArtwork ?? false
69-
const { imageUrl, onError: onImageError } = useImageSize({
69+
const {
70+
imageUrl,
71+
priorityLowResUrl,
72+
onError: onImageError
73+
} = useImageSize({
7074
artwork,
7175
targetSize: size,
7276
defaultImage: '',
@@ -97,6 +101,7 @@ export const useTrackImage = ({
97101

98102
return {
99103
source: primitiveToImageSource(imageUrl),
104+
priorityLowResSource: primitiveToImageSource(priorityLowResUrl),
100105
hasNoArtwork: false,
101106
onError: onImageError
102107
}
@@ -129,6 +134,7 @@ export const TrackImage = (props: TrackImageProps) => {
129134
const trackImageSource = useTrackImage({ trackId, size })
130135
const {
131136
source: loadedSource,
137+
priorityLowResSource,
132138
onError: onImageError,
133139
hasNoArtwork
134140
} = trackImageSource
@@ -176,6 +182,7 @@ export const TrackImage = (props: TrackImageProps) => {
176182
return (
177183
<Artwork
178184
source={source}
185+
priorityLowResSource={priorityLowResSource}
179186
onLoad={onLoad}
180187
onError={handleError}
181188
borderRadius={borderRadius}

packages/mobile/src/components/image/UserImage.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ export const useProfilePicture = ({
2727
})
2828

2929
const { profile_picture, updatedProfilePicture } = partialUser ?? {}
30-
const { imageUrl, onError: onImageError } = useImageSize({
30+
const {
31+
imageUrl,
32+
priorityLowResUrl,
33+
onError: onImageError
34+
} = useImageSize({
3135
artwork: profile_picture,
3236
targetSize: size,
3337
defaultImage: '',
@@ -54,6 +58,7 @@ export const useProfilePicture = ({
5458

5559
return {
5660
source: primitiveToImageSource(imageUrl),
61+
priorityLowResSource: primitiveToImageSource(priorityLowResUrl),
5762
isFallbackImage: false,
5863
onError: onImageError
5964
}
@@ -63,7 +68,11 @@ export type UserImageProps = UseUserImageOptions & Partial<ImageProps>
6368

6469
export const UserImage = (props: UserImageProps) => {
6570
const { userId, size, onError, ...imageProps } = props
66-
const { source, onError: onImageError } = useProfilePicture({ userId, size })
71+
const {
72+
source,
73+
priorityLowResSource,
74+
onError: onImageError
75+
} = useProfilePicture({ userId, size })
6776

6877
const handleError = (error: { nativeEvent: { error: string } }) => {
6978
if (source && typeof source === 'object' && 'uri' in source) {
@@ -72,5 +81,12 @@ export const UserImage = (props: UserImageProps) => {
7281
onError?.(error)
7382
}
7483

75-
return <Image {...imageProps} source={source} onError={handleError} />
84+
return (
85+
<Image
86+
{...imageProps}
87+
source={source}
88+
priorityLowResSource={priorityLowResSource}
89+
onError={handleError}
90+
/>
91+
)
7692
}

0 commit comments

Comments
 (0)