From dee54da9df44d135e0e9b5378b301fc87f675220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 3 Jun 2026 07:10:12 +0200 Subject: [PATCH] fix(android): defer autoplay until after VM instance is bound (#282) Entry transitions evaluated with default VM values before the user's instance was bound. Use settleInitialState=false when data binding is active so the state machine is created without advancing, then bind, then let the Choreographer frame advance with correct values. Also bumps rive-android from 11.4.0 to 11.6.1. --- .../main/java/com/rive/RiveReactNativeView.kt | 22 ++- .../__tests__/entry-databinding.harness.tsx | 149 ++++++++++++++++++ example/assets/rive/on_entry_test.riv | Bin 0 -> 848 bytes package.json | 2 +- 4 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 example/__tests__/entry-databinding.harness.tsx create mode 100644 example/assets/rive/on_entry_test.riv diff --git a/android/src/main/java/com/rive/RiveReactNativeView.kt b/android/src/main/java/com/rive/RiveReactNativeView.kt index a1d75cba..183bd5d7 100644 --- a/android/src/main/java/com/rive/RiveReactNativeView.kt +++ b/android/src/main/java/com/rive/RiveReactNativeView.kt @@ -106,21 +106,29 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { riveAnimationView?.layoutScaleFactor = config.layoutScaleFactor ?: resources.displayMetrics.density + val hasDataBinding = when (config.bindData) { + is BindData.None -> false + is BindData.Auto -> false + is BindData.Instance, is BindData.ByName -> true + } + if (reload) { - val hasDataBinding = when (config.bindData) { - is BindData.None -> false - is BindData.Auto -> false - is BindData.Instance, is BindData.ByName -> true - } + val deferAutoPlay = hasDataBinding && config.autoPlay riveAnimationView?.setRiveFile( config.riveFile, artboardName = config.artboardName, stateMachineName = config.stateMachineName, - autoplay = config.autoPlay, - autoBind = hasDataBinding, + autoplay = if (deferAutoPlay) false else config.autoPlay, + autoBind = if (deferAutoPlay) false else hasDataBinding, alignment = config.alignment, fit = config.fit ) + if (deferAutoPlay) { + // Create the state machine without advancing Entry transitions. + // The first Choreographer frame will advance it with the user's + // VM instance already bound via applyDataBinding() below. + riveAnimationView?.play(settleInitialState = false) + } _activeStateMachineName = getSafeStateMachineName() } else { riveAnimationView?.alignment = config.alignment diff --git a/example/__tests__/entry-databinding.harness.tsx b/example/__tests__/entry-databinding.harness.tsx new file mode 100644 index 00000000..d5e101b2 --- /dev/null +++ b/example/__tests__/entry-databinding.harness.tsx @@ -0,0 +1,149 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { View } from 'react-native'; +import { + RiveView, + RiveFileFactory, + Fit, + type RiveFile, + type RiveViewRef, + type ViewModelInstance, +} from '@rive-app/react-native'; + +// .riv file with two ViewModel number properties: +// - input: value we set before mount (default -1) +// - inputOnEntry: state machine copies input into this on Entry transition (default -100) +// Source: https://rive.app/community/files/27852-52628-on-entry-actions-reproducer +const ON_ENTRY_TEST = require('../assets/rive/on_entry_test.riv'); + +async function loadFile() { + const file = await RiveFileFactory.fromSource(ON_ENTRY_TEST, undefined); + return file; +} + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +type TestContext = { + ref: RiveViewRef | null; + error: string | null; +}; + +function TestView({ + file, + instance, + context, +}: { + file: RiveFile; + instance: ViewModelInstance; + context: TestContext; +}) { + return ( + + { + context.ref = ref; + }, + }} + style={{ flex: 1 }} + file={file} + autoPlay={true} + dataBind={instance} + fit={Fit.Contain} + onError={(e) => { + context.error = e.message; + }} + /> + + ); +} + +function waitForInputOnEntry( + instance: ViewModelInstance, + timeout = 5000 +): Promise { + return new Promise((resolve, reject) => { + const prop = instance.numberProperty('inputOnEntry'); + if (!prop) { + reject(new Error("Property 'inputOnEntry' not found")); + return; + } + + function done(value: number) { + clearTimeout(timer); + clearInterval(pollTimer); + removeListener(); + resolve(value); + } + + const timer = setTimeout(() => { + clearInterval(pollTimer); + removeListener(); + // If still -100 after timeout, resolve with current value (Entry never ran) + resolve(prop.value); + }, timeout); + + const removeListener = prop.addListener((newValue: number) => { + if (newValue !== -100) { + done(newValue); + } + }); + + const pollTimer = setInterval(() => { + const val = prop.value; + if (val !== -100) { + done(val); + } + }, 50); + }); +} + +describe('Entry transition uses user VM values (issue #282)', () => { + it('inputOnEntry reflects user-set input value, not the default', async () => { + const file = await loadFile(); + const vm = file.defaultArtboardViewModel(); + expectDefined(vm); + const instance = vm.createDefaultInstance(); + expectDefined(instance); + + // Set input to 42 before mounting — Entry should copy this into inputOnEntry + const inputProp = instance.numberProperty('input'); + expectDefined(inputProp); + // input default may vary per .riv file + inputProp.value = 42; + + const inputOnEntryProp = instance.numberProperty('inputOnEntry'); + expectDefined(inputOnEntryProp); + expect(inputOnEntryProp.value).toBe(-100); // default, not yet evaluated + + const context: TestContext = { ref: null, error: null }; + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + const inputOnEntry = await waitForInputOnEntry(instance); + + // Entry should have seen input=42 and copied it to inputOnEntry + // Bug: on Android, Entry evaluates with the default VM instance (input=-1) + // before the user's instance (input=42) is bound + expect(inputOnEntry).toBe(42); + expect(context.error).toBeNull(); + + cleanup(); + }); +}); diff --git a/example/assets/rive/on_entry_test.riv b/example/assets/rive/on_entry_test.riv new file mode 100644 index 0000000000000000000000000000000000000000..3d21e4edcf8ca737eaf02f81f567833e3b508598 GIT binary patch literal 848 zcmZXT&ubG=5XWa`=eZ%66>LC?KUxok94jbAsoG7*p`Zv>#FLoXRRd-#VO#7?dQg;9 zLTQ^zTOxRnLJ<*r&_hs!mL`ISUi<@^v}q``c#4HOyIn&}AIx{>eIIW>J8$0J?7tn? zar(n+{EkH|VGT2srAf-sEX~n8<>?J=YYV){OI+Y(UgaW}c%92!;SJv8DsOR(cX*el z)QnowfdSyG#5;*TiGYOb{Tpt60wx5e`fbNbmoPUmPEgfmRKOV?aMDTpA$-&pnMPCa zUY;sxKN)en^r)BKu;aFq9)nNX5(8_asU&>X6f7_lPT&wn zoEDCrrNIB3A_sjG_^6?H)POsNS5l}Mb*6zb4xz+pL4~qpWXNbf{7XR}5$P4g`Mc3s zlIsGY_Sif@_Yc!8uC1Ba(IAEo5yfdCwq+@tA>AVi>aDbQow3YVVccNcWUMl7G1eG& z7uK=)k)8 u3iWG=F)J<0eQ*V?f|)}NeraAa_vH5DK{MUn?#ZtpjfaZ1rW0JAMu5Mpxa2?p literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 62cee7ad..ad5850ee 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "homepage": "https://github.com/rive-app/rive-nitro-react-native#readme", "runtimeVersions": { "ios": "6.20.4", - "android": "11.4.0" + "android": "11.6.1" }, "publishConfig": { "registry": "https://registry.npmjs.org/"