From 38473803c1262ac0ee10da5a8095ae73669bea1a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 18 May 2026 09:46:10 -0700 Subject: [PATCH] fix(mobile): wire mirror retry to drawer profile picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilePicture dropped the `onError` callback returned by `useProfilePicture`, so render-time image failures never reached `useImageSize` — the mirror retry could only fire on prefetch failures. A slow validator node that accepted the prefetch but stalled the actual render would leave the avatar hung indefinitely (OS TCP timeout is 60–90s with no `onError` fired). Changes: - `ProfilePicture`: forward `onError` (chained with caller's), pass `priorityLowResSource`, and set `timeoutMs={3000}` so stalled URLs advance to the next mirror. - `UserImage`: add matching `timeoutMs={3000}` for parity. - Harmony-native `Image`: add optional `timeoutMs` prop (default 0/off). When >0 and source is a remote URI, synthesize an `onError` if neither `onLoad` nor `onError` fires in the window. Mirrors the web `MirrorImage` pattern. - `Artwork`: plumb `timeoutMs` through so `Avatar → Artwork → Image` wires it end-to-end. Co-Authored-By: Claude Opus 4.7 --- .../src/components/core/ProfilePicture.tsx | 39 +++++++++-- .../mobile/src/components/image/UserImage.tsx | 6 ++ .../src/harmony-native/components/Artwork.tsx | 4 +- .../harmony-native/components/Image/Image.tsx | 69 +++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/packages/mobile/src/components/core/ProfilePicture.tsx b/packages/mobile/src/components/core/ProfilePicture.tsx index 38a60cfd727..3a402ba33ee 100644 --- a/packages/mobile/src/components/core/ProfilePicture.tsx +++ b/packages/mobile/src/components/core/ProfilePicture.tsx @@ -11,6 +11,11 @@ const messages = { profilePictureFor: 'Profile picture for' } +// Per-URL render timeout (ms) so a hung connection from a slow Open Audio +// Validator Node advances to the next mirror instead of blocking on the OS +// TCP timeout (60–90s). Matches the web MirrorImage pattern. +const PROFILE_PICTURE_TIMEOUT_MS = 3000 + type BaseAvatarProps = Omit // User should prefer userId, and provide user if it's not in the cache @@ -23,23 +28,47 @@ type ProfilePictureUserProps = export type ProfilePictureProps = BaseAvatarProps & ProfilePictureUserProps export const ProfilePicture = (props: ProfilePictureProps) => { - const userId = 'user' in props ? props.user.user_id : props.userId + // Pull onError off so we can chain it with the mirror-retry handler from + // `useProfilePicture` instead of being clobbered by the props spread. + const { onError: callerOnError, ...restProps } = props as BaseAvatarProps & + ProfilePictureUserProps + const userId = + 'user' in restProps ? restProps.user.user_id : restProps.userId - const { data: userQuery } = useUser(userId, { enabled: !('user' in props) }) - const user = 'user' in props ? props.user : userQuery + const { data: userQuery } = useUser(userId, { + enabled: !('user' in restProps) + }) + const user = 'user' in restProps ? restProps.user : userQuery const accessibilityLabel = `${messages.profilePictureFor} ${user?.name}` - const { source } = useProfilePicture({ + const { + source, + priorityLowResSource, + onError: onImageError + } = useProfilePicture({ userId, size: SquareSizes.SIZE_150_BY_150 }) + // Forward render-time failures to `useImageSize` so it can mark the URL + // failed and swap in the next mirror. Without this wiring the mirror + // retry only ever fires on prefetch failures, not on stalled renders. + const handleError = (error: { nativeEvent: { error: string } }) => { + if (source && typeof source === 'object' && 'uri' in source) { + onImageError?.(source.uri as string) + } + callerOnError?.(error) + } + return ( ) } diff --git a/packages/mobile/src/components/image/UserImage.tsx b/packages/mobile/src/components/image/UserImage.tsx index 1d34c358f18..f340c80ebe9 100644 --- a/packages/mobile/src/components/image/UserImage.tsx +++ b/packages/mobile/src/components/image/UserImage.tsx @@ -9,6 +9,11 @@ import profilePicEmpty from 'app/assets/images/imageProfilePicEmpty2X.png' import { primitiveToImageSource } from './primitiveToImageSource' +// Per-URL render timeout (ms) so a stalled Open Audio Validator Node +// advances to the next mirror without waiting on the OS TCP timeout +// (60–90s). Matches the web MirrorImage pattern. +const USER_IMAGE_TIMEOUT_MS = 3000 + type UseUserImageOptions = { userId: ID | null | undefined size: SquareSizes @@ -87,6 +92,7 @@ export const UserImage = (props: UserImageProps) => { source={source} priorityLowResSource={priorityLowResSource} onError={handleError} + timeoutMs={USER_IMAGE_TIMEOUT_MS} /> ) } diff --git a/packages/mobile/src/harmony-native/components/Artwork.tsx b/packages/mobile/src/harmony-native/components/Artwork.tsx index ddefca476a6..9a46373ebef 100644 --- a/packages/mobile/src/harmony-native/components/Artwork.tsx +++ b/packages/mobile/src/harmony-native/components/Artwork.tsx @@ -27,7 +27,7 @@ export type ArtworkProps = { * Enables true progressive image loading. */ priorityLowResSource?: ImageProps['source'] -} & Partial> & +} & Partial> & BoxProps /** @@ -44,6 +44,7 @@ export const Artwork = (props: ArtworkProps) => { priorityLowResSource, onError, onLoad, + timeoutMs, borderRadius = 's', borderWidth, shadow, @@ -158,6 +159,7 @@ export const Artwork = (props: ArtworkProps) => { onLoad?.(event) }} onError={onError} + timeoutMs={timeoutMs} source={imageSource} /> ) : null} diff --git a/packages/mobile/src/harmony-native/components/Image/Image.tsx b/packages/mobile/src/harmony-native/components/Image/Image.tsx index a5b984d4961..eaf8fedf29f 100644 --- a/packages/mobile/src/harmony-native/components/Image/Image.tsx +++ b/packages/mobile/src/harmony-native/components/Image/Image.tsx @@ -30,6 +30,16 @@ export type ImageProps = ComponentProps & { * When true, the high-res image appears immediately without a fade animation. */ immediate?: boolean + /** + * Optional render-time timeout (ms) for remote URI sources. If neither + * `onLoad` nor `onError` fires within this window, a synthetic `onError` + * is dispatched so callers (e.g. mirror-retry logic) can advance to the + * next URL without waiting on a hung TCP connection. + * + * Defaults to 0 (disabled). Only applies to remote `{ uri }` sources — + * local `require()`'d sources are unaffected. + */ + timeoutMs?: number } // Export ImageProps without source for render prop usage @@ -65,9 +75,24 @@ export const Image = (props: ImageProps) => { onLoad, style, immediate = false, + timeoutMs = 0, ...other } = props + // Track whether the current source has resolved (loaded or errored) so the + // timeout fallback doesn't fire after the fact, and so we can clear pending + // timers on resolution. + const hasResolvedRef = useRef(false) + const timeoutTimerRef = useRef | null>(null) + + // Keep the latest `onError` in a ref so the timeout closure always invokes + // the freshest callback without needing to be in the effect deps (which + // would reset the timer on every render). + const onErrorRef = useRef(onError) + useEffect(() => { + onErrorRef.current = onError + }) + // Wrap onError to prevent Error objects from being passed as props // This fixes "Error.stack getter called with an invalid receiver" in Hermes const handleError = onError @@ -77,6 +102,11 @@ export const Image = (props: ImageProps) => { | { nativeEvent: ImageErrorEventData } | Error ) => { + hasResolvedRef.current = true + if (timeoutTimerRef.current) { + clearTimeout(timeoutTimerRef.current) + timeoutTimerRef.current = null + } if (error instanceof Error) { onError({ nativeEvent: { error: error.message || 'Image load error' } @@ -105,6 +135,40 @@ export const Image = (props: ImageProps) => { } }, [source, priorityLowResSource, startVisible, opacity]) + // Render-time timeout: if the remote image hasn't fired onLoad or onError + // within `timeoutMs`, synthesize an onError so mirror-retry logic upstream + // can advance to the next URL. The native image cache and TCP stack will + // otherwise wait 60–90s on a hung connection without firing onError. + useEffect(() => { + hasResolvedRef.current = false + if (timeoutTimerRef.current) { + clearTimeout(timeoutTimerRef.current) + timeoutTimerRef.current = null + } + + if (!timeoutMs || timeoutMs <= 0) return + if (!source || typeof source === 'number') return + const uri = Array.isArray(source) ? source[0]?.uri : source.uri + if (!uri) return + + timeoutTimerRef.current = setTimeout(() => { + if (hasResolvedRef.current) return + hasResolvedRef.current = true + onErrorRef.current?.({ + nativeEvent: { + error: `Image load timeout after ${timeoutMs}ms: ${uri}` + } + }) + }, timeoutMs) + + return () => { + if (timeoutTimerRef.current) { + clearTimeout(timeoutTimerRef.current) + timeoutTimerRef.current = null + } + } + }, [source, timeoutMs]) + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })) @@ -114,6 +178,11 @@ export const Image = (props: ImageProps) => { } const handleLoad = (e: NativeSyntheticEvent) => { + hasResolvedRef.current = true + if (timeoutTimerRef.current) { + clearTimeout(timeoutTimerRef.current) + timeoutTimerRef.current = null + } opacity.value = withTiming(1, { duration: immediate ? 100 : 300 }) // Hide the low-res placeholder shortly after the high-res starts fading in. if (priorityLowResSource) {