Skip to content

fix(iOS): use per-call ReferencedAssetLoader to prevent concurrent access crash#218

Merged
mfazekas merged 2 commits into
mainfrom
fix/ios-rivefile-concurrent-access-crash
Apr 17, 2026
Merged

fix(iOS): use per-call ReferencedAssetLoader to prevent concurrent access crash#218
mfazekas merged 2 commits into
mainfrom
fix/ios-rivefile-concurrent-access-crash

Conversation

@mfazekas
Copy link
Copy Markdown
Collaborator

@mfazekas mfazekas commented Apr 17, 2026

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.

Reproducer component (Issue215.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' },
});

@mfazekas mfazekas force-pushed the fix/ios-rivefile-concurrent-access-crash branch 2 times, most recently from 352df3c to 6572e43 Compare April 17, 2026 10:38
@mfazekas mfazekas requested a review from HayesGordon April 17, 2026 10:48
@mfazekas mfazekas marked this pull request as ready for review April 17, 2026 10:48
…cess crash

The shared ReferencedAssetLoader on HybridRiveFileFactory caused a data
race when multiple RiveFile loads ran concurrently. Concurrent GCD threads
calling setFileRef() on the same instance wrote to activeFileRef without
synchronization — a non-atomic property write that double-released the
previous RiveFile, causing EXC_BAD_ACCESS crashes.
@mfazekas mfazekas force-pushed the fix/ios-rivefile-concurrent-access-crash branch from 6572e43 to 3adc017 Compare April 17, 2026 14:26
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 enabled auto-merge (squash) April 17, 2026 20:48
@mfazekas mfazekas merged commit d92574d into main Apr 17, 2026
9 checks passed
@mfazekas mfazekas deleted the fix/ios-rivefile-concurrent-access-crash branch April 17, 2026 20:57
mfazekas pushed a commit that referenced this pull request Apr 17, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.4.5](v0.4.4...v0.4.5)
(2026-04-17)


### Bug Fixes

* **iOS:** use per-call ReferencedAssetLoader to prevent concurrent
access crash
([#218](#218))
([d92574d](d92574d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
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.

iOS crash introduced in 0.4.1

2 participants