Skip to content

Commit d92574d

Browse files
authored
fix(iOS): use per-call ReferencedAssetLoader to prevent concurrent access 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>
1 parent e9d648b commit d92574d

1 file changed

Lines changed: 6 additions & 4 deletions

File tree

ios/HybridRiveFileFactory.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import NitroModules
22
import RiveRuntime
33

44
final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendable {
5-
let assetLoader = ReferencedAssetLoader()
65

76
/// Asynchronously creates a `HybridRiveFileSpec` by performing the following steps:
87
/// 1. Executes `check()` to validate or fetch initial data.
@@ -34,9 +33,10 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl
3433
DispatchQueue.global(qos: .userInitiated).async {
3534
do {
3635

36+
let assetLoader = ReferencedAssetLoader()
3737
let referencedAssetCache = SendableRef(ReferencedAssetCache())
3838
let factoryCache: SendableRef<RiveFactory?> = .init(nil)
39-
let customLoader = self.assetLoader.createCustomLoader(
39+
let customLoader = assetLoader.createCustomLoader(
4040
referencedAssets: referencedAssets, cache: referencedAssetCache,
4141
factory: factoryCache
4242
)
@@ -47,11 +47,13 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl
4747
} else {
4848
try file(prepared)
4949
}
50-
self.assetLoader.setFileRef(riveFile)
50+
if customLoader != nil {
51+
assetLoader.setFileRef(riveFile)
52+
}
5153

5254
let result = (
5355
file: riveFile, cache: referencedAssetCache.value, factory: factoryCache.value,
54-
loader: customLoader != nil ? self.assetLoader : nil
56+
loader: customLoader != nil ? assetLoader : nil
5557
)
5658
DispatchQueue.main.async {
5759
continuation.resume(returning: result)

0 commit comments

Comments
 (0)