Skip to content

fix(iOS): eagerly dispose RiveView and break referenced asset retain cycle#202

Merged
mfazekas merged 7 commits into
mainfrom
fix/memory-leak-view-dispose
Apr 8, 2026
Merged

fix(iOS): eagerly dispose RiveView and break referenced asset retain cycle#202
mfazekas merged 7 commits into
mainfrom
fix/memory-leak-view-dispose

Conversation

@mfazekas
Copy link
Copy Markdown
Collaborator

@mfazekas mfazekas commented Apr 2, 2026

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)
Expo 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-screen

Set "main": "expo-router/entry" in package.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.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

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

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

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

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

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

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

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

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

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]);
};

mfazekas added 2 commits April 2, 2026 14:59
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 mfazekas changed the title fix: eagerly dispose RiveView and break referenced asset retain cycle fix(iOS): eagerly dispose RiveView and break referenced asset retain cycle Apr 2, 2026
@mfazekas mfazekas requested a review from HayesGordon April 7, 2026 07:19
Copy link
Copy Markdown
Contributor

@HayesGordon HayesGordon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mfazekas mfazekas merged commit c4b12d6 into main Apr 8, 2026
9 checks passed
@mfazekas mfazekas deleted the fix/memory-leak-view-dispose branch April 8, 2026 03:34
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants