diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c5c4f7..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 @@ -387,6 +392,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 ===" @@ -413,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 @@ -582,6 +596,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" @@ -606,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: | 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/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt b/android/src/main/java/com/margelo/nitro/rive/BaseHybridViewModelPropertyImpl.kt index 31dc90ff..bab106bd 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) { 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 e5e1e6ba..3394c98e 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 @@ -77,6 +78,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var lastFrameTimeNs = 0L private var frameCount = 0L + @Volatile + private var paused = false + private val textureView = TextureView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) surfaceTextureListener = object : TextureView.SurfaceTextureListener { @@ -129,13 +133,13 @@ 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 - // (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) { @@ -200,6 +204,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { Log.d(TAG, "configure: artboard=${artboardHandle != null} sm=${stateMachineHandle != null} surface=${riveSurface != null}") + paused = !config.autoPlay startRenderLoop() } @@ -208,7 +213,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (dataBindingChanged || initialUpdate) { applyDataBinding(config.bindData, config.riveFile) } - } private fun resizeArtboardIfLayout() { @@ -312,14 +316,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) } @@ -337,13 +338,22 @@ 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 */ } + // Deprecated: the experimental Rive runtime has no reset primitive. + fun reset() { + RiveLog.e(TAG, "reset() is deprecated and not supported on the experimental backend") + } - 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") diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index 367e5cdd..c90f2782 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,35 @@ function valueChanged(a: number, b: number): boolean { return Math.abs(a - b) > 0.001; } +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Polls prop.value directly: state-machine-driven changes don't fire addListener on all platforms. +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 +123,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 +175,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 +194,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 +258,105 @@ 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(); + }); + + // 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(); + + const context: TestContext = { ref: null, error: null }; + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + + await expect(context.ref!.reset()).resolves.toBeUndefined(); + + 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/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!(); diff --git a/example/__tests__/viewmodel-instance-lookup.harness.tsx b/example/__tests__/viewmodel-instance-lookup.harness.tsx index f60774f9..5410e356 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,63 @@ 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} + 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 () => { + if (!isAndroidExperimental) { + return; + } + 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(); + }); +}); 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 - diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 4ebb9800..f6d5c298 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,23 @@ class RiveReactNativeView: UIView { func play() { isPaused = false + riveUIView?.isPaused = false } func pause() { isPaused = true + riveUIView?.isPaused = true } func reset() { - 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() { if isPaused { isPaused = false + riveUIView?.isPaused = false } } @@ -180,7 +181,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([ 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 */