Repository navigation
useRiveList: removeListeners() on a disposed list property segfaults on unmount in release builds #407
Description
Summary
useRiveList segfaults on unmount in release builds. Its listener cleanup calls property.removeListeners() on a list property that useDisposableMemo has already disposed. In debug builds Nitro throws a JS error at that call, and the hook's try/catch swallows it. In release builds the check is compiled out, so the call dereferences a null native object:
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000
#00 pc ... librive.so (margelo::nitro::HybridFunction margelo::nitro::HybridFunction::createHybridFunction<margelo::nitro::rive::HybridViewModelListPropertySpec, void>(...)::'lambda'(facebook::jsi::Runtime&, ...)::operator()(...) const+268)
...
#10 pc ... libreactnative.so (facebook::react::RuntimeScheduler_Modern::performMicrotaskCheckpoint(facebook::jsi::Runtime&)+88)
removeListeners() is the only no-argument void method on HybridViewModelListPropertySpec.
Versions
@rive-app/react-native0.4.21, with rive-android 11.9.1. The same hook code is in 0.4.20, 0.5.1 and currentmain(v0.5.2).- React Native 0.86.2 (Expo SDK 57),
react-native-nitro-modules0.35.10, new architecture, Hermes. - Confirmed: Android release, Galaxy S24 (Android 16).
- Not confirmed: iOS release. It should crash the same way, since the null-
thispath is Nitro's shared C++, but we haven't reproduced it there.
Root cause
src/hooks/useRiveList.ts:
const property = useDisposableMemo(
() => viewModelInstance?.listProperty(path),
(p) => p?.dispose(),
[viewModelInstance, path]
);
useEffect(() => {
if (!property) return;
const removeListener = property.addListener(() => setRevision((r) => r + 1));
return () => {
try {
removeListener();
property.removeListeners(); // ← runs on an already-disposed property
} catch {
// Property may already be disposed by useDisposableMemo (deps change).
}
};
}, [property]);- Disposal happens first.
- On unmount,
useDisposableMemo's own effect is declared before the listener effect, so its cleanup runs first. In production it callsdispose()synchronously; only in__DEV__is that deferred withsetTimeout(0). - On a deps change, the property is disposed during render, before any cleanup runs.
- On unmount,
dispose()nulls the native object. It sets the object's native state tonullptr(react-native-nitro-modules/cpp/core/HybridObject.cpp,disposeRaw).- Calling a method on it is only checked in debug.
HybridFunction::getHybridObjectNativeStatevalidates a null native state only under#ifdef NITRO_DEBUG(cpp/core/HybridFunction.hpp). In release the method runs on a null pointer, and atry/catchcan't catch a segfault.
The comment says native dispose() already handles listener cleanup, and it does: Android's HybridViewModelListProperty.dispose() calls removeListeners(), and iOS's BaseHybridViewModelProperty.dispose() calls try? removeListeners(). So the JS call is redundant as well as unsafe.
Repro
- Render any component that calls
useRiveList(path, instance)on a file with a list property. - Build release:
expo run:android --variant release, or an equivalentassembleRelease. - Unmount the component, for example by toggling its
keyor navigating away. The app dies with the SIGSEGV above. A debug build never crashes.
Suggested fix
Don't call anything on the property after it may have been disposed. The smallest fix is to drop property.removeListeners() from the cleanup, since dispose() already removes the listeners. The removeListener() closure that addListener returns survived dispose in our crash logs; only the removeListeners() method call crashed.
Alternatively, own the property in a single effect: create it, subscribe, and on cleanup unsubscribe first and dispose last. That's the workaround we ship.
Related observations (from building that workaround)
- Android: the list listener doesn't report a change before a view mounts (inferred). Our hook took
lengthfrom the listener alone. After a JS-sideremoveInstanceAt()it never saw the new length until aRiveViewwas mounted and advancing the artboard. That makes "trim the list, then mount the view" patterns hang. The library hook sidesteps this by ordering: its subscription's first emission happens to land after the trim. We now readlengthback after our own mutation instead of waiting on the listener. - iOS: a change queued before the unsubscribe can still be delivered after dispose (observed). We caught it in a release build on the simulator. The listener runs through
SyncJSCallbackafter cleanup, and a callback that readsproperty.lengththen segfaults incallMethod<HybridViewModelListPropertySpec, double>. The library hook's callback only bumps React state, so it isn't affected, but user code that touches the property insideaddListenercan be.
Related, but a different bug: #369 / #405 (fallback font picker held in a WeakReference).
Activity
fix: make useRiveList operations no-ops once the list property is dis…
mfazekas commentedon Oct 6, 2026
Thanks for the excellent root-cause analysis. We reproduced it on an Android release build (the same SIGSEGV on the first unmount) and fixed it in #408: the cleanup no longer calls
removeListeners(), and the list operations the hook returns are now safe to call after unmount too. The fix will ship in the next 0.5.x release. Thanks also for the two observations about list-listener timing on Android and iOS; we'll look into those separately.fix: don't call into a disposed list property when useRiveList unmounts
test: add a reproducer page for the useRiveList unmount crash (#407)
fix: make useRiveList operations no-ops once the list property is dis…
fix: don't call into a disposed list property when useRiveList unmoun…
chore(main): release 0.5.5 (#421)