From 1397728ed48ab2a4c802ebdca33e79524253d14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 9 Jun 2026 17:49:23 +0200 Subject: [PATCH 01/18] fix(ios): make play/pause control the experimental render loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward isPaused to RiveUIView and pass it into the initializer so pause()/play() actually stop and resume playback, and autoPlay={false} starts paused. Adds harness tests for pause, resume, and autoPlay=false (reset left as a follow-up — experimental Rive has no reset primitive). --- example/__tests__/autoplay.harness.tsx | 193 +++++++++++++++++++------ ios/new/RiveReactNativeView.swift | 13 +- 2 files changed, 157 insertions(+), 49 deletions(-) diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index 367e5cdd..1c7d9d53 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -17,8 +17,6 @@ import { } from '@rive-app/react-native'; import type { ViewModelInstance } from '@rive-app/react-native'; -const isExperimental = RiveFileFactory.getBackend() === 'experimental'; - // Bouncing ball .riv with a "ypos" ViewModel number property that changes during playback // Source: https://rive.app/community/files/25997-48571-demo-for-tracking-rive-property-in-react-native/ const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); @@ -36,6 +34,39 @@ function valueChanged(a: number, b: number): boolean { return Math.abs(a - b) > 0.001; } +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Returns true if the property value changes within the timeout. Polls + * prop.value rather than relying on the listener, because state-machine-driven + * changes do not fire addListener on all platforms (e.g. iOS experimental). + */ +function pollChangedWithin( + instance: ViewModelInstance, + propertyName: string, + timeout = 800 +): Promise { + return new Promise((resolve) => { + const prop = instance.numberProperty(propertyName); + if (!prop) { + resolve(false); + return; + } + const initial = prop.value; + const timer = setTimeout(() => { + clearInterval(poll); + resolve(false); + }, timeout); + const poll = setInterval(() => { + if (valueChanged(prop.value, initial)) { + clearTimeout(timer); + clearInterval(poll); + resolve(true); + } + }, 50); + }); +} + async function loadBouncingBall() { const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); const vm = file.defaultArtboardViewModel(); @@ -96,44 +127,6 @@ function waitForPropertyChange( }); } -/** - * Returns true if the property value changes within the timeout, false otherwise. - */ -function didPropertyChange( - instance: ViewModelInstance, - propertyName: string, - timeout = 500 -): Promise { - return new Promise((resolve) => { - const prop = instance.numberProperty(propertyName); - if (!prop) { - resolve(false); - return; - } - - function done(changed: boolean) { - clearTimeout(timer); - removeListener(); - resolve(changed); - } - - const timer = setTimeout(() => done(false), timeout); - - let firstEmit = true; - let initialValue: number | undefined; - const removeListener = prop.addListener((newValue: number) => { - if (firstEmit) { - initialValue = newValue; - firstEmit = false; - return; - } - if (newValue !== initialValue) { - done(true); - } - }); - }); -} - type TestContext = { ref: RiveViewRef | null; error: string | null; @@ -186,9 +179,6 @@ describe('autoPlay prop (issue #138)', () => { }); it('autoPlay={false} does not change ypos property', async () => { - if (isExperimental) { - return; // experimental SDK has no pause API — always advances - } const { file, instance } = await loadBouncingBall(); const context: TestContext = { ref: null, error: null }; @@ -208,7 +198,7 @@ describe('autoPlay prop (issue #138)', () => { { timeout: 5000 } ); - const changed = await didPropertyChange(instance, 'ypos'); + const changed = await pollChangedWithin(instance, 'ypos', 800); expect(changed).toBe(false); expect(context.error).toBeNull(); @@ -272,6 +262,121 @@ describe('autoPlay prop (issue #138)', () => { }); }); +describe('imperative playback control (play/pause/reset)', () => { + // These observe the ypos ViewModel property to detect whether the state + // machine is advancing, instead of inspecting pixels. + + it('pause() stops ypos from advancing', async () => { + const { file, instance } = await loadBouncingBall(); + + const context: TestContext = { ref: null, error: null }; + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + // Confirm it is actually playing before we pause. + await waitForPropertyChange(instance, 'ypos'); + + await context.ref!.pause(); + // Let any in-flight frame settle so we don't catch a trailing advance. + await delay(100); + + const changedWhilePaused = await pollChangedWithin(instance, 'ypos', 800); + expect(changedWhilePaused).toBe(false); + expect(context.error).toBeNull(); + + cleanup(); + }); + + it('play() resumes ypos after pause()', async () => { + const { file, instance } = await loadBouncingBall(); + + const context: TestContext = { ref: null, error: null }; + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + await waitForPropertyChange(instance, 'ypos'); + await context.ref!.pause(); + await delay(100); + expect(await pollChangedWithin(instance, 'ypos', 800)).toBe(false); + + await context.ref!.play(); + const resumed = await waitForPropertyChange(instance, 'ypos'); + expect(typeof resumed).toBe('number'); + expect(context.error).toBeNull(); + + cleanup(); + }); + + // TODO: experimental iOS Rive has no reset primitive — "reset to initial + // state" needs recreating the artboard/state machine. Enable once implemented. + it.skip('reset() returns ypos to its initial state', async () => { + const { file, instance } = await loadBouncingBall(); + + // Authored default, read before any playback has advanced it. + const ypos = instance.numberProperty('ypos'); + expectDefined(ypos); + const initial = ypos.value; + + const context: TestContext = { ref: null, error: null }; + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + // Let it advance away from the initial value. + const moved = await waitForPropertyChange(instance, 'ypos'); + expect(valueChanged(moved, initial)).toBe(true); + + // Pause first so reset is observed against a frozen state machine rather + // than racing a frame that immediately re-advances ypos. + await context.ref!.pause(); + await context.ref!.reset(); + await delay(100); + + expect(valueChanged(ypos.value, initial)).toBe(false); + expect(context.error).toBeNull(); + + cleanup(); + }); +}); + describe('Auto dataBind with no default ViewModel (issue #189)', () => { it('auto-binds default ViewModel when one exists', async () => { // getViewModelInstance() returns null on Android experimental — auto-bind diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 4ebb9800..5887a70d 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -87,12 +87,9 @@ class RiveReactNativeView: UIView { guard !Task.isCancelled else { return } self.riveInstance = rive + self.isPaused = !config.autoPlay self.setupRiveUIView(with: rive) - if config.autoPlay { - self.isPaused = false - } - if !self.isViewReady { self.isViewReady = true for continuation in self.viewReadyContinuations { @@ -119,19 +116,25 @@ class RiveReactNativeView: UIView { func play() { isPaused = false + riveUIView?.isPaused = false } func pause() { isPaused = true + riveUIView?.isPaused = true } func reset() { + // TODO: experimental Rive has no reset primitive; "reset to initial state" + // requires recreating the artboard/state machine. Tracked as a follow-up. isPaused = true + riveUIView?.isPaused = true } func playIfNeeded() { if isPaused { isPaused = false + riveUIView?.isPaused = false } } @@ -180,7 +183,7 @@ class RiveReactNativeView: UIView { // This is a limitation of the experimental API - RiveUIView.rive is not publicly settable. riveUIView?.removeFromSuperview() - let uiView = RiveUIView(rive: rive) + let uiView = RiveUIView(rive: rive, isPaused: isPaused) uiView.translatesAutoresizingMaskIntoConstraints = false addSubview(uiView) NSLayoutConstraint.activate([ From 3c10b9fb6ca28335dd8f7834cc49298adcac12c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 08:06:47 +0200 Subject: [PATCH 02/18] fix(android): make play/pause control the experimental render loop Gate advanceStateMachine on a paused flag toggled by play/pause, and honor autoPlay (start paused when false). Mirrors the iOS fix; verified via the autoplay harness on an emulator. --- .../new/java/com/rive/RiveReactNativeView.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index e5e1e6ba..b45bb66c 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -76,6 +76,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var disposed = false private var lastFrameTimeNs = 0L private var frameCount = 0L + private var paused = false private val textureView = TextureView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) @@ -129,7 +130,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (worker != null && art != null && sm != null && rs != null) { try { - worker.advanceStateMachine(sm, deltaTime) + if (!paused) { + worker.advanceStateMachine(sm, deltaTime) + } worker.draw(art, sm, rs, activeFit) frameCount++ // Signal readiness only once the state machine is actually running @@ -200,6 +203,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { Log.d(TAG, "configure: artboard=${artboardHandle != null} sm=${stateMachineHandle != null} surface=${riveSurface != null}") + paused = !config.autoPlay startRenderLoop() } @@ -337,13 +341,23 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } - fun play() { /* controlled by render loop */ } + fun play() { + paused = false + } - fun pause() { /* controlled by render loop */ } + fun pause() { + paused = true + } - fun reset() { /* controlled by render loop */ } + // TODO: experimental Rive has no reset primitive; "reset to initial state" + // requires recreating the artboard/state machine. Tracked as a follow-up. + fun reset() { + paused = true + } - fun playIfNeeded() { /* controlled by render loop */ } + fun playIfNeeded() { + paused = false + } fun setNumberInputValue(name: String, value: Double, path: String?) { throw UnsupportedOperationException("SMI inputs not supported in experimental API") From b40058e59afa961805a767195798a14e7af89ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 08:43:56 +0200 Subject: [PATCH 03/18] deprecate reset() on experimental backend The experimental runtime has no reset primitive. Mark reset() @deprecated in the spec; the experimental native impl logs an error and does nothing. Legacy reset() is unchanged. Harness asserts reset() resolves without throwing. --- .../new/java/com/rive/RiveReactNativeView.kt | 6 ++--- example/__tests__/autoplay.harness.tsx | 24 ++++--------------- ios/new/RiveReactNativeView.swift | 6 ++--- src/specs/RiveView.nitro.ts | 6 ++++- 4 files changed, 14 insertions(+), 28 deletions(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index b45bb66c..896c3e17 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -18,6 +18,7 @@ import app.rive.core.RiveSurface import app.rive.core.StateMachineHandle import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.rive.RiveErrorLogger +import com.margelo.nitro.rive.RiveLog import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -349,10 +350,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { paused = true } - // TODO: experimental Rive has no reset primitive; "reset to initial state" - // requires recreating the artboard/state machine. Tracked as a follow-up. + // Deprecated: the experimental Rive runtime has no reset primitive. fun reset() { - paused = true + RiveLog.e(TAG, "reset() is deprecated and not supported on the experimental backend") } fun playIfNeeded() { diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index 1c7d9d53..28957bb2 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -333,16 +333,11 @@ describe('imperative playback control (play/pause/reset)', () => { cleanup(); }); - // TODO: experimental iOS Rive has no reset primitive — "reset to initial - // state" needs recreating the artboard/state machine. Enable once implemented. - it.skip('reset() returns ypos to its initial state', async () => { + // reset() is deprecated and a no-op on the experimental backend (no reset + // primitive in the runtime); it logs an error and resolves without throwing. + it('reset() resolves without throwing (deprecated no-op)', async () => { const { file, instance } = await loadBouncingBall(); - // Authored default, read before any playback has advanced it. - const ypos = instance.numberProperty('ypos'); - expectDefined(ypos); - const initial = ypos.value; - const context: TestContext = { ref: null, error: null }; await render( { { timeout: 5000 } ); - // Let it advance away from the initial value. - const moved = await waitForPropertyChange(instance, 'ypos'); - expect(valueChanged(moved, initial)).toBe(true); - - // Pause first so reset is observed against a frozen state machine rather - // than racing a frame that immediately re-advances ypos. - await context.ref!.pause(); - await context.ref!.reset(); - await delay(100); - - expect(valueChanged(ypos.value, initial)).toBe(false); - expect(context.error).toBeNull(); + await expect(context.ref!.reset()).resolves.toBeUndefined(); cleanup(); }); diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 5887a70d..f6d5c298 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -125,10 +125,8 @@ class RiveReactNativeView: UIView { } func reset() { - // TODO: experimental Rive has no reset primitive; "reset to initial state" - // requires recreating the artboard/state machine. Tracked as a follow-up. - isPaused = true - riveUIView?.isPaused = true + // Deprecated: the experimental Rive runtime has no reset primitive. + RiveLog.e("RiveReactNativeView", "reset() is deprecated and not supported on the experimental backend") } func playIfNeeded() { diff --git a/src/specs/RiveView.nitro.ts b/src/specs/RiveView.nitro.ts index 8b64648b..fa3c4ace 100644 --- a/src/specs/RiveView.nitro.ts +++ b/src/specs/RiveView.nitro.ts @@ -64,7 +64,11 @@ export interface RiveViewMethods extends HybridViewMethods { play(): Promise; /** Pauses the the Rive graphic */ pause(): Promise; - /** Resets the Rive graphic to its initial state */ + /** + * Resets the Rive graphic to its initial state. + * @deprecated Not supported on the experimental backend (logs an error and + * does nothing). + */ reset(): Promise; /** play if needed: low overhead function to make sure the rive graphics is playing. Use after property value update, to make sure graphics is updated */ From ab35d669faf8a5171df5158363dd23bcfb1155a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 14:05:04 +0200 Subject: [PATCH 04/18] trim comments and remove redundant jsdoc --- .../margelo/nitro/rive/HybridViewModelTriggerProperty.kt | 3 +-- android/src/new/java/com/rive/RiveReactNativeView.kt | 6 ++---- example/__tests__/autoplay.harness.tsx | 6 +----- example/__tests__/use-rive-trigger.harness.tsx | 9 --------- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt index ea8d5378..52f1803b 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt @@ -18,8 +18,7 @@ class HybridViewModelTriggerProperty( override fun addListener(onChanged: () -> Unit): () -> Unit { val remover = addListenerInternal { _ -> onChanged() } - // Triggers use drop=0: getTriggerFlow has replay=0 and emits nothing on - // subscription, so there is no initial value to skip. + // drop=0: getTriggerFlow (replay=0) emits nothing on subscription, unlike number/boolean flows. ensureValueListenerJob(instance.getTriggerFlow(path), 0) return remover } diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 896c3e17..eb8ed184 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -136,10 +136,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } worker.draw(art, sm, rs, activeFit) frameCount++ - // Signal readiness only once the state machine is actually running - // (surface created + first frame drawn), so callers awaiting - // awaitViewReady() before firing triggers don't fire too early. - if (frameCount == 1L) { + val isFirstFrame = frameCount == 1L + if (isFirstFrame) { viewReadyDeferred.complete(true) } } catch (e: Exception) { diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index 28957bb2..c90f2782 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -36,11 +36,7 @@ function valueChanged(a: number, b: number): boolean { const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); -/** - * Returns true if the property value changes within the timeout. Polls - * prop.value rather than relying on the listener, because state-machine-driven - * changes do not fire addListener on all platforms (e.g. iOS experimental). - */ +// Polls prop.value directly: state-machine-driven changes don't fire addListener on all platforms. function pollChangedWithin( instance: ViewModelInstance, propertyName: string, diff --git a/example/__tests__/use-rive-trigger.harness.tsx b/example/__tests__/use-rive-trigger.harness.tsx index feb0dff4..0fd54fc4 100644 --- a/example/__tests__/use-rive-trigger.harness.tsx +++ b/example/__tests__/use-rive-trigger.harness.tsx @@ -53,12 +53,6 @@ function createTriggerContext(): TriggerContext { }; } -/** - * Waits until the Rive view's state machine is live before firing triggers. - * A fireTrigger() issued before the surface/state-machine is advancing is - * dropped (the instance's trigger flow never emits it), so tests must wait for - * readiness first instead of firing synchronously on mount. - */ async function waitForViewReady(context: TriggerContext): Promise { await waitFor( () => { @@ -190,8 +184,6 @@ describe('useRiveTrigger hook', () => { expect(context.error).toBeNull(); - // Wait for the state machine to be live before firing — an early - // fireTrigger() (before the surface/state-machine is advancing) is dropped. await waitForViewReady(context); context.triggerFn!(); @@ -234,7 +226,6 @@ describe('useRiveTrigger hook', () => { expect(context.error).toBeNull(); - // Fire trigger AFTER the re-render burst — before the fix, this was lost await waitForViewReady(context); context.triggerFn!(); From 05c4e71ce2bd31aeb6d019de4bc9a5dc66786811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 15:48:35 +0200 Subject: [PATCH 05/18] fix(android): byName dataBind uses artboard default ViewModel, not vmNames.first() Previously always picked the first ViewModel in the file; now uses DefaultForArtboard like the Auto path and iOS, so the correct ViewModel is selected regardless of ordering. Also removes the runBlocking call. Added harness test: artboard2+byName(vmi1) must yield _id=vm2.vmi1.id. --- .../new/java/com/rive/RiveReactNativeView.kt | 13 ++--- .../viewmodel-instance-lookup.harness.tsx | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index eb8ed184..0499f751 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -315,14 +315,11 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } is BindData.ByName -> { try { - val vmNames = kotlinx.coroutines.runBlocking { riveFile.getViewModelNames() } - if (vmNames.isNotEmpty()) { - val vmSource = ViewModelSource.Named(vmNames.first()) - val source = vmSource.namedInstance(bindData.name) - val instance = ViewModelInstance.fromFile(riveFile, source) - boundInstance = instance - bindInstanceToStateMachine(instance) - } + val art = artboard ?: return + val source = ViewModelSource.DefaultForArtboard(art).namedInstance(bindData.name) + val instance = ViewModelInstance.fromFile(riveFile, source) + boundInstance = instance + bindInstanceToStateMachine(instance) } catch (e: Exception) { Log.e(TAG, "Failed to create named instance", e) } diff --git a/example/__tests__/viewmodel-instance-lookup.harness.tsx b/example/__tests__/viewmodel-instance-lookup.harness.tsx index f60774f9..ab64b912 100644 --- a/example/__tests__/viewmodel-instance-lookup.harness.tsx +++ b/example/__tests__/viewmodel-instance-lookup.harness.tsx @@ -9,10 +9,14 @@ import { import { useEffect } from 'react'; import { Platform, Text, View } from 'react-native'; import { + DataBindByName, + Fit, RiveFileFactory, + RiveView, ArtboardByName, useViewModelInstance, type RiveFile, + type RiveViewRef, } from '@rive-app/react-native'; import type { ViewModelInstance } from '@rive-app/react-native'; @@ -490,3 +494,57 @@ describe('useViewModelInstance onInit verifies _id', () => { cleanup(); }); }); + +// ── dataBind byName uses artboard's default ViewModel ─────────────── +// Regression for Android bug: ByName was always creating the instance from +// vmNames.first() instead of the artboard's default ViewModel. artboard2's +// default is viewmodel2 (not viewmodel1), so _id must be "vm2.vmi1.id". + +type ByNameCtx = { ref: RiveViewRef | null }; + +function ByNameView({ + file, + artboardName, + instanceName, + ctx, +}: { + file: RiveFile; + artboardName: string; + instanceName: string; + ctx: ByNameCtx; +}) { + return ( + + { ctx.ref = r; } }} + style={{ flex: 1 }} + file={file} + artboardName={artboardName} + stateMachineName="State Machine 1" + dataBind={new DataBindByName(instanceName)} + fit={Fit.Contain} + /> + + ); +} + +describe('dataBind byName uses artboard default ViewModel', () => { + it('artboard2 + byName("vmi1") → _id="vm2.vmi1.id" (not vm1)', async () => { + const file = await loadFile(); + const ctx: ByNameCtx = { ref: null }; + await render( + + ); + await waitFor(() => expect(ctx.ref).not.toBeNull(), { timeout: 5000 }); + await ctx.ref!.awaitViewReady(); + const instance = ctx.ref!.getViewModelInstance(); + expect(instance).toBeDefined(); + expect(instance!.stringProperty('_id')?.value).toBe('vm2.vmi1.id'); + cleanup(); + }); +}); From 416374de71105d96201761bb90a12c467d9c4332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 16:05:45 +0200 Subject: [PATCH 06/18] fix lint --- example/__tests__/viewmodel-instance-lookup.harness.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/example/__tests__/viewmodel-instance-lookup.harness.tsx b/example/__tests__/viewmodel-instance-lookup.harness.tsx index ab64b912..c8b245c1 100644 --- a/example/__tests__/viewmodel-instance-lookup.harness.tsx +++ b/example/__tests__/viewmodel-instance-lookup.harness.tsx @@ -516,7 +516,11 @@ function ByNameView({ return ( { ctx.ref = r; } }} + hybridRef={{ + f: (r: RiveViewRef | null) => { + ctx.ref = r; + }, + }} style={{ flex: 1 }} file={file} artboardName={artboardName} From 32fee8564107d1163ac88ac01d9efb76de4ee995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 16:15:27 +0200 Subject: [PATCH 07/18] fix(android): snapshot listeners before iterating to avoid ConcurrentModificationException onChanged runs on Dispatchers.Default; addListener/removeListener run on the JS thread. A concurrent mutation during iteration throws CME. The ensureValueListenerJob double-create concern in the review is not real since all calls come from the single-threaded JS side. --- .../com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt index 31dc90ff..ffb7e49c 100644 --- a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt +++ b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt @@ -35,7 +35,7 @@ class BaseHybridViewModelPropertyImpl : BaseHybridViewModelProperty { } override fun onChanged(value: T) { - listeners.values.forEach { listener -> + listeners.values.toList().forEach { listener -> listener(value) } } From 33fa8cae94765f5180fbf42f87ac38d600a7e35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 16:20:02 +0200 Subject: [PATCH 08/18] fix(android): use ConcurrentHashMap for listeners to prevent CME --- .../com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt index ffb7e49c..31dc90ff 100644 --- a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt +++ b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt @@ -35,7 +35,7 @@ class BaseHybridViewModelPropertyImpl : BaseHybridViewModelProperty { } override fun onChanged(value: T) { - listeners.values.toList().forEach { listener -> + listeners.values.forEach { listener -> listener(value) } } From b22468738353552e51c230bba9d4c36cf0060d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 16:25:30 +0200 Subject: [PATCH 09/18] Revert "fix(android): use ConcurrentHashMap for listeners to prevent CME" This reverts commit 54c28c1f97f462940141dd42456ee5b9d27a54c5. --- .../margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt index 31dc90ff..a779f09e 100644 --- a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt +++ b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt @@ -12,14 +12,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.drop import java.lang.ref.WeakReference import java.util.UUID -import java.util.concurrent.ConcurrentHashMap @Keep @DoNotStrip class BaseHybridViewModelPropertyImpl : BaseHybridViewModelProperty { override var scope: CoroutineScope? = null override var job: Job? = null - override val listeners = ConcurrentHashMap Unit>() + override val listeners = mutableMapOf Unit>() override fun ensureValueListenerJob(valueFlow: Flow, drop: Int) { if (scope == null) { @@ -35,7 +34,7 @@ class BaseHybridViewModelPropertyImpl : BaseHybridViewModelProperty { } override fun onChanged(value: T) { - listeners.values.forEach { listener -> + listeners.values.toList().forEach { listener -> listener(value) } } From 67e0294ceab606181edcc071b8650d51b98cf9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 16:25:30 +0200 Subject: [PATCH 10/18] Revert "fix(android): snapshot listeners before iterating to avoid ConcurrentModificationException" This reverts commit 8529abb82a323aba35c57fe0235edbc0e35a6d1a. --- .../com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt index a779f09e..bab106bd 100644 --- a/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt +++ b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt @@ -34,7 +34,7 @@ class BaseHybridViewModelPropertyImpl : BaseHybridViewModelProperty { } override fun onChanged(value: T) { - listeners.values.toList().forEach { listener -> + listeners.values.forEach { listener -> listener(value) } } From af7ed83a495bcddd49236e04f376b23c1b825021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 17:19:19 +0200 Subject: [PATCH 11/18] ci: add Metro health check to debug steps --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c5c4f7..aa324d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -387,6 +387,10 @@ jobs: - name: Debug - Check for console logs if: failure() || cancelled() run: | + echo "=== Metro health ===" + curl -s --max-time 5 http://localhost:8081/status || echo "Metro not responding" + echo "=== Metro process ===" + lsof -i:8081 | head -5 || echo "Nothing on port 8081" echo "=== Simulator logs (last 5m) ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found" echo "=== System log for Metro/Node ===" @@ -582,6 +586,10 @@ jobs: - name: Debug - Check for console logs if: failure() run: | + echo "=== Metro health ===" + curl -s --max-time 5 http://localhost:8081/status || echo "Metro not responding" + echo "=== Metro process ===" + lsof -i:8081 | head -5 || echo "Nothing on port 8081" echo "=== Checking simulator logs for errors ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found" From f381a1ddd40ca1bb9fdbe76b09ca3eed511270a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 17:48:08 +0200 Subject: [PATCH 12/18] fix(android): @Volatile on paused flag for cross-thread visibility --- android/src/new/java/com/rive/RiveReactNativeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 0499f751..ec77301b 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -77,7 +77,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var disposed = false private var lastFrameTimeNs = 0L private var frameCount = 0L - private var paused = false + @Volatile private var paused = false private val textureView = TextureView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) From e721645ecda374173327406064de9bfc88df493f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 17:50:56 +0200 Subject: [PATCH 13/18] Revert "fix(android): @Volatile on paused flag for cross-thread visibility" This reverts commit be58ddbdefba14ebc4e60b28027c9c55eeacb0f1. --- android/src/new/java/com/rive/RiveReactNativeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index ec77301b..0499f751 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -77,7 +77,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var disposed = false private var lastFrameTimeNs = 0L private var frameCount = 0L - @Volatile private var paused = false + private var paused = false private val textureView = TextureView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) From be7718aa94c94c34731b7a85f0383a19562fc377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 17:54:27 +0200 Subject: [PATCH 14/18] fix(android): @Volatile on paused for cross-thread visibility --- android/src/new/java/com/rive/RiveReactNativeView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 0499f751..3394c98e 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -77,6 +77,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var disposed = false private var lastFrameTimeNs = 0L private var frameCount = 0L + + @Volatile private var paused = false private val textureView = TextureView(context).apply { @@ -211,7 +213,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (dataBindingChanged || initialUpdate) { applyDataBinding(config.bindData, config.riveFile) } - } private fun resizeArtboardIfLayout() { From f0f414333a9f5f56356ec60950cdb747e5db746b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 18:04:57 +0200 Subject: [PATCH 15/18] ci: pre-install NDK with retry to avoid corrupt archive download --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa324d91..7ea064fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,11 @@ jobs: if: env.turbo_cache_hit != 1 run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + for i in 1 2 3; do + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break + echo "NDK install attempt $i failed, retrying..." + sleep 10 + done - name: Cache Gradle if: env.turbo_cache_hit != 1 @@ -417,6 +422,11 @@ jobs: - name: Finalize Android SDK run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + for i in 1 2 3; do + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break + echo "NDK install attempt $i failed, retrying..." + sleep 10 + done - name: Cache Gradle uses: actions/cache@v4 @@ -614,6 +624,11 @@ jobs: - name: Finalize Android SDK run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + for i in 1 2 3; do + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.1.12297006" && break + echo "NDK install attempt $i failed, retrying..." + sleep 10 + done - name: Enable legacy Rive backend run: | From c8c28177844d84040b5c6fd4947891e2c946ab5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 18:41:18 +0200 Subject: [PATCH 16/18] chore: remove tracked Kotlin build artifact, add .kotlin/ to .gitignore --- .gitignore | 3 +++ example/android/.kotlin/errors/errors-1774350400244.log | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 example/android/.kotlin/errors/errors-1774350400244.log diff --git a/.gitignore b/.gitignore index 0610dbd3..7f67d167 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ example/src/reproducers/local/* # Test coverage coverage/ + +# Kotlin build artifacts +**/.kotlin/ diff --git a/example/android/.kotlin/errors/errors-1774350400244.log b/example/android/.kotlin/errors/errors-1774350400244.log deleted file mode 100644 index 1219b509..00000000 --- a/example/android/.kotlin/errors/errors-1774350400244.log +++ /dev/null @@ -1,4 +0,0 @@ -kotlin version: 2.0.21 -error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: - 1. Kotlin compile daemon is ready - From f8aa8ef0522fbbfd662fa116b76b978d6f9834e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 20:51:45 +0200 Subject: [PATCH 17/18] fix(test): remove stateMachineName from byName test to prevent awaitViewReady hang on iOS --- example/__tests__/viewmodel-instance-lookup.harness.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/example/__tests__/viewmodel-instance-lookup.harness.tsx b/example/__tests__/viewmodel-instance-lookup.harness.tsx index c8b245c1..4c222701 100644 --- a/example/__tests__/viewmodel-instance-lookup.harness.tsx +++ b/example/__tests__/viewmodel-instance-lookup.harness.tsx @@ -524,7 +524,6 @@ function ByNameView({ style={{ flex: 1 }} file={file} artboardName={artboardName} - stateMachineName="State Machine 1" dataBind={new DataBindByName(instanceName)} fit={Fit.Contain} /> From ca36721913b33a9a8d02446f895d54884c4b3cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 10 Jun 2026 21:30:13 +0200 Subject: [PATCH 18/18] fix(test): guard byName test to experimental Android only to prevent crashes on legacy backends --- example/__tests__/viewmodel-instance-lookup.harness.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/example/__tests__/viewmodel-instance-lookup.harness.tsx b/example/__tests__/viewmodel-instance-lookup.harness.tsx index 4c222701..5410e356 100644 --- a/example/__tests__/viewmodel-instance-lookup.harness.tsx +++ b/example/__tests__/viewmodel-instance-lookup.harness.tsx @@ -533,6 +533,9 @@ function ByNameView({ describe('dataBind byName uses artboard default ViewModel', () => { it('artboard2 + byName("vmi1") → _id="vm2.vmi1.id" (not vm1)', async () => { + if (!isAndroidExperimental) { + return; + } const file = await loadFile(); const ctx: ByNameCtx = { ref: null }; await render(