From 6a882a65bbf4952fc06b5988601fada925cf2f5a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 22 May 2026 11:32:30 -0400 Subject: [PATCH] fix iOS simulator OOM crash when fast-scrolling media-rich chat threads Three fixes for Hermes heap exhaustion in the iOS simulator: 1. FlatList windowSize=3: the default windowSize=21 kept ~262 message components simultaneously mounted, filling Hermes old-gen with ~340MB of live JS objects that GC couldn't collect. windowSize=3 limits concurrent mounted components to ~51. Confirmed: heap now plateaus at ~724MB with GC collecting successfully instead of crashing at 354MB allocated. 2. SDWebImage cache cap: expo-image's SDWebImage backend keeps decoded bitmaps in a memory cache. The iOS simulator never sends memory warnings, so the cache grew unbounded across 400-500 loaded messages. Capped at 100MB via ExpoImage.configureCache at startup. Confirmed: js_externalBytes stayed under 1.2MB throughout. 3. CoreMedia thread gating: useVideoPlayer and useAudioPlayer each spawn a CoreMedia pipeline. Previously called unconditionally for every message in the list; now gated behind user interaction (or autoPlay for Giphy). --- shared/app/index.native.tsx | 5 ++ shared/chat/audio/audio-player.tsx | 7 +- shared/chat/conversation/list-area/index.tsx | 2 + .../messages/attachment/video/videoimpl.tsx | 64 +++++++++++++------ .../text/unfurl/unfurl-list/image/video.tsx | 56 ++++++++++++---- 5 files changed, 101 insertions(+), 33 deletions(-) diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 35ee779518d0..88a89f640e08 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -13,6 +13,7 @@ import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-con import {makeEngine} from '../engine' import {GestureHandlerRootView} from 'react-native-gesture-handler' import {enableFreeze} from 'react-native-screens' +import {Image as ExpoImage} from 'expo-image' import {setKeyboardUp} from '@/styles/keyboard-state' import {setServiceDecoration} from '@/common-adapters/markdown/react' import ServiceDecoration from '@/common-adapters/markdown/service-decoration' @@ -27,6 +28,10 @@ logger.info('INIT App index module load') enableFreeze(true) setServiceDecoration(ServiceDecoration) +// SDWebImage (used by expo-image) flushes its memory cache on iOS memory warnings, but +// the simulator never sends memory warnings. Cap the cache so loading hundreds of chat +// images doesn't exhaust VM in the simulator. On a real device this is a safety net only. +ExpoImage.configureCache({maxMemoryCost: 100 * 1024 * 1024}) module.hot?.accept(() => { console.log('accepted update in shared/index.native') diff --git a/shared/chat/audio/audio-player.tsx b/shared/chat/audio/audio-player.tsx index 6127f19e7dac..ab41861b3830 100644 --- a/shared/chat/audio/audio-player.tsx +++ b/shared/chat/audio/audio-player.tsx @@ -69,8 +69,13 @@ const AudioPlayer = (props: Props) => { const {duration, big, maxWidth, url, visAmps} = props const [playedRatio, setPlayedRatio] = React.useState(0) const [paused, setPaused] = React.useState(true) + // Only mount AudioVideo after the user first taps play; calling useAudioPlayer + // unconditionally for every message in the list spawns CoreMedia threads per + // message and exhausts VM memory. + const [everPlayed, setEverPlayed] = React.useState(false) const onClick = () => { if (paused) { + setEverPlayed(true) setPaused(false) } else { setPaused(true) @@ -104,7 +109,7 @@ const AudioPlayer = (props: Props) => { {formatAudioRecordDuration(timeLeft)} - {url.length > 0 && ( + {url.length > 0 && everPlayed && ( )} diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index a47dccecc3f9..fb5690b40212 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -476,6 +476,7 @@ const NativeConversationList = function NativeConversationList() { const listRef = React.useRef(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions() + const keyExtractor = (ordinal: ItemType) => { return String(ordinal) } @@ -617,6 +618,7 @@ const NativeConversationList = function NativeConversationList() { keyExtractor={keyExtractor} ref={listRef} renderScrollComponent={renderScrollComponent} + windowSize={3} maintainVisibleContentPosition={ // MUST do this else if you come into a new thread it'll slowly scroll down when it loads numOrdinals ? maintainVisibleContentPosition : undefined diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.tsx index 52207a2b4b42..dd9f23b1def8 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.tsx @@ -53,31 +53,60 @@ const DesktopVideoImpl = (p: Props) => { ) } -const NativeVideoImpl = (p: Props) => { - const {allowPlay, message, showPopup} = p - const {fileURL: url, transferState, videoDuration} = message - const {previewURL, height, width} = getAttachmentPreviewSize(message) - const {showPoster, reveal} = usePosterState(url) - const sourceUri = `${url}&contentforce=true` +// Separated into its own component so useVideoPlayer is only called when the +// user actually taps play. Calling useVideoPlayer unconditionally for every +// video message in the list caused CoreMedia to initialize a player per +// message, spawning dozens of network threads and exhausting VM memory. +type NativeActiveVideoProps = { + sourceUri: string + height: number + width: number +} +const NativeActiveVideo = (p: NativeActiveVideoProps) => { + const {sourceUri, height, width} = p const player = useVideoPlayer(sourceUri, pl => { pl.loop = false + pl.play() }) - - const onPress = () => { - reveal() - player.play() - } - useEventListener(player, 'playToEnd', () => { player.replay() }) + return ( + + ) +} + +const NativeVideoImpl = (p: Props) => { + const {allowPlay, message, showPopup} = p + const {fileURL: url, transferState, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) + const [playerActive, setPlayerActive] = React.useState(false) + const [lastUrl, setLastUrl] = React.useState(url) + if (lastUrl !== url) { + setLastUrl(url) + setPlayerActive(false) + } + const sourceUri = `${url}&contentforce=true` return ( <> - {showPoster ? ( - + {playerActive && url ? ( + + ) : ( + { + if (allowPlay) setPlayerActive(true) + }} + style={nativeStyles.pressable} + onLongPress={showPopup} + > { - ) : ( - )} ) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx index cc0ef41e7069..37c2424dd184 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/video.tsx @@ -53,12 +53,18 @@ const DesktopVideo = (p: Props) => { ) } -const NativeVideo = (props: Props) => { - const {autoPlay, onClick, url, style, width, height} = props - const {playing, setPlaying} = usePlayState(url, autoPlay) +// Separated into its own component so useVideoPlayer is only called when the +// player is needed. Calling it unconditionally for every unfurl in the list +// spawns CoreMedia threads per message and exhausts VM memory. +type NativeActiveVideoProps = { + sourceUri: string + autoPlay: boolean + playing: boolean + style: object +} - const uri = url.length > 0 ? url : 'https://' - const sourceUri = `${uri}&autoplay=${autoPlay ? 'true' : 'false'}&contentforce=true` +const NativeActiveVideo = (props: NativeActiveVideoProps) => { + const {sourceUri, autoPlay, playing, style} = props const player = useVideoPlayer(sourceUri, p => { p.loop = true @@ -85,22 +91,50 @@ const NativeVideo = (props: Props) => { return () => sub.remove() }, [player]) + return ( + + ) +} + +const NativeVideo = (props: Props) => { + const {autoPlay, onClick, url, style, width, height} = props + const {playing, setPlaying} = usePlayState(url, autoPlay) + // Activate the player when autoPlay is true or the user first taps play. + // Reset when URL changes so a new source gets a fresh player. + const [active, setActive] = React.useState(autoPlay) + const [lastUrl, setLastUrl] = React.useState(url) + if (lastUrl !== url) { + setLastUrl(url) + setActive(autoPlay) + } + + const uri = url.length > 0 ? url : 'https://' + const sourceUri = `${uri}&autoplay=${autoPlay ? 'true' : 'false'}&contentforce=true` + const _onClick = () => { if (onClick) { onClick() return } + setActive(true) setPlaying(p => !p) } return ( - + {active && ( + + )} {!playing && }