From 16369a754864f3ad3cbd1e91a6618fbb8abd1c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 29 Jul 2026 20:18:44 +0200 Subject: [PATCH 1/3] fix: support clearing image and artboard properties on the new runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ViewModelImageProperty.set` and `ViewModelArtboardProperty.set` are typed to accept `undefined`, but both new-runtime backends dropped it: the iOS image property threw "Invalid image type", and the iOS artboard and Android artboard properties returned silently. Passing `undefined` now clears the property. Android image clearing stays a no-op for now — `ViewModelInstance.setImage` only accepts a non-null `ImageAsset` up to rive-android 11.7.2. The nullable overload landed upstream in rive-app/rive-android#13261 and ships in the next release; the call is logged instead of silently dropped until we bump the pin. Setting an image (and instantiating an artboard) is asynchronous, so a slow decode could previously land after a later `set()` and resurrect a stale value. Both properties now carry a generation counter and drop outdated completions. --- .../rive/HybridViewModelArtboardProperty.kt | 4 + .../rive/HybridViewModelImageProperty.kt | 17 + .../__tests__/databinding-advanced.harness.ts | 13 + .../src/reproducers/ClearImageAndArtboard.tsx | 298 ++++++++++++++++++ ios/new/HybridViewModelArtboardProperty.swift | 17 +- ios/new/HybridViewModelImageProperty.swift | 15 + src/specs/ViewModel.nitro.ts | 7 +- 7 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 example/src/reproducers/ClearImageAndArtboard.tsx diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt index e9232d72..68ebe6bc 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt @@ -18,6 +18,10 @@ class HybridViewModelArtboardProperty( } override fun set(artboard: HybridBindableArtboardSpec?) { + if (artboard == null) { + instance.setArtboard(path, null) + return + } val hybridArtboard = artboard as? HybridBindableArtboard ?: return val sourceFile = hybridArtboard.file.riveFile ?: return try { 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..0acef939 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.AtomicLong @Keep @DoNotStrip @@ -24,12 +25,28 @@ class HybridViewModelImageProperty( private val imageScope = CoroutineScope(Dispatchers.Default) + /** + * Bumped by every set(). Decoding is async, so a slow decode can finish after a later set() + * has already applied — the generation it captured lets it detect that and bail. + */ + private val generation = AtomicLong(0) + override fun set(image: HybridRiveImageSpec?) { + if (image == null) { + // rive-android's ViewModelInstance.setImage only accepts a non-null ImageAsset up to + // 11.7.2. The nullable overload that clears the property landed upstream in + // rive-app/rive-android#13261 and ships in the next release; until we bump the pin, + // dropping the call is all we can do here. Clearing works on legacy and on iOS. + Log.w(TAG, "Clearing image property '$path' is not supported by this rive-android version") + return + } val hybridImage = image as? HybridRiveImage ?: return + val setGeneration = generation.incrementAndGet() imageScope.launch { try { val result = ImageAsset.fromBytes(riveWorker, hybridImage.rawData) if (result is app.rive.Result.Success) { + if (generation.get() != setGeneration) return@launch instance.setImage(path, result.value) } else { Log.e(TAG, "Failed to decode image for path '$path'") diff --git a/example/__tests__/databinding-advanced.harness.ts b/example/__tests__/databinding-advanced.harness.ts index 110dc928..0983449e 100644 --- a/example/__tests__/databinding-advanced.harness.ts +++ b/example/__tests__/databinding-advanced.harness.ts @@ -354,4 +354,17 @@ describe('Image Properties', () => { const imageProp = instance.imageProperty('bound_image'); expectDefined(imageProp); }); + + it('imageProperty.set(undefined) clears without throwing', async () => { + const file = await loadFile(DATABINDING_IMAGES); + const vm = file.viewModelByName('MyViewModel'); + expectDefined(vm); + const instance = vm.createInstanceByIndex(0); + expectDefined(instance); + + const imageProp = instance.imageProperty('bound_image'); + expectDefined(imageProp); + + expect(() => imageProp.set(undefined)).not.toThrow(); + }); }); diff --git a/example/src/reproducers/ClearImageAndArtboard.tsx b/example/src/reproducers/ClearImageAndArtboard.tsx new file mode 100644 index 00000000..8132a923 --- /dev/null +++ b/example/src/reproducers/ClearImageAndArtboard.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + Fit, + RiveImages, + RiveView, + useRiveFile, + useViewModelInstance, + type RiveFile, + type ViewModelArtboardProperty, + type ViewModelImageProperty, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +const IMAGE_URL = 'https://picsum.photos/id/372/500/500'; + +/** + * Clearing a data-bound image or artboard property by passing `undefined`, which returns the + * slot to its unset state instead of overwriting it with a placeholder. + * + * Both new-runtime backends used to drop the undefined: the iOS image property threw + * "Invalid image type", and the iOS artboard and Android artboard properties returned silently. + * The "set then immediately clear" buttons cover the companion race — decoding/instantiating is + * async, so a slow set() could land after the clear and resurrect the old value. + * + * Android image clearing is still a no-op: rive-android's setImage only accepts a non-null + * ImageAsset up to 11.7.2. + */ +export default function ClearImageAndArtboard() { + const { riveFile: imageFile, isLoading: imageLoading } = useRiveFile( + require('../../assets/rive/many_viewmodels.riv') + ); + const { riveFile: mainFile, isLoading: mainLoading } = useRiveFile( + require('../../assets/swap_character_main.riv') + ); + const { riveFile: assetsFile, isLoading: assetsLoading } = useRiveFile( + require('../../assets/swap_character_assets.riv') + ); + + if (imageLoading || mainLoading || assetsLoading) { + return ( + + + + ); + } + + if (!imageFile || !mainFile || !assetsFile) { + return ( + + Failed to load Rive files + + ); + } + + return ( + + + + + ); +} + +function ImageSection({ file }: { file: RiveFile }) { + const { instance } = useViewModelInstance(file, { async: true }); + const [status, setStatus] = useState('idle'); + + const withProperty = async ( + label: string, + run: (property: ViewModelImageProperty) => Promise + ) => { + const property = instance?.imageProperty('imageValue'); + if (!property) { + setStatus('image property "imageValue" not found'); + return; + } + try { + await run(property); + setStatus(label); + } catch (e) { + setStatus(`threw: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + return ( + + Image property + many_viewmodels.riv — "imageValue" + + + + + + +