Skip to content

useRiveList: removeListeners() on a disposed list property segfaults on unmount in release builds #407

Closed

Description

@maksym-kovalchuk-liven

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-native 0.4.21, with rive-android 11.9.1. The same hook code is in 0.4.20, 0.5.1 and current main (v0.5.2).
  • React Native 0.86.2 (Expo SDK 57), react-native-nitro-modules 0.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-this path 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]);
  1. Disposal happens first.
    • On unmount, useDisposableMemo's own effect is declared before the listener effect, so its cleanup runs first. In production it calls dispose() synchronously; only in __DEV__ is that deferred with setTimeout(0).
    • On a deps change, the property is disposed during render, before any cleanup runs.
  2. dispose() nulls the native object. It sets the object's native state to nullptr (react-native-nitro-modules/cpp/core/HybridObject.cpp, disposeRaw).
  3. Calling a method on it is only checked in debug. HybridFunction::getHybridObjectNativeState validates a null native state only under #ifdef NITRO_DEBUG (cpp/core/HybridFunction.hpp). In release the method runs on a null pointer, and a try/catch can'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

  1. Render any component that calls useRiveList(path, instance) on a file with a list property.
  2. Build release: expo run:android --variant release, or an equivalent assembleRelease.
  3. Unmount the component, for example by toggling its key or 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 length from the listener alone. After a JS-side removeInstanceAt() it never saw the new length until a RiveView was 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 read length back 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 SyncJSCallback after cleanup, and a callback that reads property.length then segfaults in callMethod<HybridViewModelListPropertySpec, double>. The library hook's callback only bumps React state, so it isn't affected, but user code that touches the property inside addListener can be.

Related, but a different bug: #369 / #405 (fallback font picker held in a WeakReference).

Activity

  1. mfazekas commented on Oct 6, 2026

    @mfazekas
    Collaborator

    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.

  2. added 3 commits that reference this issue on Oct 13, 2026
    130392a
    3e5bce8
    333e2c6
  3. added 2 commits that reference this issue on Oct 13, 2026
    bfa7aab
    751683b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions