fix(iOS): eagerly dispose RiveView and break referenced asset retain cycle#202
Merged
Conversation
RiveView now calls dispose() on the native HybridObject when the React component unmounts, freeing the RiveView, ViewModel, and C++ scene graph immediately instead of waiting for Hermes GC. Also removes the withExtendedLifetime(fileRef) retain cycle in ReferencedAssetLoader that kept RiveFile alive permanently through the LoadAsset closure stored on the file itself.
mfazekas
pushed a commit
that referenced
this pull request
Apr 8, 2026
🤖 I have created a release *beep* *boop* --- ## [0.4.1](v0.4.0...v0.4.1) (2026-04-08) ### Bug Fixes * **iOS:** eagerly dispose RiveView and break referenced asset retain cycle ([#202](#202)) ([c4b12d6](c4b12d6)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
mfazekas
added a commit
that referenced
this pull request
Apr 17, 2026
…cess crash (#218) Fixes #215 Regression from #202 — the shared `ReferencedAssetLoader` instance on `HybridRiveFileFactory` caused a data race when multiple `RiveFile` loads ran concurrently (e.g. mounting several `RiveView` components simultaneously). `HybridRiveFileFactory.genericFrom` dispatches file parsing to `DispatchQueue.global(qos: .userInitiated)`. With multiple views mounting at the same time, multiple GCD worker threads called `self.assetLoader.setFileRef(riveFile)` on the same shared instance concurrently. Since Swift property assignment (`activeFileRef = file`) is not atomic — it involves load-old, store-new, release-old as separate steps — two threads could both load the same old value and both release it, causing a double-release (use-after-free) of the previous `RiveFile`. The fix creates a new `ReferencedAssetLoader` per `HybridRiveFileFactory.genericFrom` call instead of sharing one on the factory. This matches the Android implementation (`HybridRiveFileFactory.kt` already creates `val loader = ReferencedAssetLoader()` per call). Verified that the retain cycle fix from #202 is not regressed (memgraph shows no leaked `RiveFile` after navigating away from referenced asset screens). ### Reproducer Rapidly mount/unmount multiple `RiveView` components (8+ views toggling at 10-50ms intervals, 1000+ cycles). Requires Expo SDK 55 / RN 0.83.4 — does not reproduce on Expo SDK 54 / RN 0.81. <details> <summary>Reproducer component (Issue215.tsx)</summary> ```tsx import { useEffect, useState, useCallback } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Fit, RiveView, useRive, useRiveFile, useViewModelInstance, } from '@rive-app/react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; const RIVE_URL = 'https://cdn.rive.app/animations/vehicles.riv'; function RiveCell({ source, label }: { source: any; label: string }) { const { riveFile } = useRiveFile(source); const { instance } = useViewModelInstance(riveFile); const { setHybridRef } = useRive(); return ( <Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)} style={styles.riveContainer} > <Text style={styles.label}>{label}</Text> {riveFile && ( <RiveView file={riveFile} autoPlay fit={Fit.Contain} style={{ flex: 1 }} hybridRef={setHybridRef} {...(instance ? { dataBind: instance } : {})} /> )} </Animated.View> ); } function LoggedInScreens() { return ( <View style={styles.loggedIn}> <Text style={styles.title}>Logged In</Text> <View style={styles.riveGrid}> <RiveCell source={RIVE_URL} label="A" /> <RiveCell source={RIVE_URL} label="B" /> <RiveCell source={RIVE_URL} label="C" /> <RiveCell source={RIVE_URL} label="D" /> <RiveCell source={RIVE_URL} label="E" /> <RiveCell source={RIVE_URL} label="F" /> <RiveCell source={RIVE_URL} label="G" /> <RiveCell source={RIVE_URL} label="H" /> </View> </View> ); } function LandingScreen() { return ( <Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)} style={styles.landing} > <Text style={styles.title}>Landing</Text> <RiveCell source={RIVE_URL} label="Landing A" /> <RiveCell source={RIVE_URL} label="Landing B" /> <RiveCell source={RIVE_URL} label="Landing C" /> <RiveCell source={RIVE_URL} label="Landing D" /> </Animated.View> ); } export default function Issue215Page() { const [isLoggedIn, setIsLoggedIn] = useState(true); const [isRapid, setIsRapid] = useState(false); const [count, setCount] = useState(0); const toggle = useCallback(() => setIsLoggedIn((v) => !v), []); useEffect(() => { if (!isRapid) return; let c = 0; let timeout: ReturnType<typeof setTimeout>; const scheduleNext = () => { const delay = Math.floor(Math.random() * 41) + 10; timeout = setTimeout(() => { toggle(); c++; setCount(c); if (c > 5000) { setIsRapid(false); } else { scheduleNext(); } }, delay); }; scheduleNext(); return () => clearTimeout(timeout); }, [isRapid, toggle]); return ( <SafeAreaView style={styles.container}> <Pressable style={styles.rapidButton} onPress={() => { setIsRapid((r) => !r); setCount(0); }} > <Text style={styles.buttonText}> {isRapid ? `Toggling... (${count}/5000)` : 'Start rapid toggle (10–50ms random)'} </Text> </Pressable> <Pressable style={styles.manualButton} onPress={toggle}> <Text style={styles.buttonText}> {isLoggedIn ? 'Manual logout' : 'Manual login'} </Text> </Pressable> {isLoggedIn ? <LoggedInScreens /> : <LandingScreen />} </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#0c1027' }, loggedIn: { flex: 1, padding: 8 }, landing: { flex: 1, padding: 8 }, title: { color: '#FFF', fontSize: 20, fontWeight: '600', textAlign: 'center', marginBottom: 8 }, label: { color: '#FFF', fontSize: 10, fontWeight: '600', textAlign: 'center', paddingVertical: 2 }, riveGrid: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, riveContainer: { width: '48%', height: 120, backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 8, overflow: 'hidden' }, rapidButton: { backgroundColor: '#a03030', paddingVertical: 10, paddingHorizontal: 24, borderRadius: 12, alignSelf: 'center', marginTop: 8 }, manualButton: { backgroundColor: '#30a050', paddingVertical: 8, paddingHorizontal: 20, borderRadius: 12, alignSelf: 'center', marginTop: 8 }, buttonText: { color: '#FFF', fontSize: 13, fontWeight: '600', textAlign: 'center' }, }); ``` </details>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
dispose()on the native HybridObject on unmount viauseEffectcleanup, freeing the native view, ViewModel, and C++ scene graph immediately instead of waiting for Hermes GCSendableRef<RiveFile?>retain cycle inReferencedAssetLoader— theLoadAssetclosure stored on theRiveFileno longer capturesfileRef, eliminating the permanentRiveFile → closure → fileRef → RiveFilecycledispose()override toHybridRiveViewthat triggersRiveReactNativeView.dispose()Reproducer: #167 (comment)
Test plan
SendableRef,RiveFile,rive::Artboard, orMTLSimTextureshould persist after GCExpo SDK 54 reproducer (matches reporter's environment)
Expo SDK 54 / RN 0.81.5 / React 19.1.0 with
enableFreeze(true)and expo-router stack navigation — same setup as the reporter.Setup
npx create-expo-app@latest expo-sdk-54-reproducer --template blank-typescript cd expo-sdk-54-reproducer npx expo install expo-router react-native-screens react-native-safe-area-context \ react-native-reanimated react-native-gesture-handler @react-navigation/native \ @react-navigation/bottom-tabs @rive-app/react-native react-native-nitro-modules \ react-native-worklets expo-linking expo-constants expo-splash-screenSet
"main": "expo-router/entry"inpackage.json.app.json{ "expo": { "name": "rive-leak-repro", "slug": "rive-leak-repro", "scheme": "rive-leak-repro", "newArchEnabled": true, "ios": { "bundleIdentifier": "com.riveleakrepro" }, "android": { "package": "com.riveleakrepro" }, "plugins": ["expo-router"], "experiments": { "typedRoutes": true, "reactCompiler": true } } }app/_layout.tsxapp/(tabs)/_layout.tsxapp/(tabs)/home/_layout.tsxapp/(tabs)/home/index.tsxapp/(tabs)/home/rive.tsxapp/(tabs)/home/rive2.tsxapp/(tabs)/home/rive3.tsxcomponents/rive/RiveMap.tsxcomponents/rive/RiveMilestone.tsxcomponents/rive/useRiveMonster.ts