From c97d33f954d3aefbd5b92aa75863c2f0f1877ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 29 Jun 2026 08:29:44 +0200 Subject: [PATCH 1/3] fix(android): release data-binding HybridObject native resources Each HybridViewModel*Property / HybridViewModelInstance is a small JS object that pins a JNI global reference plus a native Rive object. Two problems: - dispose() was hidden: a property both extends its generated *Spec (a HybridObject) and delegates BaseHybridViewModelProperty via 'by'; both declare dispose(), so the delegate's won and hid HybridObject.dispose(). Calling dispose() only cancelled the listener flow, never freeing the native part / global ref. Each property now overrides dispose() to do both. - memorySize was 0: Hermes GCs on JS-heap pressure, so it never collected these tiny-looking but native-heavy wrappers under churn, and the JNI global reference table (cap 51200) overflowed. Report memorySize so the GC collects them in time. --- .../com/margelo/nitro/rive/BaseHybridViewModelProperty.kt | 4 ++++ .../margelo/nitro/rive/HybridViewModelBooleanProperty.kt | 8 ++++++++ .../margelo/nitro/rive/HybridViewModelColorProperty.kt | 8 ++++++++ .../com/margelo/nitro/rive/HybridViewModelEnumProperty.kt | 8 ++++++++ .../margelo/nitro/rive/HybridViewModelImageProperty.kt | 8 ++++++++ .../com/margelo/nitro/rive/HybridViewModelInstance.kt | 3 +++ .../com/margelo/nitro/rive/HybridViewModelListProperty.kt | 8 ++++++++ .../margelo/nitro/rive/HybridViewModelNumberProperty.kt | 8 ++++++++ .../margelo/nitro/rive/HybridViewModelStringProperty.kt | 8 ++++++++ .../margelo/nitro/rive/HybridViewModelTriggerProperty.kt | 8 ++++++++ 10 files changed, 71 insertions(+) diff --git a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelProperty.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelProperty.kt index 1d8f4663..985f336e 100644 --- a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelProperty.kt @@ -6,6 +6,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +// memorySize hint: these wrappers look tiny to Hermes but each pins a JNI global ref, so +// without it the GC lets them pile up until the global reference table (cap 51200) overflows. +const val VIEW_MODEL_HYBRID_MEMORY_SIZE = 65_536L + @Keep @DoNotStrip interface BaseHybridViewModelProperty { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt index a8011cff..1feb307e 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt @@ -10,6 +10,14 @@ import com.margelo.nitro.core.Promise class HybridViewModelBooleanProperty(private val viewModelBoolean: ViewModelBooleanProperty) : HybridViewModelBooleanPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override var value: Boolean get() = viewModelBoolean.value set(value) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt index 7d44e514..a7c46303 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt @@ -10,6 +10,14 @@ import com.margelo.nitro.core.Promise class HybridViewModelColorProperty(private val viewModelColor: ViewModelColorProperty) : HybridViewModelColorPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override var value: Double get() = viewModelColor.value.toDouble() set(value) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt index 7f5b88e9..c6c85032 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt @@ -10,6 +10,14 @@ import com.margelo.nitro.core.Promise class HybridViewModelEnumProperty(private val viewModelEnum: ViewModelEnumProperty) : HybridViewModelEnumPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override var value: String get() = viewModelEnum.value set(value) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index 3504debb..3af01fbf 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -10,6 +10,14 @@ import kotlinx.coroutines.flow.map class HybridViewModelImageProperty(private val viewModelImage: ViewModelImageProperty) : HybridViewModelImagePropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override fun set(image: HybridRiveImageSpec?) { viewModelImage.set((image as? HybridRiveImage)?.renderImage) } diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelInstance.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelInstance.kt index b8bba139..12d55262 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelInstance.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelInstance.kt @@ -9,6 +9,9 @@ import com.margelo.nitro.core.Promise @Keep @DoNotStrip class HybridViewModelInstance(val viewModelInstance: ViewModelInstance) : HybridViewModelInstanceSpec() { + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override val instanceName: String get() = viewModelInstance.name diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt index 571303d8..e0ab3d8d 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt @@ -11,6 +11,14 @@ import kotlinx.coroutines.flow.map class HybridViewModelListProperty(private val listProperty: ViewModelListProperty) : HybridViewModelListPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override val length: Double get() = listProperty.size.toDouble() diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt index d07688d6..db4ab93c 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt @@ -11,6 +11,14 @@ import kotlinx.coroutines.flow.map class HybridViewModelNumberProperty(private val viewModelNumber: ViewModelNumberProperty) : HybridViewModelNumberPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override var value: Double get() = viewModelNumber.value.toDouble() set(value) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt index ba10ae36..7047a448 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt @@ -10,6 +10,14 @@ import com.margelo.nitro.core.Promise class HybridViewModelStringProperty(private val viewModelString: ViewModelStringProperty) : HybridViewModelStringPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override var value: String get() = viewModelString.value set(value) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt index b50dce55..f0139cf3 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt @@ -9,6 +9,14 @@ import com.facebook.proguard.annotations.DoNotStrip class HybridViewModelTriggerProperty(private val viewModelTrigger: ViewModelTriggerProperty) : HybridViewModelTriggerPropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { + override fun dispose() { + removeListeners() + super.dispose() + } + + override val memorySize: Long + get() = VIEW_MODEL_HYBRID_MEMORY_SIZE + override fun trigger() { viewModelTrigger.trigger() } From d6a0b8ccaac11c5e899ff94f5e9cc585bc620a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 29 Jun 2026 11:15:45 +0200 Subject: [PATCH 2/3] test(example): add toggleable JNI global-ref overflow reproducer Auto-registers under Reproducers. Churns view-model property/instance accessors (each pins a JNI global ref); without the memorySize fix the Android global reference table (cap 51200) overflows within seconds, with it the counter climbs indefinitely. --- .../reproducers/AndroidGlobalRefOverflow.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 example/src/reproducers/AndroidGlobalRefOverflow.tsx diff --git a/example/src/reproducers/AndroidGlobalRefOverflow.tsx b/example/src/reproducers/AndroidGlobalRefOverflow.tsx new file mode 100644 index 00000000..a7b329cb --- /dev/null +++ b/example/src/reproducers/AndroidGlobalRefOverflow.tsx @@ -0,0 +1,174 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- the repro needs the sync API */ +import { + View, + Text, + StyleSheet, + ActivityIndicator, + Button, +} from 'react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Fit, + RiveView, + useRiveFile, + useViewModelInstance, + type ViewModelInstance, + type RiveFile, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Manual reproducer for the Android JNI global-reference overflow. + * + * Every view-model property/instance accessor returns a fresh HybridObject that pins one + * JNI global reference. On Android the global reference table is capped at 51200; without + * the `memorySize` hint Hermes can't see how heavy these tiny JS objects are, so it never + * collects them under churn and the process aborts with + * `JNI ERROR (app bug): global reference table overflow`. + * + * Toggle the stress: on an unfixed build the app dies within seconds; on a fixed build the + * op counter climbs indefinitely. (No-op on iOS — there is no JNI global reference table.) + */ +const CONCURRENCY = 64; + +function StressRunner({ + instance, + file, +}: { + instance: ViewModelInstance; + file: RiveFile; +}) { + const [running, setRunning] = useState(false); + const [ops, setOps] = useState(0); + const [errors, setErrors] = useState(0); + + const runningRef = useRef(false); + const opsRef = useRef(0); + const errRef = useRef(0); + + const tick = useCallback(async () => { + const asyncWork: Promise[] = []; + for (let i = 0; i < CONCURRENCY; i++) { + const path = i % 2 === 0 ? 'vm1' : 'vm2'; + asyncWork.push( + instance + .viewModelAsync(path) + .then((nested) => nested?.stringProperty('name')?.getValueAsync()) + .catch(() => { + errRef.current += 1; + }) + ); + } + + for (let i = 0; i < CONCURRENCY; i++) { + try { + const namePath = i % 2 === 0 ? 'vm1/name' : 'vm2/name'; + const prop = instance.stringProperty(namePath); + prop?.set(`${prop?.value ?? ''}`.slice(0, 0) + 'x'); + instance.viewModel(i % 2 === 0 ? 'vm1' : 'vm2'); + } catch { + errRef.current += 1; + } + } + + await Promise.all(asyncWork); + opsRef.current += CONCURRENCY * 2; + }, [instance]); + + const loop = useCallback(async () => { + while (runningRef.current) { + await tick(); + setOps(opsRef.current); + setErrors(errRef.current); + await new Promise((r) => setTimeout(r, 0)); + } + }, [tick]); + + useEffect(() => { + return () => { + runningRef.current = false; + }; + }, []); + + const onToggle = () => { + if (runningRef.current) { + runningRef.current = false; + setRunning(false); + } else { + runningRef.current = true; + setRunning(true); + loop(); + } + }; + + return ( + + + + Android JNI global-ref overflow + property accessors: {ops} + errors: {errors} +