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..ff06dffd 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,29 @@ 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, which is what we pin. The nullable overload landed in 11.8.0
+ // (rive-app/rive-android#13261), but that release also dropped the public
+ // CommandQueue.settledFlow this backend needs for onStop — the replacement is
+ // internal — so we can't take it yet. 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..02975601 100644
--- a/example/__tests__/databinding-advanced.harness.ts
+++ b/example/__tests__/databinding-advanced.harness.ts
@@ -354,4 +354,23 @@ describe('Image Properties', () => {
const imageProp = instance.imageProperty('bound_image');
expectDefined(imageProp);
});
+
+ // Guards the iOS new backend, which threw "Invalid image type" on undefined. It cannot
+ // assert that the property was actually cleared: image properties are write-only and
+ // addListener is a no-op on both new backends, so the effect is only observable in
+ // pixels. On Android's new backend this passes because the call is deliberately
+ // dropped — setImage is non-null through rive-android 11.7.2. Green here does not mean
+ // clearing works.
+ it('imageProperty.set(undefined) does not throw', 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"
+
+
+
+
+
+
+
+
+ {status}
+
+ Clear returns the slot to empty. "Set then clear" must also end empty —
+ the in-flight decode must not overwrite the clear. Android on the new
+ runtime logs a warning and stays unchanged.
+
+
+ );
+}
+
+function ArtboardSection({
+ mainFile,
+ assetsFile,
+}: {
+ mainFile: RiveFile;
+ assetsFile: RiveFile;
+}) {
+ const { instance } = useViewModelInstance(mainFile, { async: true });
+ const [status, setStatus] = useState('idle');
+
+ const withProperty = (
+ label: string,
+ run: (property: ViewModelArtboardProperty) => void
+ ) => {
+ const property = instance?.artboardProperty('CharacterArtboard');
+ if (!property) {
+ setStatus('artboard property "CharacterArtboard" not found');
+ return;
+ }
+ try {
+ run(property);
+ setStatus(label);
+ } catch (e) {
+ setStatus(`threw: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ };
+
+ return (
+
+ Artboard property
+
+ swap_character_main.riv — "CharacterArtboard"
+
+
+
+
+
+
+
+
+ withProperty('artboard set', (property) => {
+ property.set(assetsFile.getBindableArtboard('Character 1'));
+ })
+ }
+ />
+
+ withProperty('cleared', (property) => {
+ property.set(undefined);
+ })
+ }
+ />
+
+ withProperty('set then cleared', (property) => {
+ property.set(assetsFile.getBindableArtboard('Character 1'));
+ property.set(undefined);
+ })
+ }
+ />
+
+
+ {status}
+
+ Clear removes the character and leaves the card empty. "Set then clear"
+ must also end empty.
+
+
+ );
+}
+
+function Button({ label, onPress }: { label: string; onPress: () => void }) {
+ return (
+
+ {label}
+
+ );
+}
+
+ClearImageAndArtboard.metadata = {
+ name: 'Clear Image / Artboard',
+ description:
+ 'Unset data-bound image and artboard properties by passing undefined',
+} satisfies Metadata;
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ gap: 24,
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ section: {
+ gap: 8,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ subtitle: {
+ fontSize: 13,
+ color: '#666',
+ },
+ riveContainer: {
+ height: 220,
+ backgroundColor: '#f2f2f2',
+ borderRadius: 8,
+ overflow: 'hidden',
+ },
+ rive: {
+ flex: 1,
+ },
+ buttons: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
+ button: {
+ backgroundColor: '#333',
+ paddingVertical: 10,
+ paddingHorizontal: 14,
+ borderRadius: 6,
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 14,
+ },
+ status: {
+ fontSize: 14,
+ fontFamily: 'Courier',
+ },
+ expected: {
+ fontSize: 12,
+ color: '#666',
+ },
+ error: {
+ color: 'red',
+ },
+});
diff --git a/ios/new/HybridViewModelArtboardProperty.swift b/ios/new/HybridViewModelArtboardProperty.swift
index 6b05fbd2..8fba4617 100644
--- a/ios/new/HybridViewModelArtboardProperty.swift
+++ b/ios/new/HybridViewModelArtboardProperty.swift
@@ -6,6 +6,10 @@ class HybridViewModelArtboardProperty: HybridViewModelArtboardPropertySpec {
private let prop: ArtboardProperty
private var currentArtboard: Artboard?
+ /// Bumped by every set(). Instantiating an artboard is async, so a slow one can finish after
+ /// a later set() has already applied — the generation it captured lets it detect that and bail.
+ @MainActor private var generation: UInt64 = 0
+
init(instance: ViewModelInstance, path: String) {
self.instance = instance
self.prop = ArtboardProperty(path: path)
@@ -13,14 +17,25 @@ class HybridViewModelArtboardProperty: HybridViewModelArtboardPropertySpec {
}
func set(artboard: (any HybridBindableArtboardSpec)?) throws {
+ guard let artboard = artboard else {
+ Task { @MainActor in
+ self.generation &+= 1
+ self.instance.setValue(of: self.prop, to: nil)
+ self.currentArtboard = nil
+ }
+ return
+ }
guard let hybridArtboard = artboard as? HybridBindableArtboard else {
- RCTLogWarn("[ArtboardProperty] set called with nil or incompatible artboard")
+ RCTLogWarn("[ArtboardProperty] set called with an incompatible artboard")
return
}
Task { @MainActor in
+ self.generation &+= 1
+ let generation = self.generation
do {
let newArtboard = try await hybridArtboard.file.createArtboard(hybridArtboard.artboardName)
+ guard generation == self.generation else { return }
self.currentArtboard = newArtboard
self.instance.setValue(of: self.prop, to: newArtboard)
} catch {
diff --git a/ios/new/HybridViewModelImageProperty.swift b/ios/new/HybridViewModelImageProperty.swift
index 37cdafcb..b7acdf4f 100644
--- a/ios/new/HybridViewModelImageProperty.swift
+++ b/ios/new/HybridViewModelImageProperty.swift
@@ -5,6 +5,11 @@ class HybridViewModelImageProperty: HybridViewModelImagePropertySpec {
private var instance: ViewModelInstance?
private var prop: ImageProperty?
private var worker: Worker?
+
+ /// 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.
+ @MainActor private var generation: UInt64 = 0
+
init(instance: ViewModelInstance, path: String, worker: Worker) {
self.instance = instance
self.prop = ImageProperty(path: path)
@@ -20,13 +25,23 @@ class HybridViewModelImageProperty: HybridViewModelImagePropertySpec {
guard let instance = instance, let prop = prop, let worker = worker else {
throw RuntimeError.error(withMessage: "ImageProperty not properly initialized")
}
+ guard let image = image else {
+ Task { @MainActor in
+ self.generation &+= 1
+ instance.setValue(of: prop, to: nil)
+ }
+ return
+ }
guard let hybridImage = image as? HybridRiveImage else {
throw RuntimeError.error(withMessage: "Invalid image type - expected HybridRiveImage")
}
Task { @MainActor in
+ self.generation &+= 1
+ let generation = self.generation
do {
let experimentalImage = try await worker.decodeImage(from: hybridImage.rawData)
+ guard generation == self.generation else { return }
instance.setValue(of: prop, to: experimentalImage)
} catch {
RCTLogError("HybridViewModelImageProperty: Failed to decode/set image: \(error)")
diff --git a/src/specs/ViewModel.nitro.ts b/src/specs/ViewModel.nitro.ts
index f0d852ca..947f73f5 100644
--- a/src/specs/ViewModel.nitro.ts
+++ b/src/specs/ViewModel.nitro.ts
@@ -233,7 +233,12 @@ export interface ViewModelTriggerProperty
export interface ViewModelImageProperty
extends ViewModelProperty,
ObservableProperty {
- /** Set the image property value */
+ /**
+ * Set the image property value.
+ *
+ * Pass undefined to clear the property back to its unset state and release the bound image.
+ * Clearing is not yet supported on Android when running the new runtime; the call is ignored.
+ */
set(image: RiveImage | undefined): void;
/** Add a listener to the view model image property. Returns a function to remove the listener. */
addListener(onChanged: () => void): () => void;