From 8d6995bb06cad35c3c4bfbbfd41e78d2d00b684a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 21 Oct 2026 13:57:14 +0200 Subject: [PATCH 1/5] fix(android): free decoded images on the new backend Every image property set() decoded a new ImageAsset on the command server and never closed it, so each swap leaked a decoded image. The handle is now closed once the property holds its own ref. set(undefined) clears the image instead of being ignored, and sets apply in call order. --- .../rive/HybridViewModelImageProperty.kt | 12 ++++-- .../__tests__/image-property-clear.harness.ts | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 example/__tests__/image-property-clear.harness.ts diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index 487a4e99..e9c5fa6c 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -22,15 +22,21 @@ class HybridViewModelImageProperty( private const val TAG = "HybridViewModelImageProperty" } - private val imageScope = CoroutineScope(Dispatchers.Default) + // Single-threaded so sets (including clears) apply in call order. + private val imageScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) override fun set(image: HybridRiveImageSpec?) { - val hybridImage = image as? HybridRiveImage ?: return + val hybridImage = image as? HybridRiveImage imageScope.launch { try { + if (hybridImage == null) { + instance.setImage(path, null) + return@launch + } val result = ImageAsset.fromBytes(riveWorker, hybridImage.rawData) if (result is app.rive.Result.Success) { - instance.setImage(path, result.value) + // The property keeps its own ref, so close ours right away. + result.value.use { instance.setImage(path, it) } } else { Log.e(TAG, "Failed to decode image for path '$path'") } diff --git a/example/__tests__/image-property-clear.harness.ts b/example/__tests__/image-property-clear.harness.ts new file mode 100644 index 00000000..2d77fe22 --- /dev/null +++ b/example/__tests__/image-property-clear.harness.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'react-native-harness'; +import { RiveFileFactory, RiveImages } from '@rive-app/react-native'; + +const DATABINDING_IMAGES = require('../assets/rive/databinding_images.riv'); + +// 1x1 red PNG +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'; + +function pngBytes(): ArrayBuffer { + const binary = atob(PNG_BASE64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +describe('ViewModelImageProperty', () => { + it('set(undefined) after set(image) clears without throwing', async () => { + const file = await RiveFileFactory.fromSource( + DATABINDING_IMAGES, + undefined + ); + const instance = file + .viewModelByName('MyViewModel') + ?.createInstanceByIndex(0); + const prop = instance?.imageProperty('bound_image'); + expect(prop).toBeDefined(); + + const image = await RiveImages.loadFromBytesAsync(pngBytes()); + for (let i = 0; i < 10; i++) prop!.set(image); + prop!.set(undefined); + prop!.set(image); + prop!.set(undefined); + + prop!.dispose(); + instance!.dispose(); + file.dispose(); + }); +}); From c1d6aa6550d407185ba965455266430769e07ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 22 Oct 2026 08:55:06 +0200 Subject: [PATCH 2/5] test: skip image clear on iOS new runtime until #360 --- example/__tests__/image-property-clear.harness.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/__tests__/image-property-clear.harness.ts b/example/__tests__/image-property-clear.harness.ts index 2d77fe22..b459d1c5 100644 --- a/example/__tests__/image-property-clear.harness.ts +++ b/example/__tests__/image-property-clear.harness.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'react-native-harness'; +import { Platform } from 'react-native'; import { RiveFileFactory, RiveImages } from '@rive-app/react-native'; const DATABINDING_IMAGES = require('../assets/rive/databinding_images.riv'); @@ -26,11 +27,15 @@ describe('ViewModelImageProperty', () => { const prop = instance?.imageProperty('bound_image'); expect(prop).toBeDefined(); + // The new iOS runtime can't clear image properties yet (#360) + const canClear = + Platform.OS !== 'ios' || RiveFileFactory.getBackend() !== 'experimental'; + const image = await RiveImages.loadFromBytesAsync(pngBytes()); for (let i = 0; i < 10; i++) prop!.set(image); - prop!.set(undefined); + if (canClear) prop!.set(undefined); prop!.set(image); - prop!.set(undefined); + if (canClear) prop!.set(undefined); prop!.dispose(); instance!.dispose(); From 6e2034746fab63117dcc7c87706c7a3d880723c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 28 Oct 2026 11:25:30 +0100 Subject: [PATCH 3/5] test: name the image clear test after what it checks --- example/__tests__/image-property-clear.harness.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/__tests__/image-property-clear.harness.ts b/example/__tests__/image-property-clear.harness.ts index b459d1c5..38ea430e 100644 --- a/example/__tests__/image-property-clear.harness.ts +++ b/example/__tests__/image-property-clear.harness.ts @@ -16,7 +16,7 @@ function pngBytes(): ArrayBuffer { } describe('ViewModelImageProperty', () => { - it('set(undefined) after set(image) clears without throwing', async () => { + it('repeated set(image) and set(undefined) do not throw', async () => { const file = await RiveFileFactory.fromSource( DATABINDING_IMAGES, undefined From 16c96fb70969dafbd6e4d800ed02e5eaa316e347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:44:21 +0100 Subject: [PATCH 4/5] fix(android): drop image decodes superseded by a later set or clear --- .../margelo/nitro/rive/HybridViewModelImageProperty.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index e9c5fa6c..c9ef0fa8 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -9,6 +9,7 @@ import com.facebook.proguard.annotations.DoNotStrip import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicInteger @Keep @DoNotStrip @@ -22,11 +23,15 @@ class HybridViewModelImageProperty( private const val TAG = "HybridViewModelImageProperty" } - // Single-threaded so sets (including clears) apply in call order. + // Single-threaded so a clear cannot run between the generation check and setImage. private val imageScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) + // Decoding suspends, so a decode that finishes after a newer set or clear must be dropped. + private val generation = AtomicInteger() + override fun set(image: HybridRiveImageSpec?) { val hybridImage = image as? HybridRiveImage + val gen = generation.incrementAndGet() imageScope.launch { try { if (hybridImage == null) { @@ -36,7 +41,7 @@ class HybridViewModelImageProperty( val result = ImageAsset.fromBytes(riveWorker, hybridImage.rawData) if (result is app.rive.Result.Success) { // The property keeps its own ref, so close ours right away. - result.value.use { instance.setImage(path, it) } + result.value.use { if (generation.get() == gen) instance.setImage(path, it) } } else { Log.e(TAG, "Failed to decode image for path '$path'") } From 9a27f55d04b3c6d2042980a3078e788df90d9484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:56:07 +0100 Subject: [PATCH 5/5] fix(android): share image set sequencing across property lookups --- .../rive/HybridViewModelImageProperty.kt | 26 +++++++++++-------- .../nitro/rive/HybridViewModelInstance.kt | 6 ++++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index c9ef0fa8..d8cc31d5 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -11,28 +11,32 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger +// Shared by every wrapper for one instance path, since JS may look the property up again per set. +internal class ImagePropertySequencer { + // Single-threaded so a clear cannot run between the generation check and setImage. + val scope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) + + // Decoding suspends, so a decode that finishes after a newer set or clear must be dropped. + val generation = AtomicInteger() +} + @Keep @DoNotStrip -class HybridViewModelImageProperty( +class HybridViewModelImageProperty internal constructor( private val instance: ViewModelInstance, private val path: String, - private val riveWorker: CommandQueue + private val riveWorker: CommandQueue, + private val sequencer: ImagePropertySequencer ) : HybridViewModelImagePropertySpec(), BaseHybridViewModelProperty by BaseHybridViewModelPropertyImpl() { companion object { private const val TAG = "HybridViewModelImageProperty" } - // Single-threaded so a clear cannot run between the generation check and setImage. - private val imageScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) - - // Decoding suspends, so a decode that finishes after a newer set or clear must be dropped. - private val generation = AtomicInteger() - override fun set(image: HybridRiveImageSpec?) { val hybridImage = image as? HybridRiveImage - val gen = generation.incrementAndGet() - imageScope.launch { + val gen = sequencer.generation.incrementAndGet() + sequencer.scope.launch { try { if (hybridImage == null) { instance.setImage(path, null) @@ -41,7 +45,7 @@ class HybridViewModelImageProperty( val result = ImageAsset.fromBytes(riveWorker, hybridImage.rawData) if (result is app.rive.Result.Success) { // The property keeps its own ref, so close ours right away. - result.value.use { if (generation.get() == gen) instance.setImage(path, it) } + result.value.use { if (sequencer.generation.get() == gen) instance.setImage(path, it) } } else { Log.e(TAG, "Failed to decode image for path '$path'") } diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelInstance.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelInstance.kt index c18385f4..b122daa4 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelInstance.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelInstance.kt @@ -7,6 +7,7 @@ import app.rive.ViewModelInstanceSource import app.rive.core.CommandQueue import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise +import java.util.concurrent.ConcurrentHashMap @Keep @DoNotStrip @@ -24,6 +25,8 @@ class HybridViewModelInstance( private const val TAG = "HybridViewModelInstance" } + private val imageSequencers = ConcurrentHashMap() + override fun dispose() { if (ownsInstance) { runCatching { viewModelInstance.close() } @@ -78,7 +81,8 @@ class HybridViewModelInstance( override fun imageProperty(path: String): HybridViewModelImagePropertySpec? { return try { - HybridViewModelImageProperty(viewModelInstance, path, riveWorker) + val sequencer = imageSequencers.getOrPut(path) { ImagePropertySequencer() } + HybridViewModelImageProperty(viewModelInstance, path, riveWorker, sequencer) } catch (e: Exception) { Log.e(TAG, "imageProperty failed for path '$path'", e) null