Skip to content

Commit c4b12d6

Browse files
authored
fix(iOS): eagerly dispose RiveView and break referenced asset retain cycle (#202)
## Summary - RiveView now calls `dispose()` on the native HybridObject on unmount via `useEffect` cleanup, freeing the native view, ViewModel, and C++ scene graph immediately instead of waiting for Hermes GC - Breaks the `SendableRef<RiveFile?>` retain cycle in `ReferencedAssetLoader` — the `LoadAsset` closure stored on the `RiveFile` no longer captures `fileRef`, eliminating the permanent `RiveFile → closure → fileRef → RiveFile` cycle - Android: adds `dispose()` override to `HybridRiveView` that triggers `RiveReactNativeView.dispose()` Reproducer: #167 (comment) ## Test plan - Open a Rive screen with referenced assets (e.g. map-v4.riv with 240 assets), navigate back - Take memgraph snapshots before/after — no `SendableRef`, `RiveFile`, `rive::Artboard`, or `MTLSimTexture` should persist after GC - Verify no crash on rapid open/close with referenced assets (factory.decodeImage on background thread) <details> <summary>Expo SDK 54 reproducer (matches reporter's environment)</summary> 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 ```bash 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-screen ``` Set `"main": "expo-router/entry"` in `package.json`. ### `app.json` ```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.tsx` ```tsx import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { enableFreeze } from 'react-native-screens'; import 'react-native-reanimated'; import { useColorScheme } from 'react-native'; enableFreeze(true); export default function RootLayout() { const colorScheme = useColorScheme(); return ( <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> </Stack> <StatusBar style="auto" /> </ThemeProvider> ); } ``` ### `app/(tabs)/_layout.tsx` ```tsx import { Tabs } from 'expo-router'; export default function TabLayout() { return ( <Tabs screenOptions={{ headerShown: false }}> <Tabs.Screen name="home" options={{ title: 'Home', freezeOnBlur: true }} /> </Tabs> ); } ``` ### `app/(tabs)/home/_layout.tsx` ```tsx import { Stack } from 'expo-router'; export default function HomeStackLayout() { return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="index" /> <Stack.Screen name="rive" /> <Stack.Screen name="rive2" /> <Stack.Screen name="rive3" /> </Stack> ); } ``` ### `app/(tabs)/home/index.tsx` ```tsx import { router } from 'expo-router'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; export default function HomeScreen() { return ( <ScrollView style={{ flex: 1 }}> <View style={{ padding: 24, paddingTop: 80, alignItems: 'center' }}> <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}> Rive Leak Reproducer </Text> </View> <View style={{ gap: 8, padding: 16 }}> <Pressable style={styles.gc} onPress={() => global.gc?.()}> <Text style={styles.btnText}>Force GC</Text> </Pressable> <Pressable style={styles.btn} onPress={() => router.push('/home/rive')}> <Text style={styles.btnText}>Open Rive (default)</Text> </Pressable> <Pressable style={styles.btn} onPress={() => router.push('/home/rive2')}> <Text style={styles.btnText}>Open Rive 2 (referenced assets)</Text> </Pressable> <Pressable style={styles.btn} onPress={() => router.push('/home/rive3')}> <Text style={styles.btnText}>Open Rive 3 (custom)</Text> </Pressable> </View> </ScrollView> ); } const styles = StyleSheet.create({ btn: { padding: 16, backgroundColor: '#007AFF', borderRadius: 8 }, gc: { padding: 16, backgroundColor: '#8E8E93', borderRadius: 8, alignItems: 'center' }, btnText: { color: '#FFF', fontSize: 16, fontWeight: '600' }, }); ``` ### `app/(tabs)/home/rive.tsx` ```tsx import { useRiveFile, RiveView, Fit } from '@rive-app/react-native'; import { router } from 'expo-router'; import { Pressable, StyleSheet, Text, View } from 'react-native'; export default function RiveScreen() { const { riveFile } = useRiveFile('https://cdn.rive.app/animations/vehicles.riv'); return ( <View style={{ flex: 1, paddingTop: 60 }}> <Pressable onPress={() => router.back()}> <Text style={{ padding: 16, color: '#0A7EA4', fontWeight: '600' }}>← Back</Text> </Pressable> {riveFile && <RiveView file={riveFile} fit={Fit.Contain} autoPlay style={{ flex: 1 }} />} </View> ); } ``` ### `app/(tabs)/home/rive2.tsx` ```tsx import { RiveMap } from '@/components/rive/RiveMap'; import { router } from 'expo-router'; import { Pressable, Text, useWindowDimensions, View } from 'react-native'; export default function Rive2Screen() { const { height } = useWindowDimensions(); return ( <View style={{ flex: 1 }}> <View style={{ flexDirection: 'row', alignItems: 'center', paddingTop: 60, padding: 16, gap: 12 }}> <Pressable onPress={() => router.back()}> <Text style={{ color: '#0A7EA4', fontWeight: '600' }}>← Back</Text> </Pressable> <Text style={{ fontSize: 18, fontWeight: '600' }}>Rive Map</Text> </View> <RiveMap height={height / 2} onComplete={() => router.back()} /> </View> ); } ``` ### `app/(tabs)/home/rive3.tsx` ```tsx import { RiveMilestone } from '@/components/rive/RiveMilestone'; import { router } from 'expo-router'; import { Pressable, Text, View } from 'react-native'; export default function Rive3Screen() { return ( <View style={{ flex: 1 }}> <View style={{ flexDirection: 'row', alignItems: 'center', paddingTop: 60, padding: 16, gap: 12 }}> <Pressable onPress={() => router.back()}> <Text style={{ color: '#0A7EA4', fontWeight: '600' }}>← Back</Text> </Pressable> <Text style={{ fontSize: 18, fontWeight: '600' }}>Rive Milestone</Text> </View> <View style={{ flex: 1 }}> <RiveMilestone milestoneNumber={3} onComplete={() => router.back()} /> </View> </View> ); } ``` ### `components/rive/RiveMap.tsx` ```tsx import { Fit, RiveView, useRive, useRiveBoolean, useRiveFile, useRiveNumber, useRiveTrigger, useViewModelInstance, } from '@rive-app/react-native'; import { useEffect } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useRiveMonster } from './useRiveMonster'; const BASE_URL = 'https://assets.adaptedmind.com/mobile/v2'; const monsterPartNames = [ 'baseballBat_m_1','chefGlove_m_1','headpart_1','monster_1_pattern_1', 'monster2_color_5','pirateBelt_m_5','eyes_1','mouth_1','monster1_color_1', ]; const monsterAssets = Object.fromEntries( monsterPartNames.map((p) => [p, { source: { uri: `${BASE_URL}/monsterParts/${p}.webp` } }]) ); const mapAssets = { Lemon: { source: { uri: `${BASE_URL}/rive/Lemon-Regular.ttf` } }, map1: { source: { uri: `${BASE_URL}/rive/map1-v1.webp` } }, map2: { source: { uri: `${BASE_URL}/rive/map2-v1.webp` } }, map3: { source: { uri: `${BASE_URL}/rive/map3-v1.webp` } }, map4: { source: { uri: `${BASE_URL}/rive/map4-v1.webp` } }, ...monsterAssets, }; export const RiveMap = ({ height, onComplete }: { height: number; onComplete: () => void }) => { const { riveFile } = useRiveFile(`${BASE_URL}/rive/map-v4.riv`, { referencedAssets: mapAssets }); const { instance } = useViewModelInstance(riveFile); const { setValue: setCurrentLevel } = useRiveNumber('currentLevel', instance); const { setValue: setWaiting } = useRiveBoolean('waiting', instance); const { setValue: setHop } = useRiveBoolean('avatarHop', instance); const { riveViewRef, setHybridRef } = useRive(); useRiveTrigger('animationComplete', instance, { onTrigger: onComplete }); useRiveMonster({ dataBind: instance, key: 'studentMonsterAvatarVM/', riveViewRef }); useEffect(() => { setHop(false); }, [setHop]); useEffect(() => { setWaiting(false); }, [setWaiting]); useEffect(() => { setCurrentLevel(3); riveViewRef?.playIfNeeded(); }, [setCurrentLevel, riveViewRef]); return ( <View style={{ height }}> {instance && riveFile ? ( <RiveView dataBind={instance} file={riveFile} fit={Fit.Cover} hybridRef={setHybridRef} style={StyleSheet.absoluteFill} /> ) : <Text>Loading...</Text>} </View> ); }; ``` ### `components/rive/RiveMilestone.tsx` ```tsx import { Fit, RiveView, useRive, useRiveFile, useRiveNumber, useRiveTrigger, useViewModelInstance, } from '@rive-app/react-native'; import { useCallback, useEffect } from 'react'; import { StyleSheet, View } from 'react-native'; const BASE_URL = 'https://assets.adaptedmind.com/mobile/v2'; export const RiveMilestone = ({ milestoneNumber, onComplete }: { milestoneNumber: number; onComplete: () => void }) => { const { riveFile, error } = useRiveFile(`${BASE_URL}/rive/chest-v2.riv`); const { riveRef, setHybridRef } = useRive(); const { instance } = useViewModelInstance(riveFile); const { setValue: setMilestoneNumber } = useRiveNumber('milestone', instance); const handleComplete = useCallback(async () => { await riveRef.current?.pause(); onComplete(); }, [onComplete, riveRef]); useRiveTrigger('animationComplete', instance, { onTrigger: handleComplete }); useEffect(() => { if (!instance || !riveFile) return; setMilestoneNumber(milestoneNumber); riveRef.current?.playIfNeeded(); }, [instance, milestoneNumber, riveFile, riveRef, setMilestoneNumber]); useEffect(() => { if (!error) return; void handleComplete(); }, [handleComplete, error]); return ( <View style={{ flex: 1 }}> {instance && riveFile ? ( <RiveView autoPlay dataBind={instance} file={riveFile} fit={Fit.Cover} hybridRef={setHybridRef} style={StyleSheet.absoluteFill} /> ) : null} </View> ); }; ``` ### `components/rive/useRiveMonster.ts` ```ts import { type RiveViewRef, useRiveEnum, type ViewModelInstance } from '@rive-app/react-native'; import { useEffect } from 'react'; export const useRiveMonster = ({ dataBind, key, riveViewRef }: { dataBind: ViewModelInstance | null | undefined; key: string; riveViewRef: RiveViewRef | null; }) => { const { setValue: setPG4 } = useRiveEnum(`${key}propGroup4`, dataBind); const { setValue: setPG3 } = useRiveEnum(`${key}propGroup3`, dataBind); const { setValue: setPG2 } = useRiveEnum(`${key}propGroup2`, dataBind); const { setValue: setPG1 } = useRiveEnum(`${key}propGroup1`, dataBind); const { setValue: setMouth } = useRiveEnum(`${key}mouth`, dataBind); const { setValue: setPattern } = useRiveEnum(`${key}pattern`, dataBind); const { setValue: setHeadpart } = useRiveEnum(`${key}headpart`, dataBind); const { setValue: setColor } = useRiveEnum(`${key}color`, dataBind); const { setValue: setEyes } = useRiveEnum(`${key}eyes`, dataBind); const { setValue: setMonsterType } = useRiveEnum(`${key}monsterType`, dataBind); useEffect(() => { setPG1('none'); setPG2('none'); setPG3('none'); setPG4('none'); setMouth('mouth1'); setPattern('pattern1'); setHeadpart('headpart1'); setColor('color1'); setEyes('eyes1'); setMonsterType('monster1'); riveViewRef?.playIfNeeded(); }, [setPG1, setPG2, setPG3, setPG4, setMouth, setPattern, setHeadpart, setColor, setEyes, setMonsterType, riveViewRef]); }; ``` </details>
1 parent 4fb46a1 commit c4b12d6

6 files changed

Lines changed: 70 additions & 14 deletions

File tree

android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
5151
private const val TAG = "HybridRiveView"
5252
}
5353

54+
//region Lifecycle
55+
override fun dispose() {
56+
view.dispose()
57+
}
58+
//endregion
59+
5460
//region State
5561
override val view: RiveReactNativeView = RiveReactNativeView(context)
5662
private var needsReload = false

ios/HybridRiveFileFactory.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,9 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl
3636

3737
let referencedAssetCache = SendableRef(ReferencedAssetCache())
3838
let factoryCache: SendableRef<RiveFactory?> = .init(nil)
39-
let fileRef: SendableRef<RiveFile?> = .init(nil)
4039
let customLoader = self.assetLoader.createCustomLoader(
4140
referencedAssets: referencedAssets, cache: referencedAssetCache,
42-
factory: factoryCache,
43-
fileRef: fileRef
41+
factory: factoryCache
4442
)
4543

4644
let riveFile =
@@ -49,7 +47,7 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl
4947
} else {
5048
try file(prepared)
5149
}
52-
fileRef.value = riveFile
50+
self.assetLoader.setFileRef(riveFile)
5351

5452
let result = (
5553
file: riveFile, cache: referencedAssetCache.value, factory: factoryCache.value,

ios/HybridRiveView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ class HybridRiveView: HybridRiveViewSpec {
197197
}
198198
}
199199

200+
func dispose() {
201+
let riveView = view as? RiveReactNativeView
202+
DispatchQueue.main.async {
203+
riveView?.cleanup()
204+
}
205+
}
206+
200207
// MARK: Internal State
201208
private var needsReload = false
202209
private var dataBindingChanged = false

ios/ReferencedAssetLoader.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError {
1111
}
1212

1313
final class ReferencedAssetLoader {
14+
private var activeLoadCount = 0
15+
private var activeFileRef: RiveFile?
16+
17+
func setFileRef(_ file: RiveFile) {
18+
activeFileRef = file
19+
}
20+
21+
private func retainFile() {
22+
activeLoadCount += 1
23+
}
24+
25+
private func releaseFile() {
26+
dispatchPrecondition(condition: .onQueue(.main))
27+
activeLoadCount -= 1
28+
if activeLoadCount <= 0 {
29+
activeLoadCount = 0
30+
activeFileRef = nil
31+
}
32+
}
33+
1434
private func handleRiveError(error: Error) {
1535
RCTLogError("\(error)")
1636
}
@@ -115,16 +135,15 @@ final class ReferencedAssetLoader {
115135

116136
func createCustomLoader(
117137
referencedAssets: ReferencedAssetsType?, cache: SendableRef<ReferencedAssetCache>,
118-
factory factoryOut: SendableRef<RiveFactory?>,
119-
fileRef: SendableRef<RiveFile?>
138+
factory factoryOut: SendableRef<RiveFactory?>
120139
)
121140
-> LoadAsset?
122141
{
123142
guard let referencedAssets = referencedAssets, let referencedAssets = referencedAssets.data
124143
else {
125144
return nil
126145
}
127-
return { (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in
146+
return { [weak self] (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in
128147
let assetByUniqueName = referencedAssets[asset.uniqueName()]
129148
guard let assetData = assetByUniqueName ?? referencedAssets[asset.name()] else {
130149
return false
@@ -133,10 +152,11 @@ final class ReferencedAssetLoader {
133152
cache.value[asset.uniqueName()] = asset
134153
factoryOut.value = factory
135154

136-
self.loadAssetInternal(
155+
self?.retainFile()
156+
self?.loadAssetInternal(
137157
source: assetData, asset: asset, factory: factory,
138-
completion: {
139-
withExtendedLifetime(fileRef) {}
158+
completion: { [weak self] in
159+
self?.releaseFile()
140160
})
141161

142162
return true

ios/RiveReactNativeView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ class RiveReactNativeView: UIView, RiveStateMachineDelegate {
242242
}
243243
}
244244

245-
private func cleanup() {
245+
func cleanup() {
246246
riveView?.removeFromSuperview()
247247
riveView?.stateMachineDelegate = nil
248248
riveView = nil

src/core/RiveView.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import type { ComponentProps } from 'react';
1+
import { useEffect, useRef, type ComponentProps } from 'react';
22
import { NitroRiveView } from './NitroRiveViewComponent';
33
import { RiveErrorType, type RiveError } from './Errors';
4+
import { callDispose } from './callDispose';
5+
import type { RiveViewRef } from '../index';
46

57
export interface RiveViewProps
68
extends Omit<ComponentProps<typeof NitroRiveView>, 'onError'> {
@@ -41,8 +43,31 @@ const defaultOnError = (error: RiveError) =>
4143
* - pause(): Pauses the Rive graphic
4244
*/
4345
export function RiveView(props: RiveViewProps) {
44-
const { onError, ...rest } = props;
46+
const { onError, hybridRef: userHybridRef, ...rest } = props;
4547
const wrappedOnError = onError ?? defaultOnError;
48+
const viewRef = useRef<RiveViewRef | null>(null);
4649

47-
return <NitroRiveView {...rest} onError={{ f: wrappedOnError }} />;
50+
useEffect(() => {
51+
return () => {
52+
if (viewRef.current) {
53+
callDispose(viewRef.current);
54+
viewRef.current = null;
55+
}
56+
};
57+
}, []);
58+
59+
const setRef = (ref: RiveViewRef) => {
60+
viewRef.current = ref;
61+
if (userHybridRef?.f) {
62+
userHybridRef.f(ref);
63+
}
64+
};
65+
66+
return (
67+
<NitroRiveView
68+
{...rest}
69+
onError={{ f: wrappedOnError }}
70+
hybridRef={{ f: setRef }}
71+
/>
72+
);
4873
}

0 commit comments

Comments
 (0)