Commit c4b12d6
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
- ios
- src/core
Lines changed: 6 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
54 | 60 | | |
55 | 61 | | |
56 | 62 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
39 | | - | |
40 | 39 | | |
41 | 40 | | |
42 | | - | |
43 | | - | |
| 41 | + | |
44 | 42 | | |
45 | 43 | | |
46 | 44 | | |
| |||
49 | 47 | | |
50 | 48 | | |
51 | 49 | | |
52 | | - | |
| 50 | + | |
53 | 51 | | |
54 | 52 | | |
55 | 53 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
200 | 207 | | |
201 | 208 | | |
202 | 209 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
14 | 34 | | |
15 | 35 | | |
16 | 36 | | |
| |||
115 | 135 | | |
116 | 136 | | |
117 | 137 | | |
118 | | - | |
119 | | - | |
| 138 | + | |
120 | 139 | | |
121 | 140 | | |
122 | 141 | | |
123 | 142 | | |
124 | 143 | | |
125 | 144 | | |
126 | 145 | | |
127 | | - | |
| 146 | + | |
128 | 147 | | |
129 | 148 | | |
130 | 149 | | |
| |||
133 | 152 | | |
134 | 153 | | |
135 | 154 | | |
136 | | - | |
| 155 | + | |
| 156 | + | |
137 | 157 | | |
138 | | - | |
139 | | - | |
| 158 | + | |
| 159 | + | |
140 | 160 | | |
141 | 161 | | |
142 | 162 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | | - | |
| 245 | + | |
246 | 246 | | |
247 | 247 | | |
248 | 248 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
| 5 | + | |
4 | 6 | | |
5 | 7 | | |
6 | 8 | | |
| |||
41 | 43 | | |
42 | 44 | | |
43 | 45 | | |
44 | | - | |
| 46 | + | |
45 | 47 | | |
| 48 | + | |
46 | 49 | | |
47 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
48 | 73 | | |
0 commit comments